[BUG] Prompt cache invalidated by rewrites of messages in long sessions
Status Open
Reported on v2.1.197
Maintainer reply None cached
Workaround ✓ Mentioned in thread ↓
Activity 6 comments · opened Jul 11, 2026
What's Wrong?
Claude code sessions sometimes reprocess the entire conversation instead of just a new message that a user sends. This is not due to any reason visible in the chat itself.
I found two causes by diffing /v1/messages requests around a cost spike.
- Claude Code rewrites an old hook reminder's shape later in the session. It does this by either moving it into its own message, or merging it into a neighboring one. Becausr this edits a message from earlier in the session's history, the entire session cache gets invalidated. I hit this multiple times in the same day.
- Long sessions only get one cache checkpoint, at the very end. This means that if this checkpoint is missed, you're SOL and the entire convo hits a cache write.
What Should Happen?
Old messages should not be modified, since this invalidates the entire cache, which can cost up to $20 (or even more...)
Steps to Reproduce
- Run a long VS Code session with PreToolUse hooks that add extra context to tool calls
- Diff consecutive raw request bodies around a big
cache_creation_input_tokensspike which should hit eventually - You'll see an old
<system-reminder>block change shape, either split into its own message or merged into a neighboring one, breaking the cache for everything after it
Claude Code Version
2.1.197 (Claude Code)
Is this a regression?
Not sure, probably not
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
VS Code extension (CLI wrapper)
Showing cached comments. Read the full discussion on GitHub ↗
5 Comments
Reproduced deterministically, with a controlled A/B. The trigger is the VS Code extension's
claudeCode.autosavesetting — and I caught the injected payload being born, one edit at a time.Setup: Claude Code 2.1.207, VS Code extension, Windows 11, Opus 4.8, 1h TTL.
Corpus: 155 sessions, 4,639 deduplicated requests, 868 user-turn boundaries, 49 real
messages_changedevents totalling 7,555,163 missed tokens.The A/B
Same machine, same workspace, same 40 files, same operator, same edit pattern. One variable.
|
claudeCode.autosave| requests | edits | distinct files |messages_changed||---|---|---|---|---|
| OFF | 147 | ~60 | 40 | 0 |
| ON | 61 | 41 | 40 | 1 |
With autosave off I actively tried to break it for six hours — 45 sequential read-only calls, 19 edits to an open file with live ruff errors, 40 sequential writes to 40 distinct new files, long user pauses.
cache_readgrew monotonically to 255,933. Zero drops. Not one IDE payload was ever injected.With autosave on, in a fresh session, it died on the 41st edit:
cache_readcollapsed from 125,948 to the system residual. The entire conversation was re-written at the 1h-write multiplier. ~$2.75.Watching the injection happen
This is the part I haven't seen reported. In the autosave-on session, 39 of 40 sequential
Editcalls came back carrying an<ide_diagnostics>payload, delivered as aPostToolUse:Edithook injection — a<system-reminder>wrapping the diagnostics. It fired on the very first edit:Two things follow immediately:
1. It is
PostToolUse, so it is structurally late. The diagnostic caused by an edit is not in the request that made the edit. It lands in the next build of the message array. That is the late-arrival model, observed directly rather than inferred.2. Reads are free; writes are the trigger. In the same session, with all 40 files open in the editor and the language server live, I made 40 reads and got zero payloads. The payload appears the instant a write happens. This deconfounds tool volume from the actual cause.
The race, caught in the act
Edit 18 published nothing. Every other edit in the chain — 1–17 and 19–40 — produced a payload. probe_18 produced none, and probe_19's payload named only
probe_19.py, so probe_18's diagnostic was not late-folded into a later sample either. It was simply still outstanding when the hook took its snapshot.That single gap is the whole bug in miniature. LSP
textDocument/publishDiagnosticsis a server-initiated push — the language server sends it when it finishes analysing, and there is no synchronous "give me diagnostics now" path in a save handler. The producer (language server) and the consumer (thePostToolUsehook) are not synchronised. The hook takes a point-in-time snapshot of an asynchronously-published stream, and then embeds that snapshot into an append-only, content-addressed, prefix-cached structure whose entire contract is that a message, once sent, never changes.A diagnostic that misses its snapshot has nowhere to go but a later rebuild — of a message that has already been serialized, sent, and cached. That is the "old reminder changes shape" rewrite OP describes.
To be precise about what is mine and what is OP's: I observed the payloads being injected, the missed sample, and the resulting
messages_changeddrop. I did not capture the byte-level rewrite of an already-sent block — OP did, from raw request bodies. The two reports are looking at the same object from opposite ends: OP sees it mutate; I watched it get born and timed the miss.This is an async producer writing into an immutable, already-transmitted, prefix-cached structure. It is a race against the cache, and the cache always loses.
Deterministic reproduction
claudeCode.autosaveenabled (it is the default).Editcalls to files the language server actively flags, each edit introducing a fresh hard error (an undefined symbol — a red Pylance error is a stronger signal than a yellow lint warning).<ide_diagnostics>payloads will start appearing immediately.message.diagnostics.cache_miss_reasonfrom the session JSONL (dedupe assistant records byrequestId). It will readmessages_changed.Workaround available today: disable
claudeCode.autosave. After disabling it, this corpus recorded ~350 requests and ~118 edits across four sessions with zero invalidations — sessions that would previously have been near-certain to drop.Two structural findings
**1.
messages_changedfires only at user-turn boundaries. 49 of 49. 0 of 3,774** mid-chain requests, ever. The rewrite happens when the message array is rebuilt, never while a tool chain is streaming — which is exactly the window in which a late async payload lands.2. There is exactly ONE
cache_controlbreakpoint in the messages array (confirming OP's second point, from captured request bodies):So there is nothing to fall back to. A mutation at message 3 and a mutation at message 93 are identically catastrophic. 37 of my 49 drops land on the system residual (13.1k–20.4k) regardless of how large the conversation was.
Correction for #63930
The "≥12 parallel tool calls" trigger does not replicate. Grouping
tool_useblocks byrequestId(Claude Code writes each parallel call as its own JSONL record, so naive per-record counting silently reads 1):| max parallel fan-out | n | drops | rate |
|---|---|---|---|
| 0–1 | 597 | 19 | 3.2% |
| 2–3 | 202 | 22 | 10.9% |
| 4–7 | 54 | 8 | 14.8% |
| 8–11 | 7 | 0 | 0.0% |
| 12+ | 5 | 0 | 0.0% |
My widest turns never dropped once. Holding volume fixed, low fan-out drops more. Parallelism is not the trigger — IDE-mediated file saves are.
Asks
claudeCode.autosavecarries this cost, until (1) lands. It is on by default, and it is silently expensive on exactly the workload Claude Code is for: long sessions that edit a lot of files.Independent corroboration of cause 1, with different instrumentation and numbers.
We capture every
/v1/messagesbody through a local proxy (CC 2.1.220, linux, ~2.5 GB/day). A census over consecutive same-conversation request pairs shows exactly the mechanism you describe — an old<system-reminder>hook block re-serialized later in the session, moved into its own message or merged into a neighbour:splice/insert-midclass fires several times per evening session (measured pairs at request 92, 100, 316, 466, 473, 497… of one 513-request session), ~40 kB re-billed per hit at our depth.replace/editmutations: 15 of 20 were mid-history, not tail — so these are full-prefix invalidations, the expensive kind. The worst single candidate sat at index 768 of 783, 15 s before a 484k-token cache rewrite.We mitigate it in a proxy by pinning each volatile block to its first-seen serialization, keyed by content identity (so the forwarded bytes never change even when CC re-shapes the block): cnighswonger/claude-code-cache-fix#272. That works as an external patch, but the native fix is simpler: serialize reminder blocks in their final shape at creation time and never re-shape a message that has already been sent — history behind the last message should be append-only bytes.
🤖 Generated with Claude Code
Update (same day): the full verification toolchain behind these numbers is now also PR'd — pre-pipeline request capture (cnighswonger/claude-code-cache-fix#275) and the replay/census/harvest gate (cnighswonger/claude-code-cache-fix#276) — so the measurements above are reproducible against anyone's own traffic, not just ours.
Method note, since it generalizes: the census in that toolchain classifies every consecutive same-conversation request pair by divergence shape (append-only / splice / replace-edit / tools-delta / …) and prices each class in re-billed bytes — so it surfaces and ranks invalidation causes you have not named yet, rather than confirming known ones. That is how the mid-history classes in this thread were found.
New mutation shape from the same reminder re-shaping family, caught live — and a mitigation that provably absorbs it.
Since our census corroboration above, the capture corpus recorded a shape not yet named in this thread: CC took BOTH
<system-reminder>blocks of one mid-history message, stripped their wrappers, and merged them into a SINGLE standalone system message, joined with exactly"\n\n"(627 bytes, byte-confirmed against the raw request). Content-identity suppression that hashes blocks individually cannot match it — there is no per-block equal — so it forwarded as a "new" message and spliced the history (measured:suppressed: 0across 560 session events while our census flagged the edit as not-any-known-class). The same session also oscillated one reminder among already-seen forms. Sanitized fixture with the real bytes:test/fixtures/harvested/oscillation-s-633915a8-863.jsonon our fork.The mitigation that absorbs all of it, public as of today: for a pinned message with ≥2 volatile blocks, also register the join-hash of the unwrapped block texts in wire order; a standalone equal to the join suppresses through the existing content-identity path (Gunther-Schulz/claude-code-cache-fix
78940a0, tests red-first against the fixture bytes; census clean after; upstreamed in cnighswonger/claude-code-cache-fix#272).Takeaway for a native fix, one step stronger than before: reminder identity must be content-derived, and the comparison has to survive both wrapper-stripping and concatenation of adjacent reminders — positional identity or per-block-only comparison each mis-classify this event as new content and rebuild the cache.
🤖 Generated with Claude Code
Corroborating with a minimal variant of the same mechanism, plus what the 2.1.219 bundle shows about why the bytes change.
Observed (CC 2.1.219, linux/WSL2, logging proxy): the same PreToolUse
additionalContextreminder rendered as 444 chars on one main-loop request and 443 on the next. The only difference is one trailing newline; the blocks are identical after rstrip.tools[]andsystemwere byte-identical, first divergence at message index 94 of 127, and the flipped request paidcache_creation= 292,488 tokens on a warm 1h cache (TTL expiry ruled out: a 1,175 s gap elsewhere in the session stayed warm). Five such rebuilds in one session cost 1,431,014cache_creationtokens (181,846 / 214,260 / 292,488 / 351,919 / 390,501; cost grows with context depth at the break point).What the 2.1.219 bundle shows (minified names from the shipped bundle): the wrapper renderer itself is a fixed template,
<system-reminder>\n{content}\n</system-reminder>(fnGw). The instability is in per-request assembly, which does not preserve bytes:/^<system-reminder>\n?([\s\S]*?)\n?<\/system-reminder>$/(fnk9s), so the code itself anticipates the one-newline variance it producesmU_, which also.trim()s them), i.e. a mid-history message is mutated'\n'(fngU_), then re-wrapped or relocated: own message, merged into a neighboring user message, or moved to a separate collection depending on mode flagstengu_chair_sermon) switches to yet another wrap transform whose idempotence guard checks only the<system-reminder>prefixNone of these round trips (trim, tolerant unwrap, fixed rejoin, neighbor-dependent merge) are byte-preserving, which produces exactly the split / merge / one-newline mutations reported in this thread.
A byte-stable fix would be to render each attachment once and reuse the stored string on every subsequent request, or to normalize whitespace deterministically at every wrap/unwrap/merge site.
Cross-linking #83913. A controlled untouched-v2.1.223 Sonnet 5 hook matrix supports this issue’s later reframe: the reproduced collapse is a user-turn-boundary mutation, not inherently a parallel-tool or filesystem-write bug. PreToolUse and PostToolUse timestamp hooks returning
additionalContexteach reproduced a ~22k old-prefix rewrite independently; every other exercised hook and the all-except-those-two profile stayed warm. Matched captures localized hook-carrier movement plus newline drift, and #83913 adds the separate resume sibling-parent topology finding.