[BUG] Turn-boundary prompt-cache misses on Fable 5.1 / Claude 5: 7% → 29% between 2.1.224 and 2.1.252, mechanism captured, fixed in 2.1.259 — with two residuals and one default worth changing

Status Open
Reported on v2.1.252
Maintainer reply None cached
Activity 1 comment · opened Sep 3, 2026

Connecting #83913, #87966, #87215, #87227 and #91514, which I believe are one regression. I captured full /v1/messages bodies on both sides of every turn boundary plus the server's cache_miss_reason (via the cache-diagnosis-2026-04-07 beta) on 2.1.252 and again on 2.1.259.

Environment: macOS, OAuth (Max), claude-fable-5-1[1m] and claude-fable-5, one large agentic monorepo (~30 hooks, background subagents, teammate messages), Aug 20 – Sep 3.

Symptom, from ~5,300 turn boundaries

| client | turn-boundary misses (<5 min idle) |
|---|---|
| 2.1.224 | 7.2% (n = 4,757) |
| 2.1.252 | 29.2% (n = 538) |

Mid-turn tool loops: ~0.7% on every version. Misses tier by what survives (cache_read at the miss): T1 read ≈ system+tools only, T2 read ≈ system+tools+early history. Teammate/subagent-completion-opened turns missed at 10.6× the typed-turn rate — that is #87215 and the "subagent wake" reports in #87966 / #91514. On Fable 5.1 across two days: 206M cache-write tokens, 96% from 484 such misses.

Mechanism: hook additionalContext is dropped on the next history rebuild (= #83913)

3-arm control, haiku, --strict-mcp-config, identical 8-turn spawn-heavy workload, everything else byte-identical:

| arm | misses | cache_creation |
|---|---|---|
| PreToolUse hook emitting additionalContext, text changes each call | 7 (1×T1, 6×T2) | 210,497 |
| PreToolUse hook emitting additionalContext, text constant | 7 (1×T1, 6×T2) | 171,457 |
| no hook | 0 | 85,110 |

Byte diff, request N → N+1, at the user message carrying the spawn tool_results:

N   : blocks = [tool_result ×5, text ×6]
      …"type":"tool_result"},{"text":"<system-reminder>\nPreToolUse:Agent hook additional context: …\n</system-reminder>","type":"text"}, …
N+1 : blocks = [tool_result ×5]
      …\n\n<system-reminder>\nThe task tools haven't been used recently…   ← re-inlined INTO the last tool_result

Every <system-reminder> block carrying hook context is removed from that message and the between-turn reminder is folded into the last tool_result's text. system, tools, params, headers: identical. Divergence is always at the previous turn boundary; T1 vs T2 is only where in history that message sits. Teammate-opened turns are worst because the completion arrives as a between-turn notification and the rebuild runs against the message most likely to carry hook context from the spawn.

Refuted along the way so nobody re-chases them: string↔block content-shape flip (canonicalized server-side; controlled A/B = full hit), tools churn from ListAgents/SendMessage (byte-identical across a teammate lifecycle), output-style block, per-turn effort statement, MCP tool-list deltas.

2.1.259 fixes it

Same project, Fable 5.1, real hooks (one PreToolUse hook still emits additionalContext), background subagents + SendMessage + teammate-completion turns:

| session | requests | boundary misses |
|---|---|---|
| A | 20 | 0 — cache_read monotonic 98k → 123k |
| B | 37 | 1 (first typed turn); all 6 teammate-opened turns full hits |
| C (real work, 720 req) | 720 | 7: one pasted image (#91705), five cd-triggered system-prompt rewrites + one memory-index edit (#91706) |

On 2.1.259 the hook context is rendered as a role:"system" turn and survives every rebuild byte-for-byte. Suggest closing #87227 / #87215 / #91514 as duplicates of #83913 fixed in 2.1.259. Workaround for anyone pinned below: don't emit additionalContext from PreToolUse hooks; write it to a file the model reads.

Residual 1 — a pasted image is re-rendered as a file pointer on the next request

Sent as base64 on the turn it's pasted; on the next request the same message becomes [Image: source: ~/.claude/image-cache/…/1.png]. Prefix breaks there: on a 232k-token session, read fell to 84k and 159k was rewritten for a 136 KB PNG — and the model no longer has the image (the pointer is text; it doesn't re-read the file). Keeping the block byte-stable would cost ~1.5k cached tokens/turn. Filed separately with the receipt: #91705. Workaround: deliver images as a path the model Reads (tool results are replayed byte-for-byte and the image stays in context); I built a menu bar app that makes the native screenshot shortcut do that, https://github.com/ArshansGithub/shotpath.

Residual 2 — a persisting cd in Bash rewrites the system prompt and drops the entire cache

The most expensive thing left, and it took a recorder in front of the session to see. Claude Code tracks the shell cwd across Bash calls; on the next system-prompt rebuild it re-resolves project context from the new cwd, and in a monorepo with nested .claude/ trees the agent-memory root flips and the first system block changes. Captured five times in one session at ~580k tokens each; two cd round-trips cost ~$24. Byte diff, mechanism, and the client-side fixes that would close it are in #91706. Worked around with a hook that denies a persisting cd and by consolidating the nested trees: zero collapses since.

(Earlier drafts of this issue blamed async notifications and the mid-conversation role:"system" fallback; both were timing coincidences and are withdrawn.)

A second default worth revisiting: security-guidance ships enabled and runs an Opus 4.7 call at the end of every turn

Unrelated to caching, found while attributing account usage. The official security-guidance plugin is enabled by default with the marketplace. Its Stop hook sends every turn's diff to claude-opus-4-7 for an LLM review, and its commit hook runs an SDK agent with Read/Grep/Glob on every git commit, all on the user's own OAuth token. On an agentic setup (orchestrator + subagents, so many turn-ends per minute) over the last 7 days:

| | |
|---|---|
| Stop-hook runs | 3,943 |
| Opus 4.7 review calls | 3,070 (avg 88k input tokens each, 5m cache tier) |
| findings returned to the model | 0 |
| findings that blocked anything | 0 |
| list-price equivalent | ~$1,390 (~$200/day) |

That was about the same magnitude as the entire Fable 5.1 cache waste above, on the account that then hit its weekly window. The regex layer is cheap and fine; the per-turn LLM review deserves to be opt-in, or at least to default to a small model and skip subagent turns.

Result

2.1.259 + promptCacheTtl: "1h" + hooks not emitting additionalContext, measured on a 540-request real session: write share of context 8.7% → 1.7%, input cost per context token 2.2× lower, every thinking block replayed. Three quarters of the remaining writes were the four events above.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗