[BUG] Resume/continue cache invalidation

Status Closed — not planned
Reported on v2.1.71
Maintainer reply None cached
Activity 15 comments · opened Apr 4, 2026 · closed Jun 11, 2026

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.

View original on GitHub ↗

14 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/34039
  2. https://github.com/anthropics/claude-code/issues/42338
  3. https://github.com/anthropics/claude-code/issues/34629

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

simpolism · 4 months ago

Fixed in 2.1.97!

cnighswonger · 4 months ago

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_tokens drops 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.

simpolism · 4 months ago

I will reopen then @cnighswonger -- may have been a false alarm.

deafsquad · 4 months ago

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=1 which emits request/response bodies as OpenTelemetry log events). Env used:

CLAUDE_CODE_ENABLE_TELEMETRY=1
OTEL_LOG_RAW_API_BODIES=1
OTEL_LOGS_EXPORTER=console
OTEL_LOGS_EXPORT_INTERVAL=1000

Repro

  1. Long active session on 2.1.111 (message history ~40+ turns)
  2. Exit Claude Code
  3. claude --continue
  4. Fire any prompt

First post-resume API call's api_response_body event usage block:

"usage": {
  "input_tokens": 6,
  "cache_creation_input_tokens": 304994,
  "cache_read_input_tokens": 19282,
  "output_tokens": 683,
  "cache_creation": {
    "ephemeral_1h_input_tokens": 304994,
    "ephemeral_5m_input_tokens": 0
  }
}

Hit rate: 19282 / (19282 + 304994) = 5.9%.

What the fingerprint tells us

The cache_read_input_tokens: 19282 is 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 in messages[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 at messages[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:

  • Per-turn drift → #48734 (content fusion/reshuffle between adjacent turns)
  • TTL-ordering 400 → #38542 (drifted cache_control ordering throws API error)
  • Plugin/tool-use rewrite on resume → #27048
  • Skill-listing block migration on resume → this issue (#43657)

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>.json captured by my tail-script (parsing the OTEL console-exporter blocks) includes the full api_request_body + api_request + api_response_body trio 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 via body_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.)

deafsquad · 4 months ago

Confirmed on 2.1.112 with byte-level captured evidence + source-function citation

Still reproduces on latest. We captured two miss bodies spanning a --continue boundary in the same session (same session_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). Their msg[0] shapes differ in ways that precisely match the scatter pattern this issue describes:

| Block | Pre-restart msg[0] (original) | Post-restart msg[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] | claudeMd context | claudeMd context |

Two independent drift signals at msg[0]:

  1. Content driftSessionStart:startup (original) vs SessionStart:resume (post---continue): different hook-event string, different bytes. Content drift alone would break cache at block [0]'s first byte.
  2. Position drift — blocks [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_read falls 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:

  1. src/utils/sessionStart.ts:processSessionStartHooks('resume', …) — called from loadConversationForResume in src/utils/conversationRecovery.ts when --resume/--continue fires. Produces a fresh SessionStart attachment whose event name is 'resume' (not 'startup'), so the text content differs from what was cached.
  2. 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 merged msg[0].
  3. src/utils/messages.ts:1989 normalizeMessagesForAPI(messages, tools) — the full request-prep pipeline: runs reorderAttachmentsForAPI first, then per-message normalization that internally invokes mergeUserMessagesAndToolResults (which itself calls mergeUserContentBlocks and hoistToolResults), then relocateToolReferenceSiblings (gated on tengu_toolref_defer_j8m), then thinking/whitespace filters (filterOrphanedThinkingOnlyMessages, filterTrailingThinkingFromLastAssistant, filterWhitespaceOnlyAssistantMessages, ensureNonEmptyAssistantContent), then mergeAdjacentUserMessages + smooshSystemReminderSiblings wrapped together and gated on tengu_chair_sermon, and finally sanitizeErrorToolResultContent. 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=00000 sentinel 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 smooshSystemReminderSiblingssmooshIntoToolResult path). Same propagation, different trigger:

  • This issue (#43657): trigger is processSessionStartHooks('resume') adding new attachments; manifestation is whole-prefix miss at msg[0].
  • #49585: trigger is dynamic reminder content drifting between turns; manifestation is mid-history miss at some 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.

cnighswonger · 4 months ago

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.

deafsquad · 4 months ago

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 normalizeResumeMessages handles the attachment-scatter slice — relocating drifted skills/MCP/deferred-tools/hooks blocks back to messages[0] in deterministic order. That's the resume-side Bug 2 fix (covered), and also stabilises #43657 + the processSessionStartHooks('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:

  • Trigger is NOT attachment position — it's dynamic content of <system-reminder>-prefixed text blocks (CC-internal reminders whose values change every turn: token_usage, output_token_usage, budget_usd, todo_reminder after N turns, and the mcp_instructions_delta/deferred_tools_delta diffs when server state changes).
  • Code path is not the reorder — it's smooshSystemReminderSiblings (messages.ts:1835) → smooshIntoToolResult (messages.ts:2534), gated on tengu_chair_sermon. These fold the SR-text into tool_result.content strings every turn.
  • Because the folded-in content differs byte-wise between consecutive turns, the tool_result.content where 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 a tool_result.content string 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:

  • #43657 / #27048 — attachment position + attachment regeneration on resume (your interceptor's territory)
  • #48734 — trailing-whitespace drift on persisted </system-reminder> (vendored ws_strip mitigation)
  • #38542 — same drift surfacing as a TTL-ordering 400 when the drift reshuffles a cache_control block
  • #49585 — per-turn smoosh fold of dynamic reminders (new slice, no current mitigation)

If 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 normalizeResumeMessages could 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.

eunseokOh · 3 months ago

Confirming this regression hits Python Agent SDK users too.

Environment

  • claude-agent-sdk 0.1.80 (Python)
  • Bundled CLI: 2.1.138
  • Model: claude-sonnet-4-6
  • Auth: API key
  • Pattern: multi-turn chat using resume=<sdkSessionId> between turns
  • ENABLE_TOOL_SEARCH=true, ENABLE_PROMPT_CACHING_1H=1, exclude_dynamic_sections=true, system_prompt.append=<static view guide> set via ClaudeAgentOptions

Symptom
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_URL HTTP proxy and dumped two consecutive /v1/messages payloads. The system[0] x-anthropic-billing-header cch= value drifts per request (matches the #40524 finding). To isolate the resume invalidation specifically, my proxy also rewrites cch= 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 |
|---|---|---|---|
| tools array (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_write is 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 across resume boundaries.

Happy to share full proxy dumps if useful for repro on the Anthropic side.

dbreon88 · 3 months ago

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:

  • Claude Code 2.1.119
  • Non-interactive claude -p
  • A wrapper process invokes Claude with --resume <session_id> and usually --fork-session
  • CLAUDE_CONFIG_DIR points at a persistent config dir with user skills under skills/

Repro shape:

  1. Start a Claude session before a custom skill exists.
  2. Add/import a new skill under the active Claude config dir, e.g. skills/explain-merch-message/SKILL.md.
  3. Confirm the skill file exists on disk.
  4. Run a fresh claude -p prompt asking for visible skills matching explain-merch.
  • Fresh session sees explain-merch-message.
  1. Run claude --resume <old_session_id> -p ... with the same prompt.
  • Resumed session does not see it.
  1. Run claude --resume <old_session_id> --fork-session -p ....
  • Forked resumed session also does not see it.

Observed:

  • The resumed run appears to initialize with the current skill files available, but the model behaves as if it is still using the stale skill listing from the original session.
  • In the original session JSONL, the initial skill_listing block did not include the newly imported skill.
  • After import, fresh claude -p sees the skill, but --resume and --resume --fork-session do not.

Expected:

  • A resumed session should preserve chat history, but current skill discovery should reflect the current config/filesystem, especially since fresh claude -p sees the skill.
  • If skill availability is intentionally frozen per session, that should be documented. Current behavior is surprising because the runtime appears to discover the skill, but the resumed model-visible skill list remains stale.

This looks related to the skill_listing migration / 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.

cnighswonger · 3 months ago

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 — --resume and even --fork-session reading stale skill_listing from 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

github-actions[bot] · 2 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

cnighswonger · 2 months ago

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 --resume and breaking the prefix cache — is still present in v2.1.148.

We are still mitigating it client-side in cache-fix-proxy (fresh-session-sort extension at proxy/extensions order 250). With that extension disabled on v2.1.148, the resume-side cache_read_input_tokens drops 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

cnighswonger · 2 months ago

Refiled at #67497 — same bug, v2.1.148-specific framing, with current binary evidence and the stale-bot-closure context.

— AI Team Lead

Showing cached comments. Read the full discussion on GitHub ↗