[BUG] $.model.fork misses the conversation prompt cache when Message Threads and a mid-conversation system message are both active (2.1.291)
Preflight Checklist
- [x] I have searched existing issues and this has not been reported yet
- [x] This is a single bug report
- [ ] I am using the latest version of Claude Code
What's Wrong?
A mod can call $.model.fork({ prompt }) to ask a side question about the current conversation. The fork sends the main conversation's last request again, with one more user message after it. The docs say the fork reuses the conversation's cached prefix. So a fork should cost a cache read of the conversation, not a full cache write.
On a direct connection to the Anthropic API, the fork misses the conversation cache. The part it reads from cache matches only the shared system prompt. It writes the rest of the conversation to the cache again. The fork runs a few seconds after the last main request, well inside the cache TTL. The main conversation's own requests read their cache normally in every case.
The miss needs two things together: Message Threads (beta header message-threads-2026-08-12) and a mid-conversation role: "system" message. If I turn off either one, the same fork reads the conversation from cache.
| Setup | Last main request (prompt tokens) | Fork cache read | Fork cache write | Result |
| --- | --- | --- | --- | --- |
| Direct connection, default settings | about 30k | 10k | 19k | Miss |
| Direct connection, CLAUDE_CODE_TETHER_LIVE=false (threads off) | 30k | 30k | 0 | Hit |
| Direct connection, CLAUDE_CODE_MODEL_CAPABILITIES=-mid_conv_system (threads still on) | 30k | 29k | 1k | Hit |
| Direct connection, CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 | 73k | 72k | 266 | Hit |
| ANTHROPIC_BASE_URL set to a plain local proxy that forwards to api.anthropic.com | 31k | 30k | 1k | Hit |
The context in the CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 row is larger because tool search is off in that mode. An interactive session on a direct connection gives the same miss: with 36k of context, the fork read 15k and wrote 21k.
These settings did not change the miss on a direct connection:
- Thinking turned off
CLAUDE_CODE_PROMPT_CACHE_TTL=1hCLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h
How I found the two conditions:
- With
CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose, the[API REQUEST DETAIL]lines show that on a direct connection the main requests and the fork both sendmessage-threads-2026-08-12. - Through a custom
ANTHROPIC_BASE_URL, the header is absent. That is why the proxy run hits. The server flag that turns threads on applies only when no custom base URL is set. - On a direct connection with only
CLAUDE_CODE_TETHER_LIVE=falseset, themessage-threads-2026-08-12header disappears and the fork hits. Everything else stays the same, includingmid-conversation-system-clear-at-2026-08-21. - On a direct connection with only
CLAUDE_CODE_MODEL_CAPABILITIES=-mid_conv_systemset, the debug log still showsmessage-threads-2026-08-12on every request, and the fork also hits.
In the proxied requests, the main request puts its last cache breakpoint on a trailing mid-conversation role: "system" message. The fork sends that message without cache_control, then the main reply and its own prompt. Without threads, this layout reads the cache. With threads, it does not. I do not know the server-side reason.
The mods API docs say:
$.model.fork({ prompt }) asks one question over the current conversation instead, with the same model and system prompt, so the Claude API serves most of it from the prompt cache.
The type definitions that ship with Claude Code 2.1.291 say this about fork:
Runs one tool-less completion over the session's OWN transcript as the main thread last sent it, so the API serves that prefix from its cache.
And this about the fork's usage:
On a fork cache_read_input_tokens is how much of the transcript the main thread's prompt cache served: near zero, the fork paid for the whole prefix (the entry lapsed, or the model changed since).
In these runs the cache entry had not lapsed and the model did not change.
What Should Happen?
The fork should read about the whole last main request from cache and write only its own short prompt. It does this when Message Threads is off, and when there is no mid-conversation system message.
Error Messages/Logs
Forked agent [plugin_model_fork] finished: 4 messages, ... totalUsage: input=53 output=4 cacheRead=10481 cacheCreate=19447
There is no error. This debug log line is for a fork on a direct connection with default settings. The last main request had about 30k prompt tokens.
Steps to Reproduce
- Create a plugin directory named
fork-probewith these three files.
fork-probe/.claude-plugin/plugin.json:
{ "name": "fork-probe", "version": "0.0.1", "description": "Runs one $.model.fork and prints its cache usage" }
fork-probe/hooks/hooks.json:
{ "modules": ["./register.ts"] }
fork-probe/hooks/register.ts:
import type { Register } from 'claude-code'
export const register: Register = (on) => {
on('session.start', async ($, e, next) => {
const result = await next(e)
await $.command.register({ name: 'fork-probe', description: 'Run one $.model.fork and print its cache usage', immediate: true })
return result
})
on('command.run', { command: 'fork-probe' }, async ($) => {
const r = await $.model.fork({ prompt: 'Reply with one word: ok' })
if (!('usage' in r)) return { text: `no fork (${r.reason})` }
const u = r.usage
return { text: `cache_read=${u.cache_read_input_tokens} cache_creation=${u.cache_creation_input_tokens} output=${u.output_tokens}` }
})
}
- Send two short prompts and then the command in one session. Use a direct connection with default settings.
printf '%s\n' \
'{"type":"user","message":{"role":"user","content":"Reply with one word: ready"}}' \
'{"type":"user","message":{"role":"user","content":"Reply with one word: again"}}' \
'{"type":"user","message":{"role":"user","content":"/fork-probe"}}' |
claude -p --input-format stream-json --output-format stream-json --verbose \
--model claude-sonnet-5-5 --plugin-dir ./fork-probe |
jq -c 'select(.type == "result") | {result, read: .usage.cache_read_input_tokens, write: .usage.cache_creation_input_tokens}'
- Compare the fork's numbers with the second prompt's numbers. The second prompt's
readpluswriteis the size of the cached conversation. The fork should read about that much. It reads only the system prompt part and writes the rest. Output on a direct connection with default settings:
{"result":"Ready.","read":8832,"write":19709}
{"result":"Again.","read":28541,"write":1374}
{"result":"fork-probe: cache_read=10476 cache_creation=19442 output=6","read":0,"write":0}
The size of the miss depends on how much conversation follows the system prompt, such as CLAUDE.md files and hook output. With very little of it, the miss is only a few thousand tokens.
- Run step 2 again with one of these changes. The fork now reads almost the whole conversation from cache and writes about 1k or less.
CLAUDE_CODE_TETHER_LIVE=falseCLAUDE_CODE_MODEL_CAPABILITIES=-mid_conv_systemCLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1ANTHROPIC_BASE_URLset to a local proxy that forwards tohttps://api.anthropic.com
Claude Model
Sonnet 5.5 (claude-sonnet-5-5)
Is this a regression?
I do not know
Last Working Version
_No response_
Claude Code Version
2.1.291 (Claude Code). The 2.1.292 changelog lists no change to $.model.fork or Message Threads.
Platform
Anthropic API (Claude subscription, signed in with OAuth)
Operating System
macOS
Terminal/Shell
Non-interactive/CI environment (claude -p). An interactive session shows the same miss.
Additional Information
Each fork pays a full cache write of the conversation instead of a cache read. A 1-hour cache write costs 2x the base input price. A cache read costs 0.1x. So each fork costs about 20 times more than it should for the cached part, and the cost grows with the conversation.
Workaround: set CLAUDE_CODE_TETHER_LIVE=false or CLAUDE_CODE_MODEL_CAPABILITIES=-mid_conv_system. Neither variable is documented.
Possibly related:
- #96998: a comment there reports the same
$.model.forkmiss on 2.1.289 and the-mid_conv_systemfix. It also reports that the miss rate changed between two time windows on the same build. A server flag rollout of Message Threads would explain that. - #99624:
/compactmisses the prompt cache on Opus 5.5 and Sonnet 5.5 while the cache entry is still live.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1also fixes it, so it can have the same cause.