[BUG] Cache entry written with its breakpoint on a trailing `role: system` message is never reused — with the per-user-turn `<total_tokens>` reminder on, every tool-less turn re-writes the whole conversation (likely also the root cause of #93490)
Preflight Checklist
- [x] I have searched existing issues. Closest: #93490 (same symptom on
--resume, attributed to string-vs-array re-serialization ofmessages[1]), #87966 / #83913 / #91971 (same "cache_read pinned to the tools+system floor" signature, different triggers). This report isolates a different, testable mechanism and a one-line fix. - [x] This is a single bug report
- [x] I am using the latest version of Claude Code (2.1.280)
What's Wrong?
Claude Code appends mid-conversation role: system messages after the user message — the session-start # Environment message on turn 1, and, when the per-user-turn total-tokens reminder is active, a <total_tokens>N tokens left</total_tokens> system message after every user prompt. It then puts the request's last cache_control breakpoint on that trailing system message.
The cache entry written at that breakpoint is never matched by the next request. The API's own diagnostics say so on every following call:
"diagnostics": {"cache_miss_reason": {"type": "messages_changed", "cache_missed_input_tokens": 12016}}
cache_read_input_tokens stays pinned at the tools+system floor and cache_creation_input_tokens is everything after it. In a conversation made of tool-less turns (chat, Q&A, remote-control/bridge usage), every turn re-writes the entire message history. Within tool loops the damage is limited to the tail, because tool_result turns leave usable entries behind.
Isolation (same 4-turn session, only one variable changed each time)
Setup: claude -p --input-format stream-json --output-format stream-json, one process, 4 user turns ("Say only 1", "Say only 2", …), no tools called, MCP off, model claude-opus-5-5, effort constant. Reminder forced on with --settings '{"totalTokensReminder":"fixed","totalTokensReminderAfterUserTurn":true}'. Requests captured and, in arms B/C, rewritten by a local pass-through proxy (ANTHROPIC_BASE_URL=http://127.0.0.1:PORT + _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1; the proxy logs bodies only, never auth headers).
| arm | what the proxy did | turn 3 cache_creation / cache_read | turn 4 cache_creation / cache_read | cache_miss_reason on turns 2–4 |
|---|---|---|---|---|
| A — as sent by Claude Code | nothing | 14,643 / 22,763 | 14,683 / 22,763 | messages_changed ×3 |
| B — shape normalized | every role: system message sent as a block array (never a plain string) — verified in the forwarded bodies | 14,643 / 22,763 | 14,683 / 22,763 | messages_changed ×3 |
| C — breakpoint moved | cache_control removed from the trailing system message and put on the last block of the preceding user message | 40 / 37,343 | 40 / 37,383 | none from turn 3 on |
| D — reminder off | --settings '{"totalTokensReminder":"off"}', no proxy rewrite | 16 / 37,292 | 16 / 37,308 | none from turn 3 on |
- B rules out the string-vs-array explanation. Claude Code does send the trailing system message as
[{"type":"text", …, "cache_control": …}]and re-sends it as a plain string on the next request, but making the shape identical changes nothing. - C shows the breakpoint placement is the cause. With the breakpoint on the last non-system message, the same requests hit normally.
- Turn 2 misses in every arm for the same reason: turn 1's only breakpoint is on the trailing
# Environmentsystem message. That is a one-time loss of ~12–15k tokens per session. With the reminder on, the same loss recurs every turn and grows with the conversation.
Why this probably explains #93490
In #93490 the first request of a fresh session ends with messages[1] = the # Environment system message carrying the breakpoint, and --resume then misses everything past the floor. Arm B shows that re-serializing that message as a string is not what breaks the match: an entry whose breakpoint sits on a trailing system message is unusable regardless of shape. It also fits the two observations there: two fresh launches hit each other (identical trailing-system request), and "resume 2" hits (resume 1 wrote its entry at the assistant reply, not at a system message).
What Should Happen?
- Claude Code: never place
cache_controlon a trailingrole: systemmessage. Put it on the last block of the last non-system message instead, which is the pattern the mid-conversation system messages docs describe: "Append the system message after the breakpoint." The trailing system message is a few dozen tokens; losing it from the cached prefix costs nothing. - API/docs: the mid-conversation system messages page says a system message "is itself cacheable … On the next turn you can move your cache breakpoint past it … and the system message is read from cache like any other turn." That does not hold when the entry was written with the breakpoint on the trailing system message. Either the rendering of a trailing system message should be prefix-stable, or the docs should say such an entry can't be extended.
Minimal repro (no proxy needed for the symptom)
# python3 repro.py claude-opus-5-5
import json, os, subprocess, sys
model = sys.argv[1]
p = subprocess.Popen(['claude', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
'--model', model, '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
'--settings', '{"totalTokensReminder":"fixed","totalTokensReminderAfterUserTurn":true}'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
for i in range(1, 5):
p.stdin.write(json.dumps({'type': 'user', 'message': {'role': 'user', 'content': f'Say only {i}.'}}) + '\n'); p.stdin.flush()
for line in p.stdout:
ev = json.loads(line)
if ev.get('type') == 'result':
u = ev['usage']; print(f"turn {i}: creation={u['cache_creation_input_tokens']} read={u['cache_read_input_tokens']}"); break
p.stdin.close(); p.wait()
Expected: from turn 3 on, creation is tens of tokens. Actual: creation equals the whole history after the system prompt on every turn. Change the setting to {"totalTokensReminder":"off"} and turns 3+ hit.
Without the --settings override, whether the reminder is on varies between runs: the same command gave both behaviours within minutes. It looks server-flag controlled, which would make this intermittent in the wild, as in #87966.
Also observed
- Same pattern (turns 2–4 re-writing ~18k each with the cache read pinned at the floor) on
claude-opus-5andclaude-fable-5-1with 2.1.280, before I isolated the cause. Not re-run per arm on those models. - Real-world impact on my own traffic is modest: 7 days of a remote-control bridge driving Claude Code (1,042 API calls) had 5 full-history re-writes (~1.16M cache_creation tokens) that fit this pattern (<60 min gap, same model, previous turn ended without tool use). Users with chat-style or bridge-style sessions and long contexts will be hit much harder.
Environment
- Claude Code 2.1.280 (native CLI and the Claude desktop app's bundled copy)
- macOS 26.6.2, arm64
- Claude Max subscription (1h cache TTL requested automatically), first-party API
- Model:
claude-opus-5-5(isolation arms); symptom also seen onclaude-opus-5,claude-fable-5-1 - Beta headers on the main requests included
mid-conversation-system-2026-04-07,per-turn-control-2026-07-01,prompt-caching-scope-2026-01-05,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07
Happy to share the proxy script and redacted request structures (lengths + hashes, no content) if useful.