[BUG] $.model.fork misses the conversation prompt cache when Message Threads and a mid-conversation system message are both active (2.1.291)

Status Open
Reported on v2.1.291
Maintainer reply None cached
Activity 0 comments · opened Oct 6, 2026

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=1h
  • CLAUDE_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 send message-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=false set, the message-threads-2026-08-12 header disappears and the fork hits. Everything else stays the same, including mid-conversation-system-clear-at-2026-08-21.
  • On a direct connection with only CLAUDE_CODE_MODEL_CAPABILITIES=-mid_conv_system set, the debug log still shows message-threads-2026-08-12 on 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

  1. Create a plugin directory named fork-probe with 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}` }
  })
}
  1. 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}'
  1. Compare the fork's numbers with the second prompt's numbers. The second prompt's read plus write is 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.

  1. 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=false
  • CLAUDE_CODE_MODEL_CAPABILITIES=-mid_conv_system
  • CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1
  • ANTHROPIC_BASE_URL set to a local proxy that forwards to https://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.fork miss on 2.1.289 and the -mid_conv_system fix. 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: /compact misses the prompt cache on Opus 5.5 and Sonnet 5.5 while the cache entry is still live. CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 also fixes it, so it can have the same cause.

View original on GitHub ↗