[Harness] PostToolUse additionalContext re-serialized between turns, invalidating prompt cache
Bug Description
PostToolUse additionalContext is re-serialized between turns, invalidating the entire prompt cache
Summary
Context returned by a hook as hookSpecificOutput.additionalContext is
delivered to the model in one shape during the turn, and a different shape
from the next turn onwards. The change lands wherever the hook fired, often
far back in history, so it invalidates every cached message after it. On a
long turn this costs a full-context cache write per turn that received hook
context.
Claude Code's own injected reminders (e.g. "The task tools haven't been used
recently…") do not have this problem: they arrive as role: "system"
messages immediately and survive turn boundaries byte-identical. Only the
hook additionalContext path changes shape.
The two representations
During the turn — a <system-reminder>-wrapped text block appended to
the user tool_result message that triggered the hook:
{
"role": "user",
"content": [
{ "type": "tool_result", "tool_use_id": "toolu_01SXL5…", "content": "…" },
{ "type": "text",
"text": "<system-reminder>\nPostToolUse:Bash hook additional context: <GUIDANCE TEXT>\n</system-reminder>" }
]
}
From the next turn's first request — the wrapper is stripped and the text
becomes a standalone message inserted after the tool result:
{ "role": "system",
"content": "PostToolUse:Bash hook additional context: <GUIDANCE TEXT>" }
Diffing the last request of one turn against the first request of the next,
this relocation is the only substantive change in the shared prefix;
everything after it is a one-index shift (verified elementwise), andsystem, tools, and context_management are byte-identical.
Impact
Claude Code places a single message-level cache_control breakpoint near the
tail of the message list, so a change this far back drops the entire message
cache; only the system/tools breakpoints survive.
Observed on two sessions (wire capture via a read-only reverse proxy):
| session | request | state of the hook context | cache write | cache read |
|---|---|---|---|---|
| A | turn N, injection | wrapped, at msg 43 | 1,508 | 95,840 |
| A | turn N, last request | wrapped, at msg 43 | 518 | 150,972 |
| A | turn N+1, first request | now role:"system" msg 44 | 140,916 | 6,472 |
| B | turn M, injection | wrapped, at msg 24 | 2,304 | 41,707 |
| B | turn M, second injection | wrapped, at msgs 24 and 45 | 1,635 | 58,213 |
| B | turn M+1, first request | now role:"system" msgs 25, 47, 55 | 57,007 | 8,057 |
| B | turn M+2, first request (no hook context in turn M+1) | — | 2,468 | 65,064 |
The last row is the control: a turn boundary with nothing to convert is
cheap, so the cost is specific to the re-serialization and not to starting a
turn. A third instance in session A cost 87,306 tokens. In session A the read
fell from 143,250 to 6,472 across the boundary.
Reproduction
- Configure a
PostToolUsehook that returns
{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "hello"}}.
- Start a turn that runs many tool calls, and let the hook fire early in it.
- Let the turn finish, then send any second prompt.
- On the second turn's first request,
cache_read_input_tokenscollapses to
roughly the system + tools prefix and cache_creation_input_tokens is
approximately the whole conversation.
Suggested fix
Emit one representation on both paths — either inject in-flight in the
persisted role: "system" shape, or persist the in-flight<system-reminder> block as-is. Either way the prefix stops changing and the
cache survives.
(Which shape is correct is a separate question — the wrapped inline form and
the separate system turn presumably read differently to the model. The defect
is the mismatch.)
Secondary, same cause
At the same boundary, the trailing built-in reminder message also changes
from content: [{"type": "text", "text": "…", "cache_control": {…}}] tocontent: "…". Harmless today only because it sits at the tail, past the
breakpoint.
Environment
- Claude Code 2.1.220
- macOS 22.6.0 (darwin), zsh
- Model
claude-opus-5, 1h ephemeral cache TTL - Wire captured by a read-only proxy via
ANTHROPIC_BASE_URL; nothing is
injected, rewritten or reordered on the wire by the proxy.
Environment Info
- Platform: darwin
- Terminal: tmux
- Version: 2.1.220
- Feedback ID: 73de6cf2-921f-42ac-9b60-595d150a4947
Errors
[]Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
This is specific to
PostToolUsehooks:Stop-hook additional context becomes part of a genuineuser message CC persists as-is, whereas
PostToolUsecontext rides on atool_resultand gets split out into a standalonesystemmessage whenCC rebuilds from the transcript.
Confirmed on 2.1.220 and 2.1.222 (Python Agent SDK, Linux, subscription auth). We captured request bodies with a local logging proxy and can add the byte-exact divergence.
In our capture the re-serialization differs slightly from the OP's description: within the live turn, PostToolUse
additionalContextarrives as a separatetextblock following thetool_resultblock; on the next turn's rebuild the same text is merged intotool_result.content:Either way the bytes differ, the prefix breaks at the first tool call of the previous turn, and the next request re-writes the entire history at cache-write rates. Measured impact on a real session (1h TTL): 10.4M cache-write tokens in one run, ~200-450K per turn; every turn containing tool calls missed cache, while conversation-only turns hit - which makes this easy to misdiagnose.
Control: the same long-turn scenario with no tool hooks is clean (boundary reads everything from cache), so the CLI's own mid-conversation insertions rebuild consistently; only hook additionalContext diverges.
PreToolUse
additionalContexthas a second, distinct rebuild defect - a single trailing\nlost on rebuild - filed separately as #84011 with a minimal repro script.Cross-linking #83913 with an independent official-binary control. On untouched v2.1.223 with transcript-verified
claude-sonnet-5and a one-hour cache, an isolatedPostToolUsetimestamp hook returningadditionalContextreproduced a 22,006-token prefix rewrite at the next prompt; the following request recovered. A zero-hook control and an all-hooks-except-tool-level-timestamps control stayed warm. #83913 also covers the paired PreToolUse path and the related resume parent-chain defect.