[Harness] PostToolUse additionalContext re-serialized between turns, invalidating prompt cache

Status Fixed / completed
Reported on v2.1.220
Maintainer reply None cached
Activity 4 comments · opened Jul 25, 2026 · closed Aug 20, 2026

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), and
system, 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

  1. Configure a PostToolUse hook that returns

{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "hello"}}.

  1. Start a turn that runs many tool calls, and let the hook fire early in it.
  2. Let the turn finish, then send any second prompt.
  3. On the second turn's first request, cache_read_input_tokens collapses 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": {…}}] to
content: "…". 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

[]

View original on GitHub ↗

3 Comments

rhgg2 · 1 month ago

This is specific to PostToolUse hooks: Stop-hook additional context becomes part of a genuine
user message CC persists as-is, whereas PostToolUse context rides on a
tool_result and gets split out into a standalone system message when
CC rebuilds from the transcript.

r-aristov · 26 days ago

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 additionalContext arrives as a separate text block following the tool_result block; on the next turn's rebuild the same text is merged into tool_result.content:

live turn: {"tool_result": "A1"}, {"text": "<system-reminder>\nPostToolUse:Bash hook additional context: [...]\n</system-reminder>"}
rebuilt:   {"tool_result": "A1\n\n<system-reminder>\nPostToolUse:Bash hook additional context: [...]\n</system-reminder>"}

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 additionalContext has a second, distinct rebuild defect - a single trailing \n lost on rebuild - filed separately as #84011 with a minimal repro script.

hb-man · 24 days ago

Cross-linking #83913 with an independent official-binary control. On untouched v2.1.223 with transcript-verified claude-sonnet-5 and a one-hour cache, an isolated PostToolUse timestamp hook returning additionalContext reproduced 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.

Showing cached comments. Read the full discussion on GitHub ↗