anthropics / anthropics/claude-code

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

Open
#81,397 0 comments 0 reactions 0 assignees View on GitHub
area:hooks
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.