[BUG] --resume never hits the prompt cache past the static prefix on Fable 5.1 (opus hits): session-start context message is replayed as a plain string instead of the content blocks it was sent with

Status Open
Reported on v2.1.268
Maintainer reply None cached
Activity 4 comments · opened Sep 10, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet (#91971 has the symptom without a cause, #83913 the same mechanism for a different message, #44045 is closed and covered messages[0])
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

every --resume (headless and interactive) re-writes the whole conversation into the cache even when the cache is warm. cache_read stays flat at the tools+system floor, cache_creation is the rest of the context, every single time.

i captured the requests with a local proxy and diffed a fresh session against its own resume. tools identical, system blocks identical, messages[0] identical (all 4 blocks, byte for byte). the first difference is messages[1], the role: system message that carries the SessionStart hook output plus the # Environment block:

  • fresh start: content is an array with one text block, cache_control: {type: ephemeral, ttl: 1h}
  • resume: content is a plain string. same text, same length (26285 chars), no cache_control

so the prefix stops matching right there and everything behind it gets written again. on a small probe thats ~18-25k tokens, on my real session it was a 385k re-write (a /background fork of a long session: read 27k, wrote 385k).

this is not TTL, not settings, not hooks. two fresh launches 30s apart hit each others cache completely (231238 read, 0 written), so the client can produce a stable prefix, it just doesnt on the resume path.

What Should Happen?

a rebuilt history reproduces the request that was already sent, byte for byte, same carrier, same block shape, same cache_control. thats what #83913 already asks for PreToolUse/PostToolUse context, this is the same class for the session-start context message. #91971 has this exact symptom on chained -p --resume but no cause, i think this is the cause.

Error Messages/Logs

no error, only the counters. structure of both captured requests (lengths + sha256 prefixes, no content):

fresh interactive start (first request):
  tools: 30 defs, sha 3ad5cd726882
  system[0]: 70 chars, sha 6b1c6e45dfc2, cache_control=None
  system[1]: 57 chars, sha 2719b7a469d9, cache_control={'type': 'ephemeral', 'ttl': '1h'}
  system[2]: 907 chars, sha a11f50f6b3db, cache_control=None
  system[3]: 9983 chars, sha 75bb0c61bbc0, cache_control={'type': 'ephemeral', 'ttl': '1h'}
  messages[0] role=user: content=ARRAY[4]: text 25023 sha ca6fa325c3d5 cc=None | text 1272 sha 75082207bd89 cc=None | text 347 sha 6c524f1b0cfd cc=None | text 30 sha d85f47a7c1a1 cc=None
  messages[1] role=system: content=ARRAY[1]: text 26285 sha 33946e993933 cc={'type': 'ephemeral', 'ttl': '1h'}

interactive --resume 53s later (first request):
  tools: 30 defs, sha 3ad5cd726882
  system[0]: 70 chars, sha 6b1c6e45dfc2, cache_control=None
  system[1]: 57 chars, sha 2719b7a469d9, cache_control={'type': 'ephemeral', 'ttl': '1h'}
  system[2]: 907 chars, sha a11f50f6b3db, cache_control=None
  system[3]: 9983 chars, sha 75bb0c61bbc0, cache_control={'type': 'ephemeral', 'ttl': '1h'}
  messages[0] role=user: content=ARRAY[4]: text 25023 sha ca6fa325c3d5 cc=None | text 1272 sha 75082207bd89 cc=None | text 347 sha 6c524f1b0cfd cc=None | text 30 sha d85f47a7c1a1 cc=None
  messages[1] role=system: content=STRING, 26285 chars, sha 33946e993933
  messages[2] role=assistant: content=ARRAY[1]: text 2 sha 2689367b205c cc=None
  messages[3] role=user: content=ARRAY[4]: text 246 sha 35c19d240c4d cc=None | text 129 sha c24432f46b9b cc=None | text 62 sha f8b4bb9c329c cc=None | text 36 sha 8daab17abfe0 cc=None
  messages[4] role=system: content=ARRAY[1]: text 614 sha bcb751e6ee5d cc={'type': 'ephemeral', 'ttl': '1h'}

same hashes everywhere up to and including messages[1], only the shape of messages[1] differs.

Steps to Reproduce

echo '{"mcpServers":{}}' > /tmp/empty-mcp.json
claude -p "Reply with the single word ok." --output-format json --strict-mcp-config --mcp-config /tmp/empty-mcp.json
# take session_id from the result, then within a few seconds:
claude -p "Reply with the single word ok again." --output-format json --strict-mcp-config --mcp-config /tmp/empty-mcp.json --resume <session_id>
claude -p "Reply with the single word ok a third time." --output-format json --strict-mcp-config --mcp-config /tmp/empty-mcp.json --resume <session_id>

what i get (result.usage), direct to the api, tool search on (default):

| call | cache_read | cache_creation |
|---|---|---|
| fresh | 12887 (tools+system were already cached from an earlier run) | 24383 |
| resume 1 | 12887 | 24644 |
| resume 2 | 12887 | 24907 |

with mcp servers on its the same shape, just bigger (12887 read / ~32k written on every resume, 4 resumes in a row).

note on the capture: the request diff below was taken through a local proxy (custom ANTHROPIC_BASE_URL), and in that mode tool search is off so all tools are inlined and there is no deferred-tools listing in messages[0]. in that capture messages[0] is byte-identical and the first divergence is messages[1]. direct to the api cache_read is even lower (12887 = tools+system only), so in normal mode messages[0] very likely differs as well (the deferred-tools listing block, see #49038 / #44045). i could not capture that one without disabling tool search.

interactive is the same:

fresh claude in a terminal, one prompt, /exit, claude --resume <id> 53s later, one prompt:

| | cache_read | cache_creation |
|---|---|---|
| fresh | 0 | 67824 |
| resume | 48825 | 19376 |

same diff: first divergence is messages[1], blocks with cache_control on fresh, plain string on resume.

the diff (content replaced by lengths):

fresh:

{"role":"system","content":[{"type":"text","text":"<26285 chars: SessionStart hook additional context ... # Environment ...>","cache_control":{"type":"ephemeral","ttl":"1h"}}]}

resume:

{"role":"system","content":"<same 26285 chars>"}

Claude Model

fable 5.1 (claude-fable-5-1)

Is this a regression?

dont know. first measured on 2.1.268, no earlier version measured for this exact path.

Last Working Version

unknown

Claude Code Version

2.1.268

Platform

claude.ai subscription (Max), first party api, deferred tools on (default), promptCacheTtl and subagentPromptCacheTtl both 1h

Operating System

macOS (Darwin 25.3.0, arm64)

Terminal/Shell

iTerm2, zsh (headless repro is shell independent)

Additional Information

two fresh launches 30s apart read each others cache in full (231238 read, 0 written), so the prefix is stable across processes and only the resume replay changes the shape of messages[1].

View original on GitHub ↗

4 Comments

vvasic · 3 days ago

update: this is model specific. same repro, same client, back to back, mcp off:

| model | fresh | resume 1 | resume 2 |
|---|---|---|---|
| opus (\--model opus\) | write 25262 / read 10126 | write 353 / read 35388 | write 264 / read 35741 |
| fable 5.1 (\--model fable\) | write 24378 / read 12887 | write 24639 / read 12887 | write 24902 / read 12887 |

so opus resumes hit, fable resumes miss every time. i also captured a subagent spawn vs its resume through the proxy on both models: the request shape change is identical on both (messages[1] is a content block array with cache_control on spawn and a plain string on resume, everything else byte identical incl. output_config effort and thinking). only fable misses on it. same with real subagents: 2 fresh fable seats missed on their first resume (~54k rewritten), 2 opus seats hit (139 and 645 written).

so either the fable cache does not treat a string content and a single text block as the same prefix while the opus one does, or the client should just replay the block form it sent originally. either way the cost only shows up on fable 5.1.

vvasic · 3 days ago

one more thing from the fable 5.1 docs (platform.claude.com/docs/en/models/fable-5-1/whats-new-fable-5-1), which i think explains why only fable misses:

Editing earlier turns invalidates thinking blocks. Modifying anything before a Claude Fable 5.1 thinking block (the system prompt, the tools, or an earlier message) results in an error on the next request, or in the block being dropped if you opt into that. [...] Claude Code, claude.ai, Claude Managed Agents, and the Claude Agent SDK keep that prefix intact for you.

and:

These patterns invalidate every later thinking block: [...] Injecting per-request text into an earlier turn (a reminder or status line) that you remove on the next request.

the resume replay changes messages[1] from a block array to a string, so from fables point of view an earlier turn was modified. for accounts created before 2026-08-31 the api records the mismatch silently, which matches what i see: no error, just a full re-write. so the docs promise that claude code keeps the prefix intact is exactly what the resume path does not do on 2.1.268. maintainers could confirm with thinking.block_binding.prefix_mismatch_behavior: "drop_block" and input_transformations on a resumed request.

cost side: fable cache reads are $0.25/MTok, 1h cache writes $20/MTok, so every resume of a long fable session pays the write rate on the whole history instead of the read rate, 80x per token.

vvasic · 3 days ago

closing the caveat from the report: with ENABLE_TOOL_SEARCH=true the proxy capture keeps tool search on (12 tools incl. ToolSearch, deferred, no listing block in messages[0]), i.e. the normal request shape. same result, fable, mcp off:

| call | cache_read | cache_creation |
|---|---|---|
| fresh | 0 | 30533 |
| resume 1 | 12690 | 18081 |
| resume 2 | 30536 | 475 |

fresh vs resume 1: tools identical, system identical, messages[0] identical (4 blocks), first and only diff is messages[1] (block array with cache_control on fresh, plain string on resume, same text). resume 2 hits again because resume 1 wrote its own entry at the assistant reply with messages[1] already in string form; only the previous turns hook-context message flips again and it is small. so the big miss is the first resume of any session (or subagent) on fable, where the flipped block is the ~22k char session-start message sitting right after messages[0] with no earlier message breakpoint.

omgbabyweb · 2 days ago

Independent confirmation on Claude Code 2.1.268, first-party Claude subscription authentication, --safe-mode, medium effort, macOS x86_64. The official latest-channel binary was downloaded separately and checked against its published SHA-256; no production upgrade was involved.

Six successful main requests: fresh session plus two genuinely new continuations for each model, sequentially within each arm. No tools were called. Final result.usage values (cache read / cache creation / uncached input):

| Model | Fresh | Resume 1 | Resume 2 |
|---|---|---|---|
| fableclaude-fable-5-1 | 10,126 / 6,647 / 2 | 13,003 / 3,890 / 2 | 13,003 / 4,010 / 2 |
| opusclaude-opus-5 | 10,126 / 5,751 / 2 | 15,877 / 117 / 2 | 15,994 / 117 / 2 |

The Fable continuations both report native message.diagnostics.cache_miss_reason.type = messages_changed (estimated missed tokens 3,554 and 3,647). Opus diagnostics are null alongside growing cache reuse. Initial requests need not be cold: shared static prefixes may already be cached.

Native request-body capture using OTEL_LOG_RAW_API_BODIES=file:<private-directory> confirms, for both models:

  • Message counts 2 → 5 → 8.
  • Tools unchanged within each arm.
  • First continuation: only historical message index 1 changes, from a text-block list to a string with identical text.
  • Second continuation: only historical message index 4 changes in the same way; earlier entries remain equal.

Thus in this safe-mode run the second Fable continuation still does not regain growing conversation reuse, unlike the last posted example above. Also, the representation transition itself is not a sufficient causal explanation: Opus makes the same transition and succeeds. Native model-dependent construction versus server interpretation remains unresolved.

Reproduction outline (normal subscription-authenticated official CLI; use a new UUID per model):

claude -p --safe-mode --model fable --effort medium --output-format stream-json --verbose --session-id <new-uuid> 'Independent cache probe <uuid>, new continuation 0. Do not use tools or change files. Reply exactly CURRENT_CACHE_0_OK.'
claude -p --safe-mode --model fable --effort medium --output-format stream-json --verbose --resume <same-uuid> 'Independent cache probe <uuid>, new continuation 1. Do not use tools or change files. Reply exactly CURRENT_CACHE_1_OK.'
claude -p --safe-mode --model fable --effort medium --output-format stream-json --verbose --resume <same-uuid> 'Independent cache probe <uuid>, new continuation 2. Do not use tools or change files. Reply exactly CURRENT_CACHE_2_OK.'

Repeat with --model opus and a different UUID. Our runner supplies the prompt via stdin rather than the positional argument shown above, and uses a credential-isolating wrapper that executes the unmodified official binary. Safe mode disables project customizations. No custom system prompt or beta-disable override was added.

Earlier independent tests on 2.1.260/2.1.263 also reproduced the Fable pattern, including new-input boundaries within one continuous CLI process, so process restart/resume is not necessary for the failure. Those earlier continuous-process results are not claimed as a new 2.1.268 test.

Current Fable continuation message IDs for maintainer correlation: msg_011CewCyJFHCj9kUMrivvbJ1, msg_011CewCyWppsH1PpdrSUWiFm. No raw prompts, credentials, or session dumps attached.