Remap dangling parent refs when dedup drops a distinct DAG node (#259)#261
Conversation
Newer Claude Code streams one API response as several assistant JSONL
entries sharing one message.id, and can emit two consecutive empty
thinking blocks (signature only) as separate entries within the same
millisecond. Their assistant dedup keys are identical, so the second
entry was treated as a version stutter and dropped — orphaning its
tool_use child, which got promoted to a spurious root branch:
WARNING: Orphan node …: parentUuid … not found in loaded data
Adding uuid to the dedup key is not the fix: true version stutters
re-log the message with a different uuid per copy, so that would stop
deduplicating them (pinned in test_version_deduplication.py).
Instead, record dropped_uuid -> survivor_uuid whenever dedup drops an
entry, and rewrite any surviving entry's dangling parentUuid (and
summary leafUuid) to the survivor. Children of a dropped copy now
re-parent to the surviving copy — equally correct for empty-thinking
splits and for true version stutters.
While at it, merge consecutive empty-thinking entries even across
distinct timestamps (same session only): they carry nothing renderable
and essentially never become fork points a posteriori, so the run
collapses onto its head via the same remap mechanism. Session
boundaries block the merge so fork attachment points stay addressable.
Token accounting is unaffected in the observed pattern: entries of one
response share a requestId and usage is counted once per requestId.
Verified by execution: a JSONL with the issue's exact trigger pattern
renders with no orphan warning and a single root on the fixed code,
and reproduces the warning + spurious root on the previous code.
Closes #259
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughModified ChangesDeduplication Survivor Remapping
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Loader as load_transcript
participant Dedup as deduplicate_messages
participant Map as dropped_to_survivor
participant Merge as _merge_empty_thinking_runs
participant Output as Deduped entries
Loader->>Dedup: raw transcript entries
Dedup->>Map: record dropped_uuid to survivor_uuid
Dedup->>Merge: pass deduped entries
Merge->>Map: update mapping for merged empty-thinking runs
Merge->>Dedup: return merged entries
Dedup->>Dedup: resolve parentUuid/leafUuid via Map
Dedup->>Output: emit entries with re-parented references
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
(Claude) Fixes #259.
Problem
Newer Claude Code streams one API response as several assistant JSONL entries sharing one
message.id, and can emit two consecutive empty thinking blocks ("thinking": "", signature only) as separate entries within the same millisecond. Their assistant dedup keys —(type, timestamp, isMeta, sessionId, message.id + block_types)— are identical, sodeduplicate_messages()treated the second entry as a version stutter and silently dropped it. But it is a distinct DAG node: itstool_usechild then dangled and got promoted to a spurious root branch:Adding uuid to the dedup key is not the fix: true version stutters re-log the message with a different uuid per copy (pinned in
test_version_deduplication.py), so that would stop deduplicating them.Fix
dropped_uuid → survivor_uuid; afterwards rewrite any surviving entry's danglingparentUuid(and summaryleafUuid) to the survivor. Children of a dropped copy re-parent to the surviving copy — equally correct for empty-thinking splits and true version stutters.requestId, shared by all entries of one response).Verification
TestDedupDagRemap; 4 fail on the previous code (the two guards already held), all pass with the fix.🤖 Generated with Claude Code
Summary by CodeRabbit