Session-scoped Stop hook injects mid-message, corrupting transcript when a server-side tool call is in flight

Status Open
Reported on v2.1.212
Maintainer reply None cached
Activity 0 comments · opened Jul 26, 2026

Session-scoped /goal Stop hook injects its reminder mid-message, corrupting the transcript when a server-side tool call (e.g. advisor) is in flight

Claude Code version: 2.1.212 (macOS)

Summary

When a session has an active /goal Stop hook and the assistant's current turn includes a server-side tool call (a tool whose tool_use and matching tool_result are two separate content blocks appended asynchronously to the same logical API message — in this case the advisor tool), the hook can fire and inject its goal_status attachment + reminder message between the tool_use block and its tool_result block, instead of after the assistant's message finishes.

This splits one logical assistant message across a synthetic user turn. Every later request that replays this history from the transcript then fails the Anthropic API's structural validation for server-tool blocks, because the tool_result block no longer has its matching tool_use block immediately before it in the same message. The session becomes permanently stuck: because the Stop hook's condition was never met, it keeps forcing retries, and every retry re-sends the same corrupted history and gets the same 400.

Environment / reproduction context

  • Session transcript: ~/.claude/projects/-Users-adriano/a281a361-4c51-49b2-9481-183d5a805422.jsonl
  • A /goal (or equivalent) command had set a session-scoped Stop hook earlier in the conversation with a long-running task condition ("migrate services off Sentry onto Better Stack").
  • The assistant invoked the advisor tool. Claude Code logs this as two separate JSONL entries sharing one message.id (msg_011CdPqDXUzrzGqTsFMkULz3):
  • one entry with content: [{"type": "server_tool_use", ...}]
  • a later entry with content: [{"type": "advisor_tool_result", "tool_use_id": "srvtoolu_01UEHztcoxYqRE8UYWUqbSjL", ...}]
  • Between those two entries, the transcript contains:
  • an attachment entry, attachment.type == "goal_status", met: false
  • a synthetic user entry: "A session-scoped Stop hook is now active with condition: ... Briefly acknowledge the goal, then immediately start (or continue) working toward it ..."
  • Both injected entries carry parentUuid chains that place them inside the assistant's still-open message (parent = the server_tool_use entry's uuid; the resumed advisor_tool_result entry's parent = the injected user entry's uuid).

Observed failure

Once the session's context is rebuilt from this transcript and sent to the API, every request fails with:

API Error: 400 messages.5.content.0: unexpected `tool_use_id` found in `advisor_tool_result` blocks: srvtoolu_01UEHztcoxYqRE8UYWUqbSjL. Each `advisor_tool_result` block must have a corresponding `server_tool_use` block before it.

Because the /goal Stop hook's condition was not yet met, it kept blocking Stop and forcing retries. The transcript shows this exact 400 recurring roughly a dozen times between 2026-07-26T11:29Z and 2026-07-26T16:01Z — the session was stuck in a retry loop for hours with no way to make progress, since every retry replays the same corrupted history.

Root cause (assessed from the transcript)

The Stop-hook evaluation that produces the goal_status attachment + reminder treats the pause between a server-side tool's tool_use and tool_result (which arrive as two separate streamed/logged events) as a legitimate point to inject a new turn. For ordinary client-executed tools this boundary never occurs mid-message, so the hook injection is safe there. For server-side tools where Claude Code itself splits one API message into two JSONL log entries around an async gap, injecting a turn at that gap breaks the invariant the Anthropic API enforces: a tool_result-shaped content block must be contiguous with its tool_use block within the same message, not separated by an intervening user turn.

Suggested fix

Gate Stop-hook message injection (and any other mid-turn injection mechanism) so it cannot fire while an assistant message has an unresolved server-side tool call (a server_tool_use/similar block with no matching result yet appended to that same message.id). Defer the injection until the message is fully closed out (all content blocks for that message.id have arrived), then insert the hook's turn after it, not inside it.

Workaround applied

Manually edited the affected session's transcript JSONL: removed the two injected entries (the goal_status attachment and the synthetic reminder user entry) and relinked the advisor_tool_result entry's parentUuid directly to the server_tool_use entry's uuid, restoring a contiguous message. This is a one-off manual repair, not a general fix — the underlying hook-injection race is still present in 2.1.212 and will reproduce for anyone using a /goal-style Stop hook alongside a server-side tool call.

View original on GitHub ↗