Running a slash command (/effort) while `advisor` is in flight injects local_command records mid-message and permanently 400s the session

Status Open
Reported on v2.1.226
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Aug 12, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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

  1. Start a session where the advisor tool is available.
  2. Trigger an advisor call.
  3. While it is still thinking (the gap between the streamed server_tool_use and advisor_tool_result), type /effort and pick a level.
  4. 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.

View original on GitHub ↗

3 Comments

gkgg · 18 days ago

Confirming this on 2.1.228 (darwin 25.6.0) with a different trigger: /usage typed while an advisor call was in flight — which supports the "presumably any local_command" hypothesis.

Same signature: two system/local_command records (command echo + <local-command-stdout>) spliced into the parentUuid chain between server_tool_use and its advisor_tool_result, both sharing one message.id. Every subsequent request then fails with the non-retryable 400.

Deterministic recovery that worked here: delete the two interposed local_command records and re-point the advisor_tool_result entry's parentUuid back to the server_tool_use entry'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.

bcherny collaborator · 14 days ago

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

sergeykad · 6 days ago

Also reproducing on 2.1.241 (Linux, WSL2) — newer than the 2.1.233 in the confirmation above. Trigger here was /model, a third distinct local_command after /effort and /usage.

Identical signature:

| line | type | content | uuid | parentUuid | message.id |
|---|---|---|---|---|---|
| 2661 | assistant | server_tool_use srvtoolu_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_result srvtoolu_01VwaPKF… | b2076f46 | b1feb6a9 | msg_011CeMmj |

The session made 7 advisor calls; the other 6 are adjacent line pairs with advisor_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 advisor call. Only the next user prompt died.

Timeline, same sessionId throughout, no compact_boundary anywhere after the corruption:

  • 14:59:29Zserver_tool_use streamed
  • 15:01:25Z/model typed mid-flight; advisor_tool_result lands with the corrupted parent
  • 15:01:43Z → 15:14:47Z — ~45 successful requests across ~290 transcript lines. Every user record in this range is a tool_result continuation; there is exactly one turn_duration record in the whole stretch, at the end of it. One continuous assistant turn.
  • 15:16:53Z — first new user prompt since the corruption
  • 15:16:54Z — 400. Every prompt after failed the same way, and --resume failed 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_result becomes content[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_command records, re-point the advisor_tool_result's parentUuid at the server_tool_use uuid. All 7 pairs adjacent afterwards, claude --resume clean, session has kept working since.

One caveat for anyone adapting the script: if you write via a temp file plus os.replace rather than write_text in place, the replacement inherits your umask and the transcript silently goes 600 → 644 — a file full of conversation content becoming world-readable. shutil.copystat afterwards, or chmod back.