Claude history: dropSupersededPromptBranches deletes the live branch when injected user rows share a parentUuid (495 of 890 rows dropped, hasMore: false)
Summary
dropSupersededPromptBranches() (server/modules/providers/list/claude/claude-sessions.provider.ts:364, added in #1206) prunes the wrong branch when two injected user rows share a parentUuid. Because it deletes the earlier sibling and its entire subtree, a single false positive early in a transcript silently deletes the whole rest of the conversation from the history API.
On one real transcript this dropped 495 of 890 rows, so fetchHistory returned 27 messages instead of 215 and the newest message the UI could show was 4 days stale. It reports hasMore: false, so the client believes it has the complete transcript — there is no "load more" affordance and no error. The conversation just appears to have ended mid-thought. The native CLI (claude --resume <id>) renders the same file correctly, which is what makes it obvious the data is intact and the reader is at fault.
Root cause
The heuristic assumes that two user prompts sharing a parent means "the user edited and resent", and that the last one written wins:
// The transcript is append-only, so the last prompt written under a parent
// is the one that replaced the others.
for (const row of siblings.slice(0, -1)) {
if (typeof row.uuid === 'string') supersededRoots.add(row.uuid);
}That holds for edits. It does not hold for the two ways Claude Code injects synthetic type: "user" rows, both of which regularly produce sibling prompts under one parent:
- Background-task notifications. A
<task-notification>row for the same<task-id>is written twice under one parent.isUserPromptRow()(line 338) sees atype: "user"row with a non-empty stringcontent, so both count as prompts. - Inter-agent messages. A message delivered from another session (
Another Claude session sent a message: <teammate-message>…) lands as a sibling of the prompt the user actually typed, and is not taggedisMeta. Being later in the file, the injected note "wins" and the real prompt's entire subtree — the rest of the session — is declared abandoned.
Neither is an edit, and in case 2 the branch that gets deleted is the live one.
Measured on the affected transcript (904 rows, 6 detected "forks"):
| superseded row | subtree deleted | what it actually was |
|---|---|---|
| 77 | 495 rows | duplicate <task-notification>, same task id as row 124 |
| 89 | 482 rows | duplicate <task-notification>, same task id as row 111 |
| 151 | 440 rows | a real typed prompt, lost to an injected <teammate-message> at row 157 |
| 436 | 330 rows | duplicate <task-notification>, same task id as row 452 |
| 839, 866 | 2 rows each | genuinely duplicated prompts — correct behaviour |
Only the last two are the case the function was written for.
Reproduction
Any session that (a) uses background tasks, or (b) receives a message from another session, can hit this. Minimal fixture — three rows under one parent, where p1 is the live branch:
{"type":"assistant","uuid":"A","parentUuid":"root","sessionId":"S","timestamp":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"turn 1"}]}}
{"type":"user","uuid":"p1","parentUuid":"A","sessionId":"S","timestamp":"2026-01-01T00:01:00Z","message":{"role":"user","content":"the prompt the user typed"}}
{"type":"user","uuid":"p2","parentUuid":"A","sessionId":"S","timestamp":"2026-01-01T00:01:01Z","message":{"role":"user","content":"Another Claude session sent a message: <teammate-message>…</teammate-message>"}}
{"type":"assistant","uuid":"B","parentUuid":"p1","sessionId":"S","timestamp":"2026-01-01T00:02:00Z","message":{"role":"assistant","content":[{"type":"text","text":"the rest of the conversation"}]}}fetchHistory returns only the rows up to A: p1 is pruned as superseded by p2, B is pruned as its descendant, and every subsequent row is pruned by the forward propagation pass. Expected: all four rows survive; at most the duplicate injected note is hidden.
Suggested fix
Two independent guards, either of which fixes the observed cases; both are cheap:
- Never prune an ancestor of the newest row. Walk
parentUuidup from the last row in the file and exclude that path fromsupersededRoots. Whatever the sibling shape, the branch that leads to the newest row is by definition the live one. This is the robust guard — it does not depend on recognising injection formats. - Exclude injected rows from
isUserPromptRow()— rows whose string content begins with<task-notification>orAnother Claude session sent a message:/<teammate-message>/<agent-message>are not prompts the user typed. (Claude tags some inter-agent deliveriesisMeta: trueand those are already excluded correctly; the tagging is just not consistent, so content matters.)
A third, smaller point: when the prune removes rows, total and hasMore are computed from the pruned set, so a truncated transcript is indistinguishable from a complete one. Even with the above fixed, it would be worth logging when a prune drops a large fraction of rows — this failure is completely silent today, which is why it went unnoticed across several days of use.
Workaround
Adding "isMeta": true to the offending injected rows in the .jsonl restores the full history immediately (verified: 27 → 215 messages, newest message current), since isUserPromptRow() already skips meta rows. That is a per-file patch, not a fix — the next duplicated task notification recreates the problem in whichever session hits it.
Environment
- CloudCLI 1.37.3, git install, Linux, Node 22.11.0
- Bug verified present on
mainat the time of filing (dropSupersededPromptBranchesat line 364) - Diagnosed by calling
ClaudeSessionsProvider.fetchHistory()directly against the transcript, so the client cache and pagination are ruled out — the truncation is server-side
Source: siteboon/claudecodeui