[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
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:
contentis an array with one text block,cache_control: {type: ephemeral, ttl: 1h} - resume:
contentis 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].
4 Comments
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.
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:
and:
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"andinput_transformationson 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.
closing the caveat from the report: with
ENABLE_TOOL_SEARCH=truethe 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.
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.usagevalues (cache read / cache creation / uncached input):| Model | Fresh | Resume 1 | Resume 2 |
|---|---|---|---|
|
fable→claude-fable-5-1| 10,126 / 6,647 / 2 | 13,003 / 3,890 / 2 | 13,003 / 4,010 / 2 ||
opus→claude-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: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):
Repeat with
--model opusand 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.