[BUG] Image tool results can't be persisted, so budget eviction replaces them with a sentinel mid-history and invalidates the prompt cache
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Tool-result budget eviction has a text-only persistence path. Text results are written to disk and swapped for a byte-stable <persisted-output> reference, so the request bytes are identical on every subsequent call and the prompt cache survives. Tool results containing an image or document cannot be persisted, so they are replaced with the bare literal [Old tool result content cleared] instead.
That replacement lands in the middle of the message array, which invalidates every cached token after it. On a large session this re-writes the whole conversation at the cache-creation rate.
Persistence explicitly refuses non-text content (2.1.228 native binary):
async function oVe(e, t) {
let r = Array.isArray(e);
if (r) {
if (e.some((l) => l.type !== "text"))
return { error: "Cannot persist tool results containing non-text content" };
}
}
The replacement chooser short-circuits to the literal for image/document content:
function H0n(e, t, r) {
let l = Array.isArray(s.content)
&& s.content.some((c) => c.type === "image" || c.type === "document")
? S5o // "[Old tool result content cleared]"
: r?.get(s.tool_use_id) ?? S5o; // stable <persisted-output> reference
}
var S5o = "[Old tool result content cleared]";
Eviction is largest-first, and an image is charged a flat 1,600 tokens, so images are preferentially selected:
function ry_(e, t, r) {
let n = [...e].sort((s, a) => a.size - s.size); // largest first
let o = [], i = t + e.reduce((s, a) => s + a.size, 0);
for (let s of n) { if (i <= r) break; o.push(s); i -= s.size; }
return o;
}
var ppf = 1600; // an image's charged size
Constants: qZu = 200000 (per-message tool-result budget), Shn = 50000, VZu = 400000, vhn = 4.
Replacement bookkeeping is in-memory only, so a restart or resume loses it and replacements are recomputed — a second invalidation with no new content added:
function ty_(e, t) {
return e.reduce((r, n) => {
let o = t.replacements.get(n.toolUseId);
if (o !== void 0) r.mustReapply.push({ ...n, replacement: o });
else if (t.seenIds.has(n.toolUseId)) r.frozen.push(n);
else r.fresh.push(n);
return r;
}, { mustReapply: [], frozen: [], fresh: [] });
}
Measured impact. Restricting to calls above 200k context with gaps under 1 hour (excluding ordinary TTL expiry):
| | rebuilt (>100k cache_creation) | did not | rate |
|---|---:|---:|---:|
| turn where an image entered | 41 | 185 | 18.1% |
| text-only turn | 1 | 866 | 0.1% |
Fisher exact one-sided p = 1.34e-28. Replicated in a second session: 4/47 vs 0/193, p = 0.0013. Roughly $254 of cache creation across ~35 turns in one evening, and two 5-hour limits hit in a single day.
On every rebuild cache_read_input_tokens collapsed to 25,970–37,837 — exactly system prompt + tools, i.e. only the non-message caches survived.
The conversation shrinks while an image is being added, which is the eviction visible from outside:
23:26:11 1 image in context -718 cache_creation 775,698
23:30:00 1 image in context -718 cache_creation 780,529
23:33:25 1 image in context -718 cache_creation 791,418
23:37:54 1 image in context -718 cache_creation 799,796
-718 repeating to the token is deterministic eviction, not noise. Larger deltas cluster at −1,411 / −1,429 / −1,464 / −1,511, consistent with ppf = 1600.
It is not simply "large context". Same session, same day: 52 images at 873k context → 0 rebuilds at 14:00; 12 images at 843k context → 10 rebuilds at 20:00. A controlled throwaway session at ~100k context read one image for 308 cache-creation tokens.
It is also invisible: nothing is logged to the user, /context doesn't show it, and the transcript on disk retains the original content, so grepping the transcript for the sentinel finds nothing. The mutation happens between transcript and request.
What Should Happen?
Evicting a tool result should never change the request bytes in a way that invalidates the prompt cache prefix, which is exactly what the <persisted-output> mechanism already achieves for text.
Preferred fix, in order:
- Persist image results too. Write the image block to the tool-results directory and reference it, as text already is. The byte-stable mechanism exists; images were never wired into it.
- Or exclude image results from eviction. At a flat 1,600 tokens they are cheap to keep, and evicting one costs the entire conversation cache.
- Or make the sentinel byte-stable per
tool_use_id, so a re-evicted result doesn't churn the prefix when replacement state is recomputed. - Surface it either way. A user losing an 800k cache should see something in the UI or
/context.
Error Messages/Logs
No user-visible error is produced, that is part of the problem. The only signal is the usage accounting in the session transcript:
23:39 cache_write 1,623 cache_read 841,625 normal turn
23:39 cache_write 803,181 cache_read 37,837 <-- invalidated
23:40 cache_write 803,586 cache_read 37,837 <-- invalidated
23:40 cache_write 3,514 cache_read 841,423 recovered
`--debug` does not log this path; its cache-related lines cover CA certificates and plugin marketplaces only.
Steps to Reproduce
- Start a session and let context grow past ~200k tokens (any ordinary tool-heavy work).
- Accumulate tool results until at least one user message carries more than 200,000 characters of them (
qZu). In my transcripts 158 messages exceeded this in a single day; the largest was 677,000 characters. - Read several images with the
Readtool over the following turns. - Inspect per-call usage in
~/.claude/projects/<project>/<session>.jsonl:
import json
path = "<session>.jsonl"
seen = set()
for line in open(path, encoding="utf-8", errors="replace"):
if '"usage"' not in line: continue
try: rec = json.loads(line)
except Exception: continue
u = (rec.get("message") or {}).get("usage")
if not isinstance(u, dict): continue
cw = u.get("cache_creation_input_tokens", 0) or 0
cr = u.get("cache_read_input_tokens", 0) or 0
key = (rec.get("timestamp"), cw, cr)
if key in seen: continue
seen.add(key)
flag = " <-- REBUILD" if cw > 100_000 else ""
print(f'{rec.get("timestamp")} write {cw:>9,} read {cr:>9,}{flag}')
Failure signature: cache_read pinned near system+tools size while cache_creation stays near full context, turn after turn, on exactly the turns where an image entered.
Below ~200k context it does not reproduce, one image cost 308 cache-creation tokens in a controlled test.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
Unclear. The code path is present in 2.1.228 and the behaviour reproduces on 2.1.220 and 2.1.226, so it does not appear to be newly introduced. However multiple reports of sharply increased quota burn cluster from ~2026-08-08 (#86033), which may indicate a server-side configuration change altered how often this path is reached.
Claude Code Version
2.1.228
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
VS Code integrated terminal
Additional Information
Related issues, which report symptoms this code path would produce:
- #86033 — 15–20× 5-hour quota consumption since ~2026-08-08, previously intermittent and self-resolving, now persistent. Also reports a turn with 8 input tokens registering 4% of a 5-hour window, which may be a separate accounting bug.
- #85983 — a single message removed from history dropping prompt-cache hit rate from 99.52% to 15.94%, i.e. the same class of failure via the
max_tokensrecovery path. - #42542 — silent microcompact / context stripping.
Identifiers above are from the shipped minified bundle, so names are mangled and I have not traced every call site of the eviction entry points. All measurements come from my own session transcripts and are reproducible from the .jsonl files.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗