You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Read tool is not byte-faithful: invalid UTF-8 silently replaced, truncated multi-byte accepted as text #57837
The Read tool's non-paged path decodes file bytes with a non-fatal TextDecoder, silently replacing invalid UTF-8 sequences with U+FFFD and losing original bytes. Additionally Mime.detect's isText() streams a chunk without flushing, so truncated trailing multi-byte UTF-8 sequences are accepted as valid text instead of being classified as binary. This causes exact-match edits (and any byte-level work) to fail because the bytes the model sees differ from the file. Fix should treat invalid/truncated UTF-8 as binary and flush the decoder.
// Repro for #57837: the read tool returns lossy text (U+FFFD) for files containing invalid UTF-8.import{Effect,Stream}from"effect"import{Llm,OpenCodeDriver}from"opencode-drive"constenc=(s: string)=>newTextEncoder().encode(s)constcat=(...parts: Uint8Array[])=>{constout=newUint8Array(parts.reduce((n,p)=>n+p.length,0))leto=0for(constpofparts)(out.set(p,o),(o+=p.length))returnout}// GBK bytes for 中文 (invalid UTF-8) plus a stray 0xffconstinvalid=cat(enc("const x = 1\n// "),newUint8Array([0xd6,0xd0,0xce,0xc4,0xff]),enc("\n"))// valid text ending in a truncated 3-byte UTF-8 sequence (first 2 bytes of 中 = e4 b8 ad)consttruncated=cat(enc("hello world\n"),newUint8Array([0xe4,0xb8]))exportdefaultOpenCodeDriver.use({
...(process.env.OC_DEV ? {opencode: {dev: process.env.OC_DEV}} : {}),project: {git: true,files: {"invalid.txt": invalid,"truncated.txt": truncated}},tui: {recording: true},},({ ui, llm })=>Effect.gen(function*(){letcalled=falseyield*llm.serve((request: any,index)=>{console.log("LLM request",index)if(JSON.stringify(request).includes("You name conversations"))returnStream.make(Llm.text("Read test"))if(!called&&(called=true))returnStream.make(Llm.toolCall({index: 0,id: "call_invalid",name: "read",input: {path: "invalid.txt"}}),Llm.toolCall({index: 1,id: "call_trunc",name: "read",input: {path: "truncated.txt"}}),Llm.finish("tool-calls"),)constbody=JSON.stringify(request)constfffd=(body.match(/\uFFFD|\\ufffd/gi)||[]).lengthconstfound: any[]=[]constwalk=(v: any)=>{if(Array.isArray(v))returnv.forEach(walk)if(v&&typeofv==="object"){if(v.role==="tool")found.push(v)Object.values(v).forEach(walk)}}walk(request)constmsgs=foundfor(constmofmsgs)console.log("TOOL RESULT",m.tool_call_id,JSON.stringify(m.content))console.log("U+FFFD occurrences in model request:",fffd)returnStream.make(Llm.text(`Tool results contained ${fffd} U+FFFD replacement characters.`))})yield*ui.submit("Read invalid.txt and truncated.txt")yield*ui.waitFor("replacement characters",{timeout: 60000})yield*Effect.sleep(1500)yield*ui.screenshot("result")}),)
Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.
Activity
opencode-agent commentedon Oct 21, 2026
Thanks for the clear report. I reproduced this on v2.1.2 and on the latest
dev(002d7bd81), using a simulated model:invalid.txtholdsconst x = 1followed by a comment line containing the bytesd6 d0 ce c4 ff(GBK text plus a stray0xff).truncated.txtholdshello world\nfollowed bye4 b8, a 3-byte UTF-8 character cut off after two bytes.The
readtool rejected neither file as binary. Both came back as normal text with U+FFFD in place of the original bytes:So the model sees content that differs from the file on disk, as you describe. The script below reproduces it, and the recording shows the run.
Related:
edit/apply_patch, which can corrupt files when they're written back.recording.mp4
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.