Running a slash command (/effort) while `advisor` is in flight injects local_command records mid-message and permanently 400s the session
Summary
Typing an ordinary slash command while a server-side tool call (advisor) is still in flight causes Claude Code to append the command's system / local_command records inside the still-open assistant message, between the server_tool_use block and its advisor_tool_result. The advisor_tool_result's parentUuid is then chained to the command's stdout record instead of to the server_tool_use. Every subsequent request replays the corrupted history and fails with a non-retryable 400. The session is permanently dead.
This is the same mechanism as #81397, but the injector is a plain slash command typed by the user, not a Stop hook. That makes the blast radius much wider: /effort, and presumably any local_command, is enough. No hooks, no compaction, no /goal needed.
Claude Code version: 2.1.226 (macOS 15.5 / darwin 25.5.0)
Error
API Error: 400 messages.83.content.0: unexpected `tool_use_id` found in `advisor_tool_result` blocks: srvtoolu_01MF7bundmTNou6yC7iNqAs6. Each `advisor_tool_result` block must have a corresponding `server_tool_use` block before it.
Transcript evidence
Session f11035d0-7407-4b01-8b8a-b9aaf785457d. The session made 63 advisor calls; 62 are well-formed (advisor_tool_result.parentUuid == server_tool_use.uuid). The 63rd — the last thing that happened before the session died — is broken:
| line | type | content | uuid | parentUuid | message.id | timestamp |
|---|---|---|---|---|---|---|
| 79113 | assistant | server_tool_use srvtoolu_01MF7bundmTNou6yC7iNqAs6 | 8b3c6c8a | ee8c0e34 | msg_011CdyMt | — |
| 79114 | system | local_command: <command-name>/effort</command-name> | 8a828144 | 8b3c6c8a | — | 2026-08-12T18:53:58.909Z |
| 79115 | system | local_command: <local-command-stdout>Set effort level to medium…</local-command-stdout> | 4867dfdb | 8a828144 | — | 2026-08-12T18:53:58.909Z |
| 79116 | assistant | advisor_tool_result srvtoolu_01MF7bundmTNou6yC7iNqAs6 | bd6a377d | 4867dfdb | msg_011CdyMt | — |
Lines 79113 and 79116 share one message.id (msg_011CdyMt) — they are two JSONL entries of a single logical assistant message. The two /effort records were spliced into the middle of it, so the reconstructed history places an intervening turn between the tool_use and its tool_result, violating the contiguity the API enforces.
Note this is not the compaction variant (#81233, #60523): every other pair in the same 79k-line transcript is intact, and the corruption sits at the last exchange, at the exact second the slash command was run.
Steps to reproduce
- Start a session where the
advisortool is available. - Trigger an
advisorcall. - While it is still thinking (the gap between the streamed
server_tool_useandadvisor_tool_result), type/effortand pick a level. - Next prompt → 400. Every prompt after → the same 400. Model fallback does not help; the history itself is invalid.
Expected behavior
Slash-command / local_command records must not be chained into an assistant message that has an unresolved server_tool_use. Defer the injection until all content blocks for that message.id have arrived, then append the records after the closed message. Belt-and-braces: the client should detect an orphaned advisor_tool_result at request-assembly time and drop the pair rather than sending a history it knows the API will reject.
Workaround
Manual transcript repair — drop the two injected system records and relink the advisor_tool_result's parentUuid back to the server_tool_use's uuid:
import json, shutil
from pathlib import Path
P = Path('~/.claude/projects/<PROJECT>/<SESSION>.jsonl').expanduser()
STU_UUID = '<uuid of the server_tool_use record>'
RES_PREFIX = '<uuid prefix of the advisor_tool_result record>'
DROP = {'<uuid of the injected command record>', '<uuid of the injected stdout record>'}
lines = P.read_text().splitlines(keepends=True)
out, dropped, relinked = [], 0, 0
for line in lines:
if not any(u[:8] in line for u in DROP | {RES_PREFIX}):
out.append(line); continue
obj = json.loads(line)
if obj.get('uuid') in DROP:
dropped += 1; continue
if str(obj.get('uuid', '')).startswith(RES_PREFIX) and obj.get('parentUuid') != STU_UUID:
obj['parentUuid'] = STU_UUID; relinked += 1
out.append(json.dumps(obj, ensure_ascii=False) + '\n'); continue
out.append(line)
assert (dropped, relinked) == (2, 1), (dropped, relinked)
shutil.copy2(P, P.with_suffix('.jsonl.bak'))
P.write_text(''.join(out))
claude --resume <session> works again afterwards.
Related
- #81397 — identical mechanism, injector is a session-scoped Stop hook instead of a slash command. Open, no response.
- #81233, #60523 — the compaction variant of the same invariant break.
- #73638, #76879, #62885, #84305, #84783 — same 400, other injection paths.
53 issues match advisor_tool_result in this repo. The individual injection paths keep being reported and closed as duplicates; the durable fix is the invariant, not the path: nothing may be chained into an assistant message with an unresolved server-side tool call, and the client should never send a history containing an orphaned advisor_tool_result.
3 Comments
Confirming this on 2.1.228 (darwin 25.6.0) with a different trigger:
/usagetyped while anadvisorcall was in flight — which supports the "presumably any local_command" hypothesis.Same signature: two
system/local_commandrecords (command echo +<local-command-stdout>) spliced into the parentUuid chain betweenserver_tool_useand itsadvisor_tool_result, both sharing onemessage.id. Every subsequent request then fails with the non-retryable 400.Deterministic recovery that worked here: delete the two interposed
local_commandrecords and re-point theadvisor_tool_resultentry'sparentUuidback to theserver_tool_useentry's uuid. The transcript reassembles exactly as originally streamed; the session resumed cleanly and has kept working since. Happy to share the repair script if useful.Thanks for the detailed writeup and the transcript evidence — confirmed.
Tested on 2.1.233 (Linux). I couldn't win the live timing race by hand, but the corruption it leaves behind is deterministic and reproduces on 2.1.233: with a session transcript where slash-command records sit between the two halves of a single assistant message that carries a server-side tool call, every request from then on fails with
API Error: 400 messages.N.content.0: unexpected tool_use_id found in ..._tool_result blocks: ... Each ..._tool_result block must have a corresponding server_tool_use block before it.The identical transcript with just those two records removed gets past that validation, so the injected records are the cause — and the session stays wedged on every subsequent prompt, exactly as you describe.
Your framing is right: the durable fix is the invariant, not the individual injection path. We're tracking both halves — not chaining injected records into an assistant message with an unresolved server-side tool call, and dropping an orphaned server-side tool result at request-assembly time so an already-corrupted session recovers on its own instead of needing your repair script.
🤖 Generated with Claude Code
Also reproducing on 2.1.241 (Linux, WSL2) — newer than the 2.1.233 in the confirmation above. Trigger here was
/model, a third distinctlocal_commandafter/effortand/usage.Identical signature:
| line | type | content | uuid | parentUuid | message.id |
|---|---|---|---|---|---|
| 2661 |
assistant|server_tool_usesrvtoolu_01VwaPKF…|2a9662fb|1ca1a8ad|msg_011CeMmj|| 2662 |
system|local_command:<command-name>/model</command-name>|af1d7fff|2a9662fb| — || 2663 |
system|local_command:<local-command-stdout>Set model to Opus 5 (1M context)…</local-command-stdout>|b1feb6a9|af1d7fff| — || 2664 |
assistant|advisor_tool_resultsrvtoolu_01VwaPKF…|b2076f46|b1feb6a9|msg_011CeMmj|The session made 7
advisorcalls; the other 6 are adjacent line pairs withadvisor_tool_result.parentUuid == server_tool_use.uuid.It fails on the next prompt, not the next request
This distinction matters for anyone trying to reproduce it. The corrupted turn didn't fail — it ran to completion, for another 13 minutes and ~45 requests, including a second, perfectly well-formed
advisorcall. Only the next user prompt died.Timeline, same
sessionIdthroughout, nocompact_boundaryanywhere after the corruption:14:59:29Z—server_tool_usestreamed15:01:25Z—/modeltyped mid-flight;advisor_tool_resultlands with the corrupted parent15:01:43Z → 15:14:47Z— ~45 successful requests across ~290 transcript lines. Everyuserrecord in this range is atool_resultcontinuation; there is exactly oneturn_durationrecord in the whole stretch, at the end of it. One continuous assistant turn.15:16:53Z— first new user prompt since the corruption15:16:54Z— 400. Every prompt after failed the same way, and--resumefailed identically.The natural reading: within a turn the assistant message is still held assembled in memory, so the injected records are invisible to request assembly. At the turn boundary the history is rebuilt from the transcript, and that rebuild is where the message splits and the
advisor_tool_resultbecomescontent[0]of a fresh message.If that's right, it bears on which half of the fix does the rescuing. The request-assembly-time drop of an orphaned server-side tool result is what recovers a session that has already been written to disk — including the live one the user is sitting in, which will look perfectly healthy until they hit Enter on their next instruction.
It also makes user-reported triggers unreliable. Here the corrupting slash command was 13 minutes and dozens of tool calls before the visible failure; the obvious thing to blame is the prompt that happened to be typed next.
Repair
Same as the issue body: drop the two
local_commandrecords, re-point theadvisor_tool_result'sparentUuidat theserver_tool_useuuid. All 7 pairs adjacent afterwards,claude --resumeclean, session has kept working since.One caveat for anyone adapting the script: if you write via a temp file plus
os.replacerather thanwrite_textin place, the replacement inherits your umask and the transcript silently goes600 → 644— a file full of conversation content becoming world-readable.shutil.copystatafterwards, or chmod back.