Session rename mid-server-tool-call injects a turn that permanently corrupts the transcript (400 on every future prompt)

Status Open
Maintainer reply None cached
Activity 12 comments · opened Jul 2, 2026

Summary

Renaming a session (custom title) while a server_tool_use call (e.g. the built-in advisor tool) is in flight injects a system-reminder as a synthetic user turn in the transcript, landing it between the server_tool_use block and its matching advisor_tool_result block. Because server-tool call + result must live in the same API message, this splits them across two messages on the next resume/request, and the Anthropic API rejects the entire history with a 400 on every subsequent turn — permanently breaking the session until the transcript file is hand-edited.

Reproduction

  1. Start a session and trigger a server-side tool call that returns asynchronously (in our case, the advisor tool).
  2. While the call is in flight, rename the session (set a custom title) — e.g. via whatever UI/flow assigns customTitle/agent-name.
  3. Let the tool call resolve and continue the conversation, then send further prompts.
  4. Every subsequent prompt now fails with:

``
API Error: 400 messages.N.content.0: unexpected
tool_use_id found in advisor_tool_result blocks: srvtoolu_XXXX. Each advisor_tool_result block must have a corresponding server_tool_use block before it.
``

Root cause (confirmed by inspecting the session .jsonl)

In the broken transcript, the three lines around the failure were:

{"type":"assistant", ..., "uuid":"4320b59f-...", "message":{"content":[{"type":"server_tool_use","id":"srvtoolu_01YZBP7b9c3YoD3NXiLgXsVb","name":"advisor","input":{}}]}}
{"type":"user","isMeta":true, "parentUuid":"4320b59f-...", "message":{"role":"user","content":"<system-reminder>\nThe user named this session \"...\". This may indicate the session's focus or intent.\n</system-reminder>"}}
{"type":"assistant", "parentUuid":"<the user line above>", "message":{"content":[{"type":"advisor_tool_result","tool_use_id":"srvtoolu_01YZBP7b9c3YoD3NXiLgXsVb", ...}]}}

When the harness replays/merges the transcript into the API messages array, consecutive same-role entries are folded into one logical message (confirmed by other server_tool_use/advisor_tool_result pairs in the same file that have no interruption and work fine). The injected isMeta user turn breaks that fold here, producing:

  • message A (assistant): [..., server_tool_use]
  • message B (user): [system-reminder text]
  • message C (assistant): [advisor_tool_result, ...]

advisor_tool_result in message C no longer has its server_tool_use in the same message → 400 from the API → every future turn in the session fails identically, since the full (corrupted) history is resent each time.

Impact

  • Session becomes permanently unusable for every future prompt, not just the one where the race happened.
  • No user-facing recovery path — we had to manually locate the offending lines in ~/.claude/projects/<project>/<session>.jsonl, delete the injected isMeta system-reminder line, and repoint the following message's parentUuid to restore a valid chain, then re-verify the merged message structure by hand.

Suggested fix

  • Don't inject system-reminder (or any synthetic) user turns while a server-side tool call is awaiting its result — queue the reminder and deliver it only once the current turn (tool_use + its result) is fully closed.
  • Defensively, on session load/resume, detect and either coalesce or drop any zero-content-value synthetic turn that would split a server_tool_use/matching result pair, rather than sending it to the API and hard-failing.

Happy to share the (redacted) transcript snippet if useful.

View original on GitHub ↗

8 Comments

junaidtitan · 1 month ago

Full disclosure: I maintain cozempic.

The forensic detail here is unusually clear — you've traced the exact injected line (isMeta system-reminder between server_tool_use and advisor_tool_result), confirmed the failure mechanism, and already done the repair manually. Worth knowing that cozempic doctor has an orphaned-tool-results check targeting exactly this structural break: a tool_result block (or advisor_tool_result) that no longer has a corresponding tool_use in the same API message context.

cozempic doctor

Running it on the broken session file should detect the orphaned result automatically and walk through the repair rather than requiring manual JSONL surgery. There's also a doctor --fix unresumable-session path that targets sessions the API rejects on every turn — which is exactly the state your session ended up in.

It won't prevent the race condition (that's CC's fix to ship), but it covers the recovery step you had to do by hand. Worth trying before editing session files manually in future occurrences.

yurukusa · 1 month ago

Ran into the same failure mode and wanted to leave a recovery recipe for anyone whose session is already 400-bricked, since there's no in-product path yet.
Confirming your root cause on my side. I reconstructed your three-line pattern (the server_tool_use, the injected isMeta system-reminder user turn, and the advisor_tool_result) alongside an uninterrupted healthy pair in the same file, and the failure is exactly the fold break you describe: the isMeta turn between the server_tool_use and its result forces the result into a separate messages entry, so on replay the advisor_tool_result has no server_tool_use in the same message → 400 unexpected tool_use_id. The healthy pair with no interruption folds fine, which is the tell.
Automated recovery. This does what you did by hand — finds an isMeta user turn that sits between a server_tool_use and its matching result, drops it, and repoints the result's parentUuid back to the server_tool_use line. It only touches a turn that actually splits a matching pair, so uninterrupted pairs and ordinary isMeta reminders are left alone.

import json, sys
def load(p): return [json.loads(l) for l in open(p, encoding="utf-8") if l.strip()]
def ctypes(e):
    c = e.get("message", {}).get("content", [])
    return [b.get("type") for b in c if isinstance(b, dict)] if isinstance(c, list) else []
def ids(e, kind):
    c = e.get("message", {}).get("content", [])
    return [b.get("id") or b.get("tool_use_id") for b in c
            if isinstance(b, dict) and b.get("type") == kind] if isinstance(c, list) else []
entries = load(sys.argv[1])
by = {e.get("uuid"): e for e in entries}
drop, fixes = set(), []
RESULTS = ("advisor_tool_result", "web_search_tool_result", "code_execution_tool_result", "tool_result")
for i, e in enumerate(entries):
    if e.get("type") == "user" and e.get("isMeta") is True:
        parent = by.get(e.get("parentUuid"))
        if not (parent and "server_tool_use" in ctypes(parent)):
            continue
        su = set(ids(parent, "server_tool_use"))
        for j in range(i + 1, len(entries)):
            nxt = entries[j]
            if nxt.get("parentUuid") == e.get("uuid"):
                res = set(rid for k in RESULTS for rid in ids(nxt, k))
                if su & res:
                    drop.add(e.get("uuid"))
                    nxt["parentUuid"] = parent.get("uuid")
                    fixes.append((e.get("uuid"), nxt.get("uuid"), parent.get("uuid")))
                break
if not fixes:
    print("No contamination found; file left untouched.")
else:
    for d, r, p in fixes:
        print(f"Removed injected isMeta {d}; repointed result {r} -> parent {p}")
    with open(sys.argv[1] + ".fixed", "w", encoding="utf-8") as f:
        for e in entries:
            if e.get("uuid") not in drop:
                f.write(json.dumps(e, ensure_ascii=False) + "\n")
    print("Wrote", sys.argv[1] + ".fixed")

Usage (back up first): cp session.jsonl session.jsonl.bak && python3 repair.py ~/.claude/projects/<project>/<session>.jsonl then swap in the .fixed file. On my reconstruction it removed only the contaminated turn, restored the chain so every *_tool_result again shares a message with its server_tool_use, and left the healthy pair byte-for-byte. Caveat: I validated against the reconstructed pattern from your report, not your actual file — verify the .fixed output before replacing.
Detection before it bricks the session. The same match condition works as a SessionStart check that scans ~/.claude/projects/**/<session>.jsonl for an isMeta turn splitting a server_tool_use/result pair and warns with the offending line, so you can repair before the next prompt 400s instead of discovering it mid-work.
Agree the real fix is on the harness side — either queue the system-reminder until the in-flight tool turn closes (your first suggestion), or coalesce/drop any zero-value synthetic turn that would split a server_tool_use/result pair on load. Detection + repair only limits the blast radius after the fact.

junaidtitan · 1 month ago

@yurukusa that recovery script is excellent — you arrived at the exact same structural diagnosis independently, which validates the root cause. The three-line pattern you reconstructed (server_tool_use → injected isMeta → advisor_tool_result separated across message boundaries) is precisely what the repair needs to target.

For what it's worth, cozempic ships an auto-repair for this same break: cozempic doctor --fix unresumable-session detects an advisor_tool_result or server_tool_result whose matching server_tool_use isn't in the same message context, and repairs the parentUuid chain — which is structurally the same thing your script does. It idle-gates on mtime (won't race a live write), and the guard daemon runs it automatically on session start before the first prompt can 400.

Your SessionStart detection hook idea is also the same approach our guard uses — scan the JSONL at session start, warn before the bad turn triggers, repair if it can. Nice to see two independent implementations converge on the same design.

The repair script you posted is a solid standalone option if someone doesn't want to install anything — good to have it documented in this thread for people who find it later.

jcfernandez-890825 · 1 month ago

Confirming the same failure independently, third report converging on the identical root cause.

Hit this today: advisor() in flight, session got auto-named ("The user named this session ...") mid-call, and every subsequent turn 400'd with the exact error. Inspecting the transcript found the identical three-line pattern already documented here:

line N   assistant  server_tool_use (advisor)          uuid=A
line N+1 user        <system-reminder> session named…  parentUuid=A
line N+2 assistant  advisor_tool_result                 parentUuid=(line N+1's uuid)

One data point worth adding: the repair did not require dropping the injected reminder line or rewriting the file into a .fixed copy — re-pointing only the advisor_tool_result record's parentUuid directly to the server_tool_use record's own uuid (leaving the injected reminder line in place, just no longer on the path between the pair) was sufficient. Verified before/after with a script that walks every server_tool_use/advisor_tool_result pair in the file and asserts result.parentUuid == server_tool_use.uuid: 1 broken pair found, 0 after the one-field edit, all 8 other pairs in the same session untouched.

Adds confidence to the suggested fix direction: the harness only needs to avoid letting a zero-content synthetic turn sit on the path between a server_tool_use and its result — it doesn't need to be excluded from the transcript entirely, just not injected as an intervening parent while that pair is open.

alies-dev · 1 month ago

Hit this again today, independent confirmation of the root cause and fix already documented here.

advisor() call in flight, session got auto-titled mid-call, every prompt after that 400'd with the exact unexpected tool_use_id found in advisor_tool_result blocks error.

Transcript matched the documented pattern, but with one extra wrinkle: the injected content wasn't a single isMeta user line, it was three lines — two "type":"system" framing entries plus the user system-reminder itself, all sitting between the pair:

assistant  server_tool_use (advisor)          uuid=A
system     (framing)                          parentUuid=A
system     (framing)                          parentUuid=<framing1>
user       <system-reminder> session named…   parentUuid=<framing2>
assistant  advisor_tool_result                parentUuid=<reminder>

A single-field patch (repoint advisor_tool_result.parentUuid straight to server_tool_use.uuid, per @jcfernandez-890825) isn't quite enough here on its own: the line that originally followed the result (the next assistant turn) still points its parentUuid at the result. If you only fix the one field, that next turn is left pointing at a parentUuid that's still mid-chain instead of at the new tail, forking the tree into two children at that node instead of a linear chain.

Full repair that worked: move the result so it's an immediate child of server_tool_use, then chain the framing/reminder lines after it in their original relative order, then repoint the turn that used to follow the result so it now follows the last framing/reminder line instead. Net effect: same three-field reparenting, just generalized to N intervening lines instead of assuming exactly one.

+1 for the harness-side fix — queue/defer the system-reminder (and any framing it carries) until the in-flight server-tool turn is fully closed. This is at least 4 independent reports of the same race now, and the "exactly one isMeta line" assumption in the manual/scripted repairs floating in this thread won't hold for every occurrence.

ungeism · 1 month ago

Still reproducing on 2.1.220 (npm latest as of 2026-07-30), and the trigger does not require a user-initiated rename.

Two independent sessions on the same machine bricked within 24h. In both, the injected records were isMeta user records carrying the "The user named this session ..." system-reminder — but in one of them the name was never changed by the user: it was set once at session start, and the harness re-asserted the identical value repeatedly (52 custom-title / 61 agent-name records over the session). The reminder pair still landed mid-flight.

  • Case A: server_tool_use at T+0 → two identical reminders at T+95s (1 ms apart) → advisor_tool_result at T+106s
  • Case B: server_tool_use at T+0 → two identical reminders at T+73s (1 ms apart) → advisor_tool_result at T+112s

In both cases the call and the result share the same message.id, so the pair ends up split across two persisted assistant records with the reminder records in between, and every later request fails identically:

API Error: 400 messages.N.content.0: unexpected tool_use_id found in advisor_tool_result blocks: srvtoolu_...

Deterministic: retry, a different prompt and /compact all fail the same way, since the same history is resent every turn.

Notes that may help others hitting this:

  • Offline repair works: delete the intervening records and re-point the result record's parentUuid at the call record's uuid. Anchor on uuid, not line numbers — the file keeps being appended to while you work.
  • The running process holds the corrupted history in memory, so the session must be stopped before repairing — and stopped again if it auto-respawned between the stop and the repair.
  • claude --resume <id> --fork-session -p ... verifies the repaired history without touching the real session.
  • Scanning 107 transcripts on this machine: 117 advisor call/result pairs, 3 bricked sessions in 24h.

Is buffering injected reminders until the server-tool turn completes (the way pause_turn resumption already requires the assistant message to be replayed unchanged) viable? The call/result pair looks like it has to be persisted atomically.

jameschien87 · 1 month ago

Same race, different server tool: tool_search_tool_regextool_search_tool_result. Every report in this thread so far is advisor(); this machine has never called advisor — 183/183 server_tool_use records across 223 transcripts are tool_search_tool_regex — so the defect is in the shared injection/fold path rather than anything advisor-specific, and it reaches the tool-loading calls the harness issues on its own initiative.

Occurrence (2.1.220, macOS 25.4.0, CLI, user-initiated rename):

line 22  assistant  msg_011CdZYDxp…  server_tool_use  srvtoolu_016Xj1WA…      04:55:17.839  uuid=29af3dfb
line 23  user       isMeta:true      <system-reminder> The user named this…  04:55:18.631  parentUuid=29af3dfb
line 24  assistant  msg_011CdZYDxp…  tool_search_tool_result srvtoolu_016X…  04:55:19.709  parentUuid=<line 23>

Lines 22 and 24 carry the same message.id — the fold the API requires is exactly the one the reminder breaks. Every subsequent prompt then fails with:

API Error: 400 messages.3.content.0: unexpected `tool_use_id` found in `tool_search_tool_result`
blocks: srvtoolu_016Xj1WA…. Each `tool_search_tool_result` block must have a corresponding
`server_tool_use` block before it.

Exposure window was ~1.9s (call → result); the reminder landed 0.8s in.

The repair scripts posted in this thread miss this variant. @yurukusa's matcher keys on

RESULTS = ("advisor_tool_result", "web_search_tool_result", "code_execution_tool_result", "tool_result")

tool_search_tool_result is not in that tuple, so it prints "No contamination found; file left untouched." on a file that is bricked. Suggest matching any *_tool_result block whose tool_use_id starts with srvtoolu_ instead of an allowlist of names — server tools will keep being added, and the allowlist silently fails closed-eyed on each new one.

Otherwise the shape matches the original report exactly: a single isMeta: true user record, no type:"system" framing lines (so @alies-dev's three-line variant is not universal). Minimal repair sufficed — drop the injected record, repoint the result record's parentUuid to the server_tool_use record's uuid — verified by asserting that no non-assistant record sits between any server_tool_use and its matching result, swept across all 223 transcripts on the machine: 1 broken pair before, 0 after, every other pair untouched.

Base rate here: 223 transcripts, 183 server-tool call/result pairs, 1 split by an injected reminder → 1 permanently bricked session.

yurukusa · 29 days ago

Correction: the repair script I posted above does nothing on the exact case @ungeism reported.

Both of those cases have two identical reminders (1 ms apart) between the server_tool_use and the advisor_tool_result. My script walks forward from an isMeta turn but inspects only the first record whose parentUuid matches, then breaks. With two chained reminders that first record is the second reminder, not the result — no id match, loop exits, nothing repaired. It only ever worked for a single injected turn, which is the shape I reconstructed from the original report. So anyone who ran it on a session bricked this way got "No contamination found" on a genuinely contaminated file.

Fixed by anchoring on the pair instead of on the reminder: walk server_tool_use ids, find the record carrying the matching result id, and drop what is between them only if every intervening record is an isMeta user turn. Any number of reminders is handled; anything else in between is left for a human instead of being silently deleted.

import json, sys

RESULTS = ("advisor_tool_result", "web_search_tool_result",
           "code_execution_tool_result", "tool_result")

def blocks(e):
    c = e.get("message", {}).get("content", [])
    return [b for b in c if isinstance(b, dict)] if isinstance(c, list) else []

def ids(e, kind):
    return [b.get("id") or b.get("tool_use_id") for b in blocks(e) if b.get("type") == kind]

entries = [json.loads(l) for l in open(sys.argv[1], encoding="utf-8") if l.strip()]

drop, fixes, pending = set(), [], {}
for i, e in enumerate(entries):
    for sid in ids(e, "server_tool_use"):
        pending[sid] = i
    for kind in RESULTS:
        for rid in ids(e, kind):
            if rid not in pending:
                continue
            j = pending.pop(rid)
            between = entries[j + 1:i]
            if not between:
                continue                                   # healthy pair, untouched
            if not all(b.get("type") == "user" and b.get("isMeta") is True for b in between):
                continue                                   # something else in between: leave it
            for b in between:
                drop.add(b.get("uuid"))
            e["parentUuid"] = entries[j].get("uuid")
            fixes.append((rid, len(between)))

if not fixes:
    print("No split pair found; file left untouched.")
else:
    for rid, n in fixes:
        print(f"Rejoined {rid}: dropped {n} injected isMeta turn(s)")
    with open(sys.argv[1] + ".fixed", "w", encoding="utf-8") as f:
        for e in entries:
            if e.get("uuid") not in drop:
                f.write(json.dumps(e, ensure_ascii=False) + "\n")
    print("Wrote", sys.argv[1] + ".fixed")

What I verified, and what I could not. I synthesised the Case A/B shape (call → N identical reminders → result) in a file that also contains an uninterrupted pair and an ordinary isMeta reminder splitting nothing, then ran both versions:

| injected reminders | old | new |
|---|---|---|
| 1 | repairs | repairs |
| 2 (Case A/B) | does nothing | repairs |
| 3 | does nothing | repairs |

The new one leaves the healthy pair's parentUuid unchanged, keeps the non-splitting isMeta, and drops exactly the injected turns.

I could not test against a real corrupted transcript. Scanning this machine found 819 session files, 240 server_tool_use/result pairs, and zero with anything between a call and its result — it has never fired here, and I don't rename sessions, which fits @ungeism's point that the reminder storm is what puts a pair at risk. So this is verified against the reported shape, not against a live brick: check the .fixed output before swapping it in.

Showing cached comments. Read the full discussion on GitHub ↗