[Bug] advisor_tool_result orphaned when /goal (Stop-hook) notice is injected mid-advisor-roundtrip → permanent API 400
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: opusin settings;/goalin 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
- Enable the advisor (
advisorModel) so the executor callsadvisor(). - While an
advisor()call is in flight (the multi-second pause), submit/goal <condition>(or queue any prompt). - 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 aserver_tool_useand its matching*_tool_result; defer the injection until the in-flight server-tool round-trip completes, or - at assembly time, keep
server_tool_use+ itsadvisor_tool_resultin the same API message regardless of intervening injected rows (i.e., don't let an injecteduser/attachmentrow 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).
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗