[BUG] Successful auto-retry after api_error orphans the assistant turn: stop_hook_summary + next user message get parented to the api_error node instead of the recovered reply

Open 💬 2 comments Opened Jun 29, 2026 by zzkevin2019

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet. The closest issues (#22526, #61188 = phantom parentUuids; #36583 = messageId/uuid collisions; #22566 = writes stop after cd; #46603/#60523/#49996 = post-compaction breakage; #21617 = generic "messages lost on resume" with no mechanism identified) all have a different trigger and mechanism from the one below.
  • [x] This is a single bug report.
  • [x] Reproduced on the latest version available to me (see Environment).

Summary

When a turn hits a transient api_error that the client successfully auto-retries, the recovered assistant reply is written to the JSONL but ends up on an orphaned sibling branch. The turn's continuation — the stop_hook_summary record and the next user message — is parented to the api_error node instead of to the recovered assistant message. As a result, any consumer that reconstructs the conversation by walking the parentUuid chain (resume, /remote-control handoff, transcript export, the Agent SDK) silently drops the assistant reply, even though it is intact on disk and was shown live in the desktop UI.

The defect, precisely

For a turn that errors once and recovers, the tree comes out like this (A = the attachment/user node the turn started from):

user  (U)
└─ attachment (A)
   ├─ assistant: thinking        parentUuid = A   ┐  recovered reply
   │  └─ assistant: text         parentUuid = thinking ┘  (BRANCH R)
   └─ system: api_error (401)    parentUuid = A   ┐  error branch
      └─ system: stop_hook_summary  parentUuid = api_error   │ (BRANCH E)
         └─ user: <next message>    parentUuid = stop_hook_summary
            └─ … rest of session …                            ┘

api_error and the assistant turn are siblings under A. The end-of-turn stop_hook_summary is attached to the api_error node rather than to the just-produced assistant: text node, and the next user message descends from the hook. So the active leaf's ancestry runs … → next user → stop_hook_summary → api_error → A, bypassing Branch R entirely. Branch R (the actual answer) becomes an unreachable dead-end.

Chronology confirms the reply is real and post-dates the error (one real occurrence, timestamps in UTC):

| Event | parentUuid → | timestamp |
|---|---|---|
| user message | (prior) | 16:54:55.260 |
| api_error (status 401, retryAttempt 1) | attachment A | 16:54:55.556 |
| assistant: thinking (successful retry) | attachment A | 16:56:11.503 |
| assistant: text "…the answer…" | thinking | 16:56:42.501 |
| stop_hook_summary | api_error | 16:56:42.603 |
| next user message | stop_hook_summary | 17:15:21.761 |

The retry succeeded ~107 s after the 401, produced thinking + text, and then the stop hook fired — but the hook (and everything after) chained onto the transient error node instead of the reply that had just been generated 0.1 s earlier.

Why the error itself is benign / unavoidable (so please don't focus on the 401)

The api_error is a routine short-lived auth-token (JWT) expiry that the client refreshes-and-retries — status: 401, authentication_error, retryAttempt: 1, recovered in ~0.5 s. It is independently visible all over the desktop app's own logs, e.g. the remote-control bridge:

[remote-control] bridge_state: reconnecting — JWT expired — refreshing
[remote-control] bridge_state: failed — Transport closed: auth token expired (code 401)

(22 such auth events in ~9 days in one user's logs.) It does not correlate with idle time, app restart, app update, machine reboot, or remote-control usage — it is just normal token churn. The 401 is expected; the bug is purely the parent-linking after recovery. Any transient retried error (not just 401) that produces this sibling-fork shape would trigger it.

Impact

  • On claude --resume, /remote-control handoff to another device, transcript export, and Agent SDK history reconstruction, the recovered assistant reply is silently missing — while it still renders in the desktop app (which replays the raw log), so the loss is invisible until you resume or hand off.
  • Any decision or content that lived only in an orphaned reply is dropped from the model's context on resume, with no warning.
  • In one long-running session this occurred 5 times, each with the identical structure — so it is deterministic given a retried error mid-turn, not a rare race.

Minimal synthetic reproduction

Conversation content blanked to [redacted]; only the structural fields matter. The orphan is reproducible purely from this shape:

{"type":"user","uuid":"U","parentUuid":"ROOT","message":{"role":"user","content":"[redacted]"},"timestamp":"2026-01-01T00:00:00.000Z"}
{"type":"attachment","uuid":"A","parentUuid":"U","timestamp":"2026-01-01T00:00:00.100Z"}
{"type":"system","subtype":"api_error","uuid":"E","parentUuid":"A","level":"error","error":{"status":401,"error":{"type":"authentication_error"}},"retryAttempt":1,"maxRetries":10,"timestamp":"2026-01-01T00:00:00.400Z"}
{"type":"assistant","uuid":"T1","parentUuid":"A","message":{"role":"assistant","content":[{"type":"thinking","thinking":"[redacted]"}]},"timestamp":"2026-01-01T00:01:30.000Z"}
{"type":"assistant","uuid":"T2","parentUuid":"T1","message":{"role":"assistant","content":[{"type":"text","text":"[redacted]"}]},"timestamp":"2026-01-01T00:01:30.500Z"}
{"type":"system","subtype":"stop_hook_summary","uuid":"H","parentUuid":"E","timestamp":"2026-01-01T00:01:30.600Z"}
{"type":"user","uuid":"U2","parentUuid":"H","message":{"role":"user","content":"[redacted]"},"timestamp":"2026-01-01T00:05:00.000Z"}

Walk parentUuid back from the last record (U2): U2 → H → E → A → U. T1/T2 (the reply) are never reached. Expected: the chain should be U2 → H → T2 → T1 → A → U, i.e. the end-of-turn hook and the next user message should descend from the recovered assistant message, not from the transient api_error sibling.

Expected behavior

When a turn recovers after a retried api_error, the successful assistant message should become the parent of the end-of-turn stop_hook_summary and of the subsequent user message, so the recovered reply stays on the active parentUuid chain. The api_error record should remain a non-spine annotation (or be re-parented under the assistant turn), not the node the conversation continues from.

Environment

  • Entry point: Claude Desktop (Electron app 1.14271.0), claude-code version field 2.1.187 in the JSONL; reproduced identically across 2.1.149, 2.1.181, 2.1.187.
  • Platform: macOS (Darwin 25.x), Apple Silicon.
  • Model: Opus 4.8.
  • Discovered while building a parentUuid-walking transcript exporter; cross-confirmed because the same replies are missing on a /remote-control handoff to an iPhone but present in the desktop app.

View original on GitHub ↗

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