Prompt cache invalidated when PreToolUse/PostToolUse additionalContext changes during history rebuild
Summary
Claude Code changes previously cached PreToolUse and PostToolUse additionalContext when rebuilding conversation history for the next turn. The first request after an ordinary user prompt therefore misses the still-valid conversation prefix and rewrites it at the cache-write rate. The following request is warm again.
This reproduces on untouched Claude Code v2.1.223 with claude-sonnet-5 and a confirmed one-hour cache TTL. It is not caused by TTL expiry, the model, filesystem writes, or timestamps themselves.
Controlled result
Exact isolated hook profiles were tested with the same binary, model, prompt, gateway, and tool sequence:
| enabled hooks | result at next prompt |
|---|---|
| none | warm |
| PreToolUse returning additionalContext | 22,000-token prefix rewrite |
| PostToolUse returning additionalContext | 22,006-token prefix rewrite |
| both | 22,066-token prefix rewrite |
Every other exercised hook stayed warm. A final control enabled every configured hook except those two, performed a real Write, and stayed warm across all eight requests, ending at 56,526 cache_read / 193 cache_create.
The same timestamp script at SessionStart and UserPromptSubmit stayed warm. A different UserPromptSubmit additionalContext hook also stayed warm. The affected path is specifically tool-level PreToolUse/PostToolUse additionalContext handling.
Cause
Captured outgoing requests show three concrete defects:
PostToolUsecontext changes representation between the live tool loop and rebuilt history: old hook text can move between user/system carriers or be merged intotool_result.content.PreToolUsecontext can lose the trailing newline after its rendered wrapper during rebuild. A one-byte historical change is enough to break prefix caching.- On resume, tool-result persistence can reset its parent to
sourceToolAssistantUUID, leaving the matchingPreToolUserecord on a sibling branch that active-ancestry reconstruction omits.
system and tools remain unchanged in the matched same-process capture; the first divergence is inside old hook context before the new prompt suffix.
Expected behavior
Once a request prefix has been sent, rebuilding history must reproduce it byte-for-byte. Hook context should retain one canonical carrier, wrapper, whitespace representation, and parent chain across live requests and resume.
Workaround
Disable tool-level hooks that return additionalContext. Timestamp injection at SessionStart and UserPromptSubmit can remain enabled. Tool-level hooks can also remain for side-effect-only logging if they emit no model context.
A local diagnostic patch that canonicalizes hook carriers/whitespace and repairs the PreToolUse parent chain removed the reproduced same-process and resume rewrites.
Environment
- Claude Code v2.1.223, official macOS arm64 binary
claude-sonnet-5- one-hour prompt-cache TTL, confirmed via
ephemeral_1h_input_tokens - same-process reproduction; no compact, resume, model switch, or auth/config change
Related issues
- #81077 — exact
PostToolUse additionalContextcarrier divergence - #84011 — exact
PreToolUse additionalContextnewline loss - #76606 — broader historical-hook-message rewrite report
- #38575, #43657, #44045 — related hook-parent-chain and resume reconstruction defects
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
Follow-up with request-level evidence from 2.1.222. This is narrower than the original report, but it identifies one deterministic mechanism.
29,666 cache_read / 22,761 cache_create. The first request after the next ordinary prompt used29,666 / 22,985, rewriting the 22,761-token prefix; the following request recovered to52,651 / 186.systemandtoolsunchanged. Two already-cached hook additional-context text blocks (121 and 122 bytes) disappeared, and the following carrier changed from a 367-byte text block to different 539-byte content before the expected new suffix was appended.A7o()/yCb(), which appends one newline to a preceding PreToolUse hook-context wrapper. The rebuilt transcript lacks that newline (149 -> 147JSON bytes). Rebuild can also promote oldhook_*attachments from user carriers into mid-conversation system messages. Both changes are inside the old cached prefix.parentUuidtosourceToolAssistantUUID. The PreToolUse record becomes a sibling rather than an ancestor and is omitted when resume reconstructs the active chain.A local patch that (1) keeps
hook_*attachments in user carriers, (2) skips the separator only for the rendered hook additional-context wrapper, (3) parents matching tool results to freshly persisted PreToolUse context, and (4) recovers matching legacy siblings with the same producer folding policy removed the reproduced rewrite. The matched request stayed warm at52,147 cache_read / 299 cache_create; an expanded run plus restart had zero prefix rewrites and 12/12 corrected PreToolUse parent links.This does not prove every event in the original 2.1.220 report is hook-caused. It proves a deterministic 2.1.222 path involving context-producing PreToolUse/PostToolUse hooks. A UserPromptSubmit hook that changes every prompt remains unrelated to this specific prefix mutation.
Claude Code mishandled valid PreToolUse/PostToolUse hook context by:
Moving it between user/system carriers.
Adding/removing a newline during reconstruction.
Persisting PreToolUse context on a branch omitted during resume.
Disabling the hooks would likely hide this specific bug.
This was discovered by working with claude code binary trying to patch it. Due to this bug claude code session would've cost/spend usage 1.5-2 times more than it would otherwise, due to cache invalidation almost on all user turns.
Independent confirmation on a different platform
Confirming this on a setup that differs from the original report in the ways most likely to matter: Windows rather than macOS arm64, a different hook suite, and Opus/Fable rather than Sonnet. Claude Code v2.1.220 through v2.1.223.
Signature. The first request after an ordinary user prompt collapses
cache_readto what appears to be the first cache breakpoint (system + tools) and recreates the remainder ascache_creation. The next request is warm again at the full prior size. Nearly every incident recovered on the immediately following request, which rules out TTL expiry, context growth, compaction and model switches. The prefix was still cacheable; it was serialized differently for exactly one request.Dose-response. Grouping user turns by how many
hook_additional_contextattachments the preceding turn produced on PreToolUse/PostToolUse, the rewrite rate rises monotonically with that count and saturates at certainty. Turns in the same sessions whose preceding turn produced none stayed at a low baseline, as did separate projects on the same machine whose hooks only use SessionStart and Stop.Carrier. Matches the first defect described in the report.
hook_additional_contextrenders as a<system-reminder>-wrapped user message, and a later normalization step folds<system-reminder>-prefixed text siblings into the lasttool_resultof the same user message. Whether that fold has happened differs between the live tool loop and rebuilt history, so the divergence lands inside an already-cached prefix.Why the cost is disproportionate to the change. Because few cache breakpoints exist and they cluster toward the tail, one divergent byte anywhere in the conversation body invalidates everything after the first breakpoint. A single-character difference re-bills the whole conversation at the cache-write rate.
Prompt-boundary events are unaffected.
additionalContextfrom SessionStart and UserPromptSubmit stayed warm here too. Those blocks land in a user message carrying notool_result, so the fold never applies. This matches the control results in the report.hook_successis inert on tool events. For events other than SessionStart and UserPromptSubmit it contributes nothing to the prompt, so hooks that return no context cost nothing. OnlyadditionalContextis implicated. Worth stating explicitly, because it makes the workaround far narrower than "stop using hooks".Workaround confirmed. Changing our hooks so PreToolUse/PostToolUse emit
additionalContextat most once per session, rather than once per matching tool call, and moving recurring reminders to UserPromptSubmit, removed the rewrites. Blocking hooks (permissionDecision/decision) were unaffected throughout, since they produce no context block.The dose-response result makes this look like a data-model bug more than a cache bug. One invariant that has held up for us is giving hook context exactly one model-visible owner at execution time. In BitFun, PostToolUse
additionalContextis folded into the tool result fieldresult_for_assistantbefore that result enters session state. Request rendering always reads the same field as the tool message, and save/restore preserves it. There is no later pass that decides whether an old hook attachment should become a user or system carrier.I would make the regression test compare canonical request bytes at three points: the live tool-loop request, the next-turn rebuild, and a save/restart/restore, stopping immediately before the new user suffix. The
parentUuidrepair is still needed for existing transcripts, but after migration active-chain reconstruction should only decide whether that already-canonical tool result belongs on the chain; it should never choose or re-fold its carrier. That catches the newline case and the sibling-branch case with the same invariant.