Prompt cache invalidated when PreToolUse/PostToolUse additionalContext changes during history rebuild

Status Open
Maintainer reply None cached
Activity 6 comments · opened Aug 4, 2026

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:

  1. PostToolUse context changes representation between the live tool loop and rebuilt history: old hook text can move between user/system carriers or be merged into tool_result.content.
  2. PreToolUse context can lose the trailing newline after its rendered wrapper during rebuild. A one-byte historical change is enough to break prefix caching.
  3. On resume, tool-result persistence can reset its parent to sourceToolAssistantUUID, leaving the matching PreToolUse record 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 additionalContext carrier divergence
  • #84011 — exact PreToolUse additionalContext newline loss
  • #76606 — broader historical-hook-message rewrite report
  • #38575, #43657, #44045 — related hook-parent-chain and resume reconstruction defects

View original on GitHub ↗

4 Comments

hb-man · 25 days ago

Follow-up with request-level evidence from 2.1.222. This is narrower than the original report, but it identifies one deterministic mechanism.

  • In a matched same-process run on untouched 2.1.222, the final tool-loop request used 29,666 cache_read / 22,761 cache_create. The first request after the next ordinary prompt used 29,666 / 22,985, rewriting the 22,761-token prefix; the following request recovered to 52,651 / 186.
  • A structural diff of those outgoing requests showed system and tools unchanged. 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.
  • The live merge path calls A7o() / yCb(), which appends one newline to a preceding PreToolUse hook-context wrapper. The rebuilt transcript lacks that newline (149 -> 147 JSON bytes). Rebuild can also promote old hook_* attachments from user carriers into mid-conversation system messages. Both changes are inside the old cached prefix.
  • A separate resume defect is in transcript topology: after persisting PreToolUse context, tool-result persistence resets parentUuid to sourceToolAssistantUUID. 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 at 52,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.

hb-man · 25 days ago

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.

CrazyCoder · 24 days ago

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_read to what appears to be the first cache breakpoint (system + tools) and recreates the remainder as cache_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_context attachments 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_context renders as a <system-reminder>-wrapped user message, and a later normalization step folds <system-reminder>-prefixed text siblings into the last tool_result of 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. additionalContext from SessionStart and UserPromptSubmit stayed warm here too. Those blocks land in a user message carrying no tool_result, so the fold never applies. This matches the control results in the report.

hook_success is 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. Only additionalContext is implicated. Worth stating explicitly, because it makes the workaround far narrower than "stop using hooks".

Workaround confirmed. Changing our hooks so PreToolUse/PostToolUse emit additionalContext at 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.

bobleer · 24 days ago

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 additionalContext is folded into the tool result field result_for_assistant before 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 parentUuid repair 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.

Showing cached comments. Read the full discussion on GitHub ↗