anthropics / anthropics/claude-code
Session-scoped Stop hook injects mid-message, corrupting transcript when a server-side tool call is in flight
- Dominant language
- Python
- Stars
- 145k
- Forks
- 23.1k
- PR merge metrics
- PR metrics pending
Description
## 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.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with the affected session transcript at ~/.claude/projects/-Users-adriano/a281a361-4c51-49b2-9481-183d5a805422.jsonl and trace the Stop-hook injection around the advisor server_tool_use and advisor_tool_result entries sharing message.id msg_011CdPqDXUzrzGqTsFMkULz3. The fix is complete when hook entries are deferred until that message's server-side tool result arrives, preserving contiguous tool blocks and preventing the repeated 400 retry loop.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100