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
Anthropic: one prompt with 20+ content blocks misses the previous prompt cache entry #61689
The auto cache policy marks only the newest message. If one new message has 20+ blocks, for example 10 attached image files (each adds an "Attached file:" text block plus the image) or about 20 text files, that breakpoint can't reach the entry the previous request wrote. The whole conversation is then written to the cache again (1h writes cost 2x input) instead of being read.
Meanwhile the breakpoint on the first system part only covers the base prompt (~440 tokens) beyond the tools breakpoint.
I found this by reading cache-policy.ts against the docs and checked it with a simulation that runs the real request builder. I haven't captured it from a live session.
Use a Claude model through the anthropic provider.
After a few steps, send one prompt with 10 attached image files.
Expected: the conversation before the new prompt is read from the cache. Per the lookback rule it is written again instead; in the simulation that step writes about 56k tokens instead of about 14k.
Thanks for the detailed analysis. I could reproduce the breakpoint placement you describe on the latest dev (93f30ca). I used a mock Anthropic endpoint, since there are no credentials here, so I checked where the breakpoints land in the request rather than real cache usage numbers.
Steps: point the anthropic provider at a mock that records request bodies, send two plain prompts in a session, then send one prompt with 10 local images attached by file:// path (the way the desktop app attaches them).
In that last request, each image becomes an Attached file: … text block plus the image. The only conversation cache_control lands on the last text block, 21 block positions after the previous request's tail breakpoint, with no breakpoint in between. That is just past Anthropic's 20-block lookback, so going by the documented rule the previous entry can't be read. Also, the final image comes after that breakpoint, so it isn't covered by it.
opencode run -f doesn't trigger this: it sends images without the Attached file: text and puts text files inline in the prompt.
The script below reproduces it without credentials. It exits 1 and prints the gap when the breakpoint falls outside the lookback.
Related: #38725 (added the rolling tail breakpoint for this same lookback limit, but only for tool loops), #58801 (logging why a step missed the cache).
Reproduction script (repro.ts)
// Reproduces anomalyco/opencode#61689: with the default "auto" cache policy, one user message carrying 10 local// image files (each lowered to an "Attached file: <path>" text block plus the image) puts the only conversation// cache breakpoint more than 20 block positions after the previous request's breakpoint, which is beyond// Anthropic's documented 20-block lookback for cache hits.//// Run: bun repro.ts (uses `opencode` from PATH)// OC_DEV=/path/to/opencode-checkout bun repro.ts (runs the CLI from source)// No credentials needed: the anthropic provider is pointed at an in-process mock that records request bodies.import{mkdirSync,mkdtempSync,writeFileSync}from"node:fs"import{tmpdir}from"node:os"importpathfrom"node:path"import{deflateSync,crc32}from"node:zlib"constbodies: any[]=[]constev=(t: string,d: unknown)=>`event: ${t}\ndata: ${JSON.stringify(d)}\n\n`constmock=Bun.serve({port: 0,asyncfetch(req){if(!newURL(req.url).pathname.endsWith("/messages"))returnnewResponse("{}",{status: 404})constbody=awaitreq.json()bodies.push(body)returnnewResponse(ev("message_start",{type: "message_start",message: {id: "m",type: "message",role: "assistant",model: body.model,content: [],stop_reason: null,usage: {input_tokens: 1,output_tokens: 1}}})+ev("content_block_start",{type: "content_block_start",index: 0,content_block: {type: "text",text: ""}})+ev("content_block_delta",{type: "content_block_delta",index: 0,delta: {type: "text_delta",text: "ok"}})+ev("content_block_stop",{type: "content_block_stop",index: 0})+ev("message_delta",{type: "message_delta",delta: {stop_reason: "end_turn"},usage: {output_tokens: 1}})+ev("message_stop",{type: "message_stop"}),{headers: {"content-type": "text/event-stream"}},)},})// Isolated project + XDG dirs, 10 tiny PNGs.constroot=mkdtempSync(path.join(tmpdir(),"oc-61689-"))constproj=path.join(root,"proj")mkdirSync(proj)writeFileSync(path.join(proj,"opencode.json"),JSON.stringify({providers: {anthropic: {settings: {baseURL: `http://127.0.0.1:${mock.port}/v1`,apiKey: "test"}}}}))constchunk=(t: string,d: Buffer)=>{constlen=Buffer.alloc(4);len.writeUInt32BE(d.length)constcrc=Buffer.alloc(4);crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(t),d]))>>>0)returnBuffer.concat([len,Buffer.from(t),d,crc])}for(leti=0;i<10;i++){constihdr=Buffer.alloc(13);ihdr.writeUInt32BE(4,0);ihdr.writeUInt32BE(4,4);ihdr.set([8,6,0,0,0],8)constraw=Buffer.concat(Array.from({length: 4},()=>Buffer.from([0, ...Array(4).fill([i*20,100,200,255]).flat()])))writeFileSync(path.join(proj,`img${i}.png`),Buffer.concat([Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]),chunk("IHDR",ihdr),chunk("IDAT",deflateSync(raw)),chunk("IEND",Buffer.alloc(0))]))}constport=4196constenv={ ...process.env,OPENCODE_SERVER_PASSWORD: "pw",XDG_DATA_HOME: `${root}/data`,XDG_CONFIG_HOME: `${root}/config`,XDG_STATE_HOME: `${root}/state`,XDG_CACHE_HOME: `${root}/cache`}constcmd=process.env.OC_DEV
? ["bun","--conditions=browser",`--preload=${process.env.OC_DEV}/packages/tui/node_modules/@opentui/solid/scripts/preload.js`,`${process.env.OC_DEV}/packages/cli/src/index.ts`]
: ["opencode"]constserver=Bun.spawn([...cmd,"serve","--port",String(port)],{cwd: proj, env,stdout: "pipe",stderr: "inherit"})constbase=`http://127.0.0.1:${port}`constauth={authorization: "Basic "+btoa("opencode:pw"),"content-type": "application/json"}for(leti=0;;i++){if(awaitfetch(`${base}/api/session`,{headers: auth}).then((r)=>r.ok,()=>false))breakif(i>120)thrownewError("server did not start")awaitBun.sleep(500)}constpost=(p: string,b: unknown)=>fetch(base+p,{method: "POST",headers: auth,body: JSON.stringify(b)}).then((r)=>r.json()asany)constwaitFor=async(n: number)=>{for(leti=0;i<60&&bodies.filter((b)=>b.tools?.length).length<n;i++)awaitBun.sleep(250);awaitBun.sleep(500)}constsession=(awaitpost("/api/session",{model: {providerID: "anthropic",id: "claude-sonnet-4-5"}})).data.idawaitpost(`/api/session/${session}/prompt`,{text: "first question"})awaitwaitFor(1)awaitpost(`/api/session/${session}/prompt`,{text: "second question"})awaitwaitFor(2)awaitpost(`/api/session/${session}/prompt`,{text: "describe these images",files: Array.from({length: 10},(_,i)=>({uri: `file://${proj}/img${i}.png`,name: `img${i}.png`})),})awaitwaitFor(3)server.kill();mock.stop(true)// Block positions of the messages array; runs of tool_use / tool_result count as one (Anthropic's rule).constpositions=(body: any)=>{constall=body.messages.flatMap((m: any)=>(typeofm.content==="string" ? [{type: "text"}] : m.content))returnall.filter((c: any,i: number)=>!(i>0&&(c.type==="tool_use"||c.type==="tool_result")&&all[i-1].type===c.type))}constmain=bodies.filter((b)=>b.tools?.length)if(main.length<3)thrownewError(`expected 3 agent requests, got ${main.length} (of ${bodies.length})`)main.forEach((b,i)=>console.log(`request ${i}:`,positions(b).map((c: any)=>c.type+(c.cache_control ? "*" : "")).join(" ")))constlastBp=(b: any)=>positions(b).findLastIndex((c: any)=>c.cache_control)const[prev,next]=main.slice(-2)constdistance=lastBp(next)-lastBp(prev)console.log(`previous tail breakpoint at position ${lastBp(prev)}, new tail breakpoint at ${lastBp(next)}, distance ${distance}`)console.log(distance>20 ? "BUG: no breakpoint within 20 blocks of the previous cache entry -> Anthropic cannot read it" : "OK: within lookback")process.exit(distance>20 ? 1 : 0)
Activity
opencodejr commentedon Nov 4, 2026
Thanks for the detailed analysis. I could reproduce the breakpoint placement you describe on the latest
dev(93f30ca). I used a mock Anthropic endpoint, since there are no credentials here, so I checked where the breakpoints land in the request rather than real cache usage numbers.Steps: point the
anthropicprovider at a mock that records request bodies, send two plain prompts in a session, then send one prompt with 10 local images attached byfile://path (the way the desktop app attaches them).In that last request, each image becomes an
Attached file: …text block plus the image. The only conversationcache_controllands on the last text block, 21 block positions after the previous request's tail breakpoint, with no breakpoint in between. That is just past Anthropic's 20-block lookback, so going by the documented rule the previous entry can't be read. Also, the final image comes after that breakpoint, so it isn't covered by it.opencode run -fdoesn't trigger this: it sends images without theAttached file:text and puts text files inline in the prompt.The script below reproduces it without credentials. It exits 1 and prints the gap when the breakpoint falls outside the lookback.
Related: #38725 (added the rolling tail breakpoint for this same lookback limit, but only for tool loops), #58801 (logging why a step missed the cache).
Reproduction script (repro.ts)
Run it:
bun repro.ts