[Bug] advisor_tool_result orphaned when /goal (Stop-hook) notice is injected mid-advisor-roundtrip → permanent API 400

Resolved 💬 2 comments Opened Jun 6, 2026 by vochicong Closed Jun 6, 2026

Summary

A session becomes permanently unrecoverable with API Error: 400 ... unexpected tool_use_id found in advisor_tool_result blocks ... Each advisor_tool_result block must have a corresponding server_tool_use block before it when a **/goal Stop-hook activation notice (or any queued user prompt) is written into the transcript between an in-flight advisor call's server_tool_use block and its advisor_tool_result block.**

This is a distinct trigger from the already-reported variants:

  • #56515 — orphaned by auto-compaction dropping the server_tool_use.
  • #62885 — orphan already in history; turning off the advisor doesn't help.
  • #49994 / #53594 / #50223 / #51128 — serialization / general orphan family.

In this variant both blocks are present and correctly paired by id — they are merely split into two API messages by an injected user/attachment row, so the advisor_tool_result becomes content[0] of a fresh assistant message with no server_tool_use before it.

Environment

  • Claude Code 2.1.167 (also seen on 2.1.x earlier in the same session)
  • WSL2 (Linux 6.6.x microsoft-standard-WSL2)
  • advisorModel: opus in settings; /goal in use (added in 2.1.139)

What happens (root cause)

advisor() is a server-side tool with a long wall-clock pause (it forwards full history to the advisor model). The transcript records the server_tool_use block first, then waits for the result. If, during that wait, the user submits/queues input — in particular /goal <condition>, which appends a meta notice "A session-scoped Stop hook is now active with condition: …" — Claude Code writes that queued prompt between the server_tool_use and the later advisor_tool_result.

On reassembly, the injected user row splits the assistant turn into two API messages:

assistant: [ ... , server_tool_use(advisor, id=srvtoolu_XXX) ]   ← message N
user:      [ "A session-scoped Stop hook is now active ..." ]    ← injected (attachment + isMeta user)
assistant: [ advisor_tool_result(tool_use_id=srvtoolu_XXX), ... ] ← message N+1, content[0] is now orphaned

→ every subsequent request 400s; /compact also fails. The session is dead until the transcript is hand-repaired.

Transcript evidence (one real session)

8 advisor pairs in the session; 7 are clean (the two blocks are adjacent assistant rows). The 1 that 400s has exactly two rows wedged between its server_tool_use and advisor_tool_result:

row K   assistant  server_tool_use   id=srvtoolu_XXX
row K+1 attachment  (empty content)
row K+2 user        isMeta=true       "A session-scoped Stop hook is now active with condition: <…>"
row K+3 assistant  advisor_tool_result tool_use_id=srvtoolu_XXX

The 3 /goal activations in the session each had a distinct condition (3 separate /goal runs); only the one whose notice happened to land during an in-flight advisor call caused the corruption. The other two landed between turns and were harmless. This strongly implicates the timing overlap of /goal (Stop-hook) injection with a server-tool round-trip, not the advisor or /goal alone.

Steps to reproduce

  1. Enable the advisor (advisorModel) so the executor calls advisor().
  2. While an advisor() call is in flight (the multi-second pause), submit /goal <condition> (or queue any prompt).
  3. After the turn, send any follow-up → API Error: 400 ... advisor_tool_result ... must have a corresponding server_tool_use block before it, repeating on every request including /compact.

(Race-dependent on landing input during the advisor pause; easier to hit in long, goal-driven autonomous runs that re-issue /goal.)

Impact

Permanent, unrecoverable 400 for the whole session. Official recovery (/rewind, /clear, new session) all discard context.

Proposed fix

Treat a server-tool round-trip as atomic during transcript assembly/persistence:

  • never interleave a queued user prompt / Stop-hook (/goal) meta notice between a server_tool_use and its matching *_tool_result; defer the injection until the in-flight server-tool round-trip completes, or
  • at assembly time, keep server_tool_use + its advisor_tool_result in the same API message regardless of intervening injected rows (i.e., don't let an injected user/attachment row split the pair).

This is the same atomicity principle requested in #56515 for the compaction path, applied to the queued-input / Stop-hook-injection path.

Workaround

Hand-repair the session JSONL: delete the injected empty-attachment + meta user rows that sit between the pair, and re-parent the advisor_tool_result row onto the server_tool_use row. This restores adjacency under file/parent/timestamp ordering and recovers the session with full context (unlike /rewind//clear).

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗