PreToolUse hook additionalContext loses trailing newline on history rebuild, breaking prompt cache at the first tool call of every turn
Environment: Claude Code 2.1.220 and 2.1.222, Python Agent SDK, Linux, subscription auth.
Summary. When a PreToolUse hook returns additionalContext, the live turn inserts it into the conversation as a text block ending with a trailing newline after the closing tag. When the history is rebuilt for the next turn's request, the same block is serialized without that trailing newline. One byte differs, so the cached prefix breaks at the first tool call of the previous turn, and the next request re-writes the entire conversation at cache-write rates.
Byte-exact diff (request bodies captured with a local logging proxy; message[4] = the first tool_result message of the long turn; both Pre and PostToolUse hooks registered):
live turn: {"text": "<system-reminder>\nPreToolUse:Bash hook additional context: [...]\n</system-reminder>\n", "type": "text"}
rebuilt: {"text": "<system-reminder>\nPreToolUse:Bash hook additional context: [...]\n</system-reminder>", "type": "text"}
Observed impact. Every turn containing tool calls misses the conversation cache entirely on the next turn boundary. On a real long-running session (1h TTL) this produced 10.4M cache-write tokens in a single run, ~200-450K re-written per turn. Conversation-only turns (no tool calls) hit cache normally, which is what makes the pattern hard to spot.
Control experiment: the same scenario with no tool hooks is clean even with 12 tool calls and CLI-internal reminders present - the boundary reads the whole history from cache and pays only for the new user message. So the CLI's own mid-conversation insertions rebuild consistently; only hook additionalContext diverges.
Expected: history rebuild is byte-identical to live construction, so the conversation cache survives turn boundaries.
Repro (watch T3's first iteration: cache_read falls back below T2's last entry; remove the hooks and it reads everything):
# repro.py - needs claude-agent-sdk; python repro.py
# T2 makes 4 sequential Bash calls with Pre+PostToolUse hooks returning
# additionalContext. T3's first iteration then fails to read T2's cache tail:
# cache_read falls back to the entry BEFORE T2's first tool call.
# Remove the two tool hooks -> boundary is clean (T3 pays only for its own message).
import anyio, os
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, HookMatcher
async def post_hook(inp, tuid, ctx):
return {"hookSpecificOutput": {"hookEventName": "PostToolUse",
"additionalContext": "[post-signal: static text]"}}
async def pre_hook(inp, tuid, ctx):
return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
"additionalContext": "[pre-signal: static text]"}}
async def main():
opts = ClaudeAgentOptions(
model="haiku", cwd="/tmp",
system_prompt="You are a test agent. Do exactly what is asked.",
allowed_tools=["Bash"], permission_mode="bypassPermissions", max_turns=30)
opts.hooks = {"PostToolUse": [HookMatcher(hooks=[post_hook])],
"PreToolUse": [HookMatcher(hooks=[pre_hook])]}
async with ClaudeSDKClient(options=opts) as c:
async def turn(tag, prompt):
await c.query(prompt)
async for m in c.receive_response():
u = getattr(m, "usage", None)
if u:
d = u if isinstance(u, dict) else u.__dict__
print(tag, {k: d.get(k) for k in ("cache_read_input_tokens",
"cache_creation_input_tokens")})
await turn("T1", "Say one word: hello")
await turn("T2", "Run 4 separate Bash calls, one command each: echo A1, "
"then echo A2, up to echo A4. Then say done.")
await turn("T3", "Thanks. Say one word: done")
anyio.run(main)
Related: #81077 - PostToolUse variant of the same bug class (live vs rebuilt serialization divergence). In our capture the PostToolUse context is a separate text block live, but gets merged into tool_result.content on rebuild.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗