[BUG] Resume/continue cache invalidation
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?
When using --resume to continue a session, the prompt cache is broken on every turn. Content that should be a cache hit is re-created instead, leading to massively increased session usage.
In particular, the cause is twofold:
Issue 1: Skill listing block migrates between messages on resume
A <system-reminder> block listing available skills (~1501 chars) is injected into the first user message on each turn. This block is not persisted in the session JSONL. On the initial turn it appears in messages[0]. On resume, the persisted messages are replayed without it, and a fresh copy is injected into the new user message instead.
This changes the block structure of messages[0], which invalidates the cache prefix for everything after it.
Observed in proxy dump — Turn 1 messages[0] (4 content blocks):
block[0]: 533 chars — companion system-reminder
block[1]: 1501 chars — skill listing system-reminder ← PRESENT HERE
block[2]: 7801 chars — project context system-reminder
block[3]: 4 chars — user text ("test"), cache_control: ephemeral 1h
Observed in proxy dump — Turn 2 messages[0] (3 content blocks):
block[0]: 534 chars — companion system-reminder (also 1 byte longer, see Issue 2)
block[1]: 7801 chars — project context system-reminder
block[2]: 4 chars — user text ("test"), NO cache_control
Turn 2 messages[2] (new user message, 2 content blocks):
block[0]: 1501 chars — skill listing system-reminder ← MOVED HERE
block[1]: 10 chars — user text ("test again"), cache_control: ephemeral 1h
The skill listing block moved from messages[0] (turn 1) to messages[2] (turn 2). Since the API caches by exact prefix match, this structural difference at the start of the messages array means the session-specific content can never be a cache hit on resume.
Issue 2: Extra newline appended to text blocks on re-normalization
When replayed messages are re-normalized on resume, an extra \n is appended to certain text blocks at merge boundaries. This is visible in the companion block:
Turn 1: 533 chars, ends with "</system-reminder>"
Turn 2: 534 chars, ends with "</system-reminder>\n"
This 1-byte difference independently invalidates the prefix cache, even if Issue 1 were fixed. The extra newline accumulates on each resume — a third resume would add another \n.
Expected behavior
Both issues are independently sufficient to break the cache. When both are corrected, resumed turns achieve ~100% cache hit:
turn hit% cached new_cache uncached total ctx% message
---- ---- ------- --------- -------- ------ ---- -------
1 68% 11271 5277 3 16551 2% test
2 100% 16548 45 3 16596 2% test again
3 100% 16593 44 3 16640 2% one more test
4 100% 16637 39 3 16679 2% test
HEALTHY: steady-state avg hit=100%, avg new cache=43
New cache creation drops from ~2850 to ~43 tokens/turn (just the new user message content).
What Should Happen?
claude --resume -p with a provided session, used within the cache time limit, should not invalidate prior session cache, nor introduce new tokens. In other words, --resume should be idempotent and --resume -p should be functionally identical to sending a new interactive turn, i.e. not generating unnecessary caching burden.
Error Messages/Logs
Steps to Reproduce
First, initialize a local HTTP reverse proxy intercepting API requests to api.anthropic.com, capturing full request payloads across a 4-turn --resume sequence.
# Turn 1: start session
claude -p --output-format stream-json <<< "test"
# → session_id: <id>
# Turns 2–4: resume
claude -p --output-format stream-json --resume <id> <<< "test again"
claude -p --output-format stream-json --resume <id> <<< "one more test"
claude -p --output-format stream-json --resume <id> <<< "test"
Observed cache behavior
turn hit% cached new_cache uncached total ctx% message
---- ---- ------- --------- -------- ------ ---- -------
1 0% 0 21220 3 21223 2% test
2 87% 18416 2828 3 21247 2% test again
3 87% 18416 2847 3 21266 2% one more test
4 87% 18416 2866 3 21285 2% test
UNHEALTHY: steady-state avg hit=87%, avg new cache=2847
Turns 2–4 should show ~100% cache hit with <100 tokens of new cache creation (just the new user message). Instead, ~2850 tokens are re-created on every resumed turn.
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
2.1.71
Claude Code Version
2.1.92
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
Non-interactive/CI environment
Additional Information
#42338 contained extensive documentation of these and other issues, including minified source patches, but was closed prior to 2.1.92 launching.
Showing cached comments. Read the full discussion on GitHub ↗
14 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Fixed in 2.1.97!
Our automated cache behavior tests on v2.1.97 show that resume block scatter is still present — system-level blocks (skills, plugins) get relocated outside
messages[0]on--resume, breaking the prefix cache.Test data: with the interceptor disabled on v2.1.97,
cache_read_input_tokensdrops to near-zero on resumed sessions. With the interceptor re-enabled, cache hit rate returns to ~98%.Full test report: v2.1.97-cache-test-report.md
It's possible some specific scenario improved in v2.1.97, but the core resume cache invalidation is still reproducible.
I will reopen then @cnighswonger -- may have been a false alarm.
Still reproducible on Claude Code 2.1.111 — confirming this is not resolved.
Captured the miss natively via CC's own telemetry (no interceptor needed — v2.1.111 added
OTEL_LOG_RAW_API_BODIES=1which emits request/response bodies as OpenTelemetry log events). Env used:Repro
claude --continueFirst post-resume API call's
api_response_bodyevent usage block:Hit rate:
19282 / (19282 + 304994) = 5.9%.What the fingerprint tells us
The
cache_read_input_tokens: 19282is exactly the tools+system prefix token count, cached at the account level and shared across sessions. So server-side cache WAS available, and tools/system prefix DID hit. What missed was the entire message prefix — every historical message got re-cached as a new prefix from scratch.If this were "no cache available at all" (e.g., fresh session, expired TTL), we'd expect
cache_read_input_tokens: 0. We see the tools-prefix hit cleanly — only the message prefix fails. That's the signature the OP described: block structure inmessages[0]drifts between turn 1 and turn 2 (or: the last turn before exit and the first turn after resume), so the cached prefix no longer matches byte-for-byte starting atmessages[0].Relationship to #48734, #38542, #27048
Same underlying class of bug. Different triggers, same non-deterministic serialization of historical user messages that contain hook-injected
additionalContext/ skill-listing / MCP / plugin blocks:All four manifestations fire on the same structural substrate: CC's request assembler re-renders historical messages non-deterministically when it reconstructs the request body, whether that reconstruction is per-turn or per-resume. Consolidating root-cause investigation across these four issues would probably speed resolution.
What I can offer
The
cache-miss-<ts>-<request_id>.jsoncaptured by my tail-script (parsing the OTEL console-exporter blocks) includes the fullapi_request_body+api_request+api_response_bodytrio for each miss. Happy to share a redacted trio for the team if that helps the internal repro — just note that the request body is large (300KB+) and CC truncates it at around 100KB viabody_truncated: 'true'on the OTEL event, so the captured body may be incomplete. (Feature request: raise or remove the OTEL raw-body truncation limit — without it, we can't give you the full prefix bytes that caused the miss.)Confirmed on 2.1.112 with byte-level captured evidence + source-function citation
Still reproduces on latest. We captured two miss bodies spanning a
--continueboundary in the same session (samesession_id, pre-restart pid=4024 and post-restart pid=8296). Both are full-prefix misses (cache_read= tools-only ~19k,cache_creation= full message prefix re-built). Theirmsg[0]shapes differ in ways that precisely match the scatter pattern this issue describes:| Block | Pre-restart
msg[0](original) | Post-restartmsg[0](after--continue) ||---|---|---|
|
[0]|<system-reminder>SessionStart:startup hook success: [hoe-reload]…</system-reminder>|<system-reminder>The following deferred tools are now available…</system-reminder>← was[1]pre-restart ||
[1]|<system-reminder>The following deferred tools…</system-reminder>|<system-reminder># MCP Server Instructions…</system-reminder>← was[2]pre-restart ||
[2]|<system-reminder># MCP Server Instructions…</system-reminder>|<system-reminder>The following skills are available…</system-reminder>← was[3]pre-restart ||
[3]|<system-reminder>The following skills are available…</system-reminder>|<system-reminder>SessionStart:resume hook success: [hoe-reload]…</system-reminder>← NEW, different event name and position ||
[4]| UserPromptSubmit classify-intent | UserPromptSubmit classify-intent ||
[5]|claudeMdcontext |claudeMdcontext |Two independent drift signals at
msg[0]:SessionStart:startup(original) vsSessionStart:resume(post---continue): different hook-event string, different bytes. Content drift alone would break cache at block[0]'s first byte.[0]..[3]shift by one slot because the new SessionStart-resume attachment joins the bootstrap-attachment run and the whole run gets reordered together. The new block lands at index[3]not because that position is targeted, but because reordering flushes the attachment run at the first tool_result/assistant boundary.The cache breaks at offset 0 of
msg[0]→ the whole message prefix re-creates.cache_readfalls back to tools+system (the two prefixes cached separately at the account level).(Note: post-restart body has 699 messages vs pre-restart's 141 — that's normal session growth, not part of the diff. The relevant change is the
msg[0]shape.)Source functions responsible (from leaked source-map TS)
Three functions together produce this behavior:
src/utils/sessionStart.ts:processSessionStartHooks('resume', …)— called fromloadConversationForResumeinsrc/utils/conversationRecovery.tswhen--resume/--continuefires. Produces a fresh SessionStart attachment whose event name is'resume'(not'startup'), so the text content differs from what was cached.src/utils/messages.ts:1481 reorderAttachmentsForAPI(messages)— bubbles attachment messages up until they hit a tool_result user message or an assistant boundary. The new SessionStart-resume attachment joins the existing bootstrap-attachment run; the run is reordered together and lands at indices[0..3]of the mergedmsg[0].src/utils/messages.ts:1989 normalizeMessagesForAPI(messages, tools)— the full request-prep pipeline: runsreorderAttachmentsForAPIfirst, then per-message normalization that internally invokesmergeUserMessagesAndToolResults(which itself callsmergeUserContentBlocksandhoistToolResults), thenrelocateToolReferenceSiblings(gated ontengu_toolref_defer_j8m), then thinking/whitespace filters (filterOrphanedThinkingOnlyMessages,filterTrailingThinkingFromLastAssistant,filterWhitespaceOnlyAssistantMessages,ensureNonEmptyAssistantContent), thenmergeAdjacentUserMessages+smooshSystemReminderSiblingswrapped together and gated ontengu_chair_sermon, and finallysanitizeErrorToolResultContent. Because the attachment set differs after step 1, the per-message merge/smoosh pass produces different downstream bytes than the original session's normalization did — even though the stored JSONL content is byte-identical.Prior community RE on the adjacent threads covered the other two cache-bugs in the trio: @jmarianski's Bug 1
cch=00000sentinel analysis in #40524 (MITM + Ghidra), @whiletrue0x's Bug 3 attribution-header hash findings in #40524, and @VictorSun92's v2.1.88 patch in #34629 built on @jmarianski's RE. This one — resume attachment drift — had the behavioral diagnosis earlier in this thread but not the function-level attribution. Adding that piece.Cross-link
Just filed #49585 for the per-turn manifestation of the same propagation pipeline (dynamic hook reminders causing mid-history byte drift via the same
smooshSystemReminderSiblings→smooshIntoToolResultpath). Same propagation, different trigger:processSessionStartHooks('resume')adding new attachments; manifestation is whole-prefix miss atmsg[0].msg[N].Both would be fixed by making the attachment-set + normalize pipeline byte-idempotent across resume/between-turn boundaries (e.g., persist the post-normalize body to JSONL, or make SessionStart-hook attachments stable rather than event-tagged by call site).
Simulation code that ports the smoosh logic is attached to #49585 if it helps internal repro.
We can confirm your source-level attribution. We reviewed the same functions — processSessionStartHooks (sessionStart.ts:35), reorderAttachmentsForAPI (messages.ts:1481), and normalizeMessagesForAPI (messages.ts:1989) — from the leaked CC source when we built the interceptor's fix in early April.
Our normalizeResumeMessages() in claude-code-cache-fix addresses the same drift you documented: it scans all messages for scattered attachment blocks and relocates them to messages[0] in deterministic order (deferred → mcp → skills → hooks) on every API call. This makes the block layout byte-identical regardless of whether the session started via startup, resume, clear, or compact — eliminating both the content drift (startup vs resume event string) and the position drift (reorderAttachmentsForAPI shuffling the run).
Your OTEL-based capture methodology is a valuable addition — native CC telemetry confirming the same bug our interceptor addresses. The combination of source-level attribution + OTEL capture + interceptor fix from three independent approaches is strong evidence for the consolidation you're proposing across #43657, #48734, #38542, and #27048.
Really appreciate the independent confirmation — three teams landing on the same set of functions (
processSessionStartHooks,reorderAttachmentsForAPI,normalizeMessagesForAPI) from different entry points (your binary-review + interceptor build, my OTEL body capture + source-map diff, and the prior community Ghidra work) is about as strong a triangulation as we're going to get without Anthropic-side code access.One scope clarification so we don't miss that #49585 is complementary to (not covered by)
normalizeResumeMessages:Your
normalizeResumeMessageshandles the attachment-scatter slice — relocating drifted skills/MCP/deferred-tools/hooks blocks back tomessages[0]in deterministic order. That's the resume-side Bug 2 fix (covered), and also stabilises #43657 + theprocessSessionStartHooks('resume')event-name drift we diffed. Genuinely the right fix for that slice.#49585 targets a different slice that the interceptor doesn't currently catch:
<system-reminder>-prefixed text blocks (CC-internal reminders whose values change every turn:token_usage,output_token_usage,budget_usd,todo_reminderafter N turns, and themcp_instructions_delta/deferred_tools_deltadiffs when server state changes).smooshSystemReminderSiblings(messages.ts:1835) →smooshIntoToolResult(messages.ts:2534), gated ontengu_chair_sermon. These fold the SR-text intotool_result.contentstrings every turn.tool_result.contentwhere the fold landed produces different cached bytes each call. Prefix break at that offset → full re-creation from there on. Fires without resume, without attachment scatter, without any plugin state change.Relocating to
messages[0]doesn't help here because the bytes that drift are INSIDE atool_result.contentstring at a mid-history position — they've already been folded by the smoosh before any interceptor scan. A hypothetical interceptor-side fix would need to un-smoosh first (split each tool_result.content on\n\n<system-reminder>boundaries back into separate blocks), then re-normalize the smooshed form with deterministic inputs. That's substantially more invasive than reorder.On consolidation across #43657 / #48734 / #38542 / #27048 / #49585: agree they're all siblings with the same "request assembly isn't byte-idempotent over stored transcript" root, but each points at a different concrete fix target:
</system-reminder>(vendoredws_stripmitigation)cache_controlblockIf Anthropic triages them as a family, great — but keeping the specific code-path attribution per issue helps them scope the fix patches. The interceptor's
normalizeResumeMessagescould arguably also consolidate #44045 (skill_listing scatter) under its umbrella, but the smoosh path still needs direct attention at the source level.Happy to run our captured OTEL trios against any hypothesis you want to test — or to integrate with the interceptor if you want to prototype a smoosh-mitigation there. Either way, thanks for the cross-confirmation on the source citations; that gives the upstream report meaningful weight.
Confirming this regression hits Python Agent SDK users too.
Environment
claude-agent-sdk0.1.80 (Python)claude-sonnet-4-6resume=<sdkSessionId>between turnsENABLE_TOOL_SEARCH=true,ENABLE_PROMPT_CACHING_1H=1,exclude_dynamic_sections=true,system_prompt.append=<static view guide>set viaClaudeAgentOptionsSymptom
Every chat turn shows
cache_write ≈ cache_read ≈ 30K, even for trivial prompts ("hi", "1+1?"):| turn | cache_write | cache_read | cost |
|------|-------------|------------|------|
| 1 | 30,092 | 29,942 | $0.123 |
| 2 | 30,989 | 30,737 | $0.127 |
| 3 | 31,776 | 31,624 | $0.132 |
| 4 | 32,664 | 32,512 | $0.135 |
| 5 | 33,608 | 33,404 | $0.140 |
Floor cost per simple reply is ~$0.10, driven by cache_write × 1.25 rate.
Proxy capture analysis
I ran an
ANTHROPIC_BASE_URLHTTP proxy and dumped two consecutive/v1/messagespayloads. Thesystem[0]x-anthropic-billing-headercch=value drifts per request (matches the #40524 finding). To isolate the resume invalidation specifically, my proxy also rewritescch=to a static value before forwarding.After normalizing
system[0], the relevant prefixes for two consecutive turns are byte-identical:| Field | REQ_A | REQ_B | Match |
|---|---|---|---|
|
toolsarray (full hash) |dad01798af7bf212|dad01798af7bf212| ✅ ||
system[0](normalized) |12f8b6128c71e542|12f8b6128c71e542| ✅ ||
system[1](cc=1h) |6c1afcd9232947a1|6c1afcd9232947a1| ✅ ||
system[2](cc=1h, 35,683 chars) |efd1616911cd2f4a|efd1616911cd2f4a| ✅ |Yet
cache_writeis still ~30K every turn even with identical tools + identical system. This matches the attachment-scatter + per-turn smoosh mechanism documented in this thread and #49585: the messages-array normalization pipeline is not byte-idempotent acrossresumeboundaries.Happy to share full proxy dumps if useful for repro on the Anthropic side.
I’m seeing what looks like the same resume/request-assembly bug, but with a functional skill-discovery impact, not only cache/cost impact.
Environment:
2.1.119claude -p--resume <session_id>and usually--fork-sessionCLAUDE_CONFIG_DIRpoints at a persistent config dir with user skills underskills/Repro shape:
skills/explain-merch-message/SKILL.md.claude -pprompt asking for visible skills matchingexplain-merch.explain-merch-message.claude --resume <old_session_id> -p ...with the same prompt.claude --resume <old_session_id> --fork-session -p ....Observed:
skill_listingblock did not include the newly imported skill.claude -psees the skill, but--resumeand--resume --fork-sessiondo not.Expected:
claude -psees the skill.This looks related to the
skill_listingmigration / regenerated attachment behavior described here, but the user-facing effect is stronger than cache invalidation: newly added skills can be unavailable until the session is not resumed.Confirming a second account sees related patterns. We've documented skill-listing prefix instability in cache-fix proxy logs (#47098 area), but @dbreon88's specific failure mode —
--resumeand even--fork-sessionreading staleskill_listingfrom JSONL rather than re-evaluating against current filesystem — is more functionally severe than the cache-cost framing of the original report.Worth knowing: this isn't proxy-fixable. CC's session-state restoration code is what would need to regenerate skill listings against the current filesystem on resume.
— AI Team Lead
Closing for now — inactive for too long. Please open a new issue if this is still relevant.
This was closed by github-actions[bot] this morning for inactivity, not because the bug was resolved. The underlying behavior — system-level blocks (skills, plugins, project context) scattering outside
messages[0]on--resumeand breaking the prefix cache — is still present in v2.1.148.We are still mitigating it client-side in
cache-fix-proxy(fresh-session-sortextension at proxy/extensions order 250). With that extension disabled on v2.1.148, the resume-sidecache_read_input_tokensdrops to near zero on the next turn; with it enabled, hit rate returns to ~96-99%. The proxy is keeping the cost in check, but it's compensating for a CC-side block-layout drift that hasn't changed since the original report.The original cause documented in this thread (skill-listing block migration between
messages[0]and the new user message on each resume) is the same shape we observe in current binaries.Filing a fresh issue for v2.1.148-specific reproduction so the bot doesn't close this class of regression on inactivity alone. Will link the new issue here when it's open.
— AI Team Lead
Refiled at #67497 — same bug, v2.1.148-specific framing, with current binary evidence and the stale-bot-closure context.
— AI Team Lead