From 1414b8c7a92074fee76aa8cf1f23b2ccf4ccdb8d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 3 Jul 2026 19:22:03 +0200 Subject: [PATCH] Remap dangling parent refs when dedup drops a distinct DAG node (#259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- claude_code_log/converter.py | 105 +++++++++++- test/test_version_deduplication.py | 262 +++++++++++++++++++++++++++++ 2 files changed, 365 insertions(+), 2 deletions(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 4a7ff9ef..4d01b78f 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -46,6 +46,7 @@ QueueOperationTranscriptEntry, SummaryTranscriptEntry, SystemTranscriptEntry, + ThinkingContent, UserTranscriptEntry, ToolResultContent, ToolUseContent, @@ -904,6 +905,33 @@ def load_directory_transcripts( # ============================================================================= +def _is_empty_thinking(entry: TranscriptEntry) -> bool: + """True for assistant entries whose content is only empty thinking blocks. + + Interleaved-thinking streaming (Claude 5 family) routinely emits these + signature-only entries; they carry nothing renderable. + """ + if not isinstance(entry, AssistantTranscriptEntry): + return False + content = entry.message.content + return bool(content) and all( + isinstance(c, ThinkingContent) and not c.thinking.strip() for c in content + ) + + +def _resolve_survivor(uuid: str, remap: dict[str, str]) -> str: + """Follow a dropped→survivor chain to its live end. + + Chains are at most a few hops (a run member remapped to a head that was + itself a stutter survivor); the visited guard is a cheap cycle backstop. + """ + visited: set[str] = set() + while uuid in remap and uuid not in visited: + visited.add(uuid) + uuid = remap[uuid] + return uuid + + def deduplicate_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: """Remove duplicate messages based on (type, timestamp, sessionId, content_key). @@ -918,16 +946,30 @@ def deduplicate_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntr 3. User text messages with same timestamp but different UUIDs (branch switch artifacts) -> Same timestamp, no tool_use_id -> SHOULD deduplicate, keep the one with most content + A dropped entry can be a distinct DAG node whose children reference it by + parentUuid (issue #259: consecutive empty-thinking entries within the same + millisecond share the assistant dedup key). Dropping is recorded in a + dropped→survivor map and dangling parentUuid/leafUuid references are + rewritten to the survivor, so children re-parent instead of orphaning. + Consecutive empty-thinking entries are additionally merged even across + distinct timestamps — they render nothing and essentially never become + fork points a posteriori. + Args: messages: List of transcript entries to deduplicate Returns: List of deduplicated messages, preserving order (first occurrence kept, - but replaced in-place if a better version is found later) + but replaced in-place if a better version is found later). Entries + whose parent was dropped are mutated in place to point at the survivor. """ # Track seen dedup_key -> index in deduplicated list (for in-place replacement) seen: dict[tuple[str, str, bool, str, str], int] = {} deduplicated: list[TranscriptEntry] = [] + # dropped uuid -> surviving uuid, for re-parenting children of dropped + # entries (issue #259). Replacement cases (user text, ai-title) never + # land here: their content_key is the uuid itself (or they have none). + dropped_to_survivor: dict[str, str] = {} for message in messages: # Get basic message type @@ -1007,14 +1049,73 @@ def deduplicate_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntr # Always keep the most recent ai-title per session — Claude # Code may refine the curated title across the session. deduplicated[seen[dedup_key]] = message - # Otherwise skip duplicate + else: + # Skip duplicate — but remember which entry survived so + # children of the dropped copy can be re-parented. + dropped_uuid = getattr(message, "uuid", None) + survivor_uuid = getattr(deduplicated[seen[dedup_key]], "uuid", None) + if dropped_uuid and survivor_uuid and dropped_uuid != survivor_uuid: + dropped_to_survivor[dropped_uuid] = survivor_uuid else: seen[dedup_key] = len(deduplicated) deduplicated.append(message) + deduplicated = _merge_empty_thinking_runs(deduplicated, dropped_to_survivor) + + if dropped_to_survivor: + for entry in deduplicated: + parent_uuid = getattr(entry, "parentUuid", None) + if parent_uuid in dropped_to_survivor: + # Safe cast: only BaseTranscriptEntry subclasses have parentUuid + entry.parentUuid = _resolve_survivor( # type: ignore[union-attr] + parent_uuid, dropped_to_survivor + ) + elif ( + isinstance(entry, SummaryTranscriptEntry) + and entry.leafUuid in dropped_to_survivor + ): + entry.leafUuid = _resolve_survivor(entry.leafUuid, dropped_to_survivor) + return deduplicated +def _merge_empty_thinking_runs( + entries: list[TranscriptEntry], dropped_to_survivor: dict[str, str] +) -> list[TranscriptEntry]: + """Collapse consecutive empty-thinking entries onto the run's head. + + An empty-thinking entry whose (survivor-resolved) parent is another + surviving empty-thinking entry in the same session is dropped, its uuid + remapped to that parent, so children re-parent to the run head (issue + #259 follow-up). Session boundaries block the merge — a cross-session + parent link is a fork attachment point that must stay addressable. + """ + surviving_by_uuid: dict[str, TranscriptEntry] = {} + merged: list[TranscriptEntry] = [] + for entry in entries: + if _is_empty_thinking(entry): + assert isinstance(entry, AssistantTranscriptEntry) + parent_uuid = ( + _resolve_survivor(entry.parentUuid, dropped_to_survivor) + if entry.parentUuid + else None + ) + parent = surviving_by_uuid.get(parent_uuid) if parent_uuid else None + if ( + parent is not None + and _is_empty_thinking(parent) + and getattr(parent, "sessionId", None) == entry.sessionId + ): + assert parent_uuid is not None + dropped_to_survivor[entry.uuid] = parent_uuid + continue + merged.append(entry) + uuid = getattr(entry, "uuid", None) + if uuid: + surviving_by_uuid[uuid] = entry + return merged + + @dataclass class GenerationStats: """Track statistics for HTML generation across a project.""" diff --git a/test/test_version_deduplication.py b/test/test_version_deduplication.py index 11840ec5..5efb5d62 100644 --- a/test/test_version_deduplication.py +++ b/test/test_version_deduplication.py @@ -5,6 +5,9 @@ from claude_code_log.models import ( AssistantTranscriptEntry, AssistantMessageModel, + SummaryTranscriptEntry, + ThinkingContent, + TranscriptEntry, UserTranscriptEntry, UserMessageModel, ToolUseContent, @@ -319,3 +322,262 @@ def test_user_text_messages_with_different_uuids_not_deduped(self): deduped = deduplicate_messages([msg1, msg2]) assert len(deduped) == 2, "Different UUIDs should not be deduped" + + +def _make_assistant( + uuid: str, + parent_uuid: str | None, + timestamp: str, + content: list, + message_id: str = "msg_stream", + session_id: str = "session-test", +) -> AssistantTranscriptEntry: + """Build a minimal assistant entry for dedup DAG tests.""" + return AssistantTranscriptEntry( + type="assistant", + uuid=uuid, + parentUuid=parent_uuid, + timestamp=timestamp, + version="2.1.198", + isSidechain=False, + userType="external", + cwd="/test", + sessionId=session_id, + message=AssistantMessageModel( + id=message_id, + type="message", + role="assistant", + model="claude-fable-5", + content=content, + stop_reason=None, + ), + ) + + +def _empty_thinking() -> list: + return [ThinkingContent(type="thinking", thinking="", signature="sig")] + + +def _uuids(entries: list[TranscriptEntry]) -> list["str | None"]: + return [getattr(e, "uuid", None) for e in entries] + + +def _assert_no_dangling_parents( + original: list[TranscriptEntry], deduped: list[TranscriptEntry] +) -> None: + """No surviving parentUuid may reference a dropped entry (issue #259). + + Parents outside the loaded slice (never in ``original``) are fine — the + DAG layer handles those; dedup must only not create NEW dangling refs. + """ + loaded = {getattr(e, "uuid", None) for e in original if getattr(e, "uuid", None)} + surviving = {getattr(e, "uuid", None) for e in deduped if getattr(e, "uuid", None)} + dropped = loaded - surviving + for entry in deduped: + parent = getattr(entry, "parentUuid", None) + assert parent not in dropped, ( + f"Entry {getattr(entry, 'uuid', '?')} references dropped parent {parent}" + ) + + +class TestDedupDagRemap: + """Dedup must not orphan children of dropped entries (issue #259). + + Newer Claude Code streams one API response as several assistant entries + sharing one message.id, and can emit two consecutive empty thinking + blocks within the same millisecond. The second is dropped as a version + stutter — its child's parentUuid must be remapped to the survivor. + """ + + def test_empty_thinking_stutter_reparents_child(self): + """The issue #259 trigger: same-timestamp empty-thinking pair + tool_use child.""" + ts = "2026-07-01T17:16:21.820Z" + first = _make_assistant("uuid-think-1", "uuid-turn-root", ts, _empty_thinking()) + second = _make_assistant("uuid-think-2", "uuid-think-1", ts, _empty_thinking()) + child = _make_assistant( + "uuid-tool-use", + "uuid-think-2", + "2026-07-01T17:16:22.717Z", + [ + ToolUseContent( + type="tool_use", + id="toolu_x", + name="Read", + input={"file_path": "/f"}, + ) + ], + ) + + original = [first, second, child] + deduped = deduplicate_messages(original) + + uuids = _uuids(deduped) + assert "uuid-think-1" in uuids, "Survivor must be kept" + assert "uuid-think-2" not in uuids, "Same-key stutter must still be dropped" + assert child.parentUuid == "uuid-think-1", ( + "Child of the dropped entry must be re-parented to the survivor, " + f"got {child.parentUuid}" + ) + _assert_no_dangling_parents(original, deduped) + + def test_empty_thinking_run_merged_across_timestamps(self): + """Consecutive empty-thinking entries merge even with distinct timestamps. + + They carry no renderable content and essentially never become fork + points a posteriori, so the run collapses to its first entry and + children re-parent to it (issue #259 follow-up comment). + """ + first = _make_assistant( + "uuid-think-1", + "uuid-turn-root", + "2026-07-01T17:16:21.820Z", + _empty_thinking(), + ) + second = _make_assistant( + "uuid-think-2", + "uuid-think-1", + "2026-07-01T17:16:21.950Z", + _empty_thinking(), + ) + third = _make_assistant( + "uuid-think-3", + "uuid-think-2", + "2026-07-01T17:16:22.100Z", + _empty_thinking(), + ) + child = _make_assistant( + "uuid-tool-use", + "uuid-think-3", + "2026-07-01T17:16:22.717Z", + [ + ToolUseContent( + type="tool_use", + id="toolu_x", + name="Read", + input={"file_path": "/f"}, + ) + ], + ) + + original = [first, second, third, child] + deduped = deduplicate_messages(original) + + uuids = _uuids(deduped) + assert uuids == ["uuid-think-1", "uuid-tool-use"], ( + f"Empty-thinking run should collapse to its head, got {uuids}" + ) + assert child.parentUuid == "uuid-think-1" + _assert_no_dangling_parents(original, deduped) + + def test_non_empty_thinking_not_merged(self): + """Distinct-timestamp thinking entries with real content stay separate.""" + first = _make_assistant( + "uuid-think-1", + "uuid-turn-root", + "2026-07-01T17:16:21.820Z", + [ + ThinkingContent( + type="thinking", thinking="Real reasoning", signature="s" + ) + ], + ) + second = _make_assistant( + "uuid-think-2", + "uuid-think-1", + "2026-07-01T17:16:21.950Z", + [ + ThinkingContent( + type="thinking", thinking="More reasoning", signature="s" + ) + ], + ) + + deduped = deduplicate_messages([first, second]) + assert _uuids(deduped) == ["uuid-think-1", "uuid-think-2"] + + def test_empty_thinking_not_merged_across_sessions(self): + """Session boundaries block the merge — fork attachment points must survive.""" + first = _make_assistant( + "uuid-think-1", + None, + "2026-07-01T17:16:21.820Z", + _empty_thinking(), + session_id="session-a", + ) + second = _make_assistant( + "uuid-think-2", + "uuid-think-1", + "2026-07-01T17:16:21.950Z", + _empty_thinking(), + message_id="msg_other", + session_id="session-b", + ) + + deduped = deduplicate_messages([first, second]) + assert _uuids(deduped) == ["uuid-think-1", "uuid-think-2"] + + def test_summary_leaf_uuid_remapped(self): + """A summary pointing at a dropped entry re-attaches to the survivor.""" + ts = "2026-07-01T17:16:21.820Z" + first = _make_assistant("uuid-think-1", "uuid-turn-root", ts, _empty_thinking()) + second = _make_assistant("uuid-think-2", "uuid-think-1", ts, _empty_thinking()) + summary = SummaryTranscriptEntry( + type="summary", summary="Session about X", leafUuid="uuid-think-2" + ) + + deduplicate_messages([first, second, summary]) + assert summary.leafUuid == "uuid-think-1" + + def test_version_stutter_children_reparented(self): + """The pre-existing stutter case also benefits: children of the dropped + copy re-parent to the surviving copy instead of dangling.""" + ts = "2026-07-01T10:00:00.000Z" + v1 = _make_assistant( + "uuid-v1", + "parent-001", + ts, + [ + ToolUseContent( + type="tool_use", + id="toolu_e", + name="Edit", + input={"file_path": "/f"}, + ) + ], + message_id="msg_dup", + ) + v2 = _make_assistant( + "uuid-v2", + "parent-002", + ts, + [ + ToolUseContent( + type="tool_use", + id="toolu_e", + name="Edit", + input={"file_path": "/f"}, + ) + ], + message_id="msg_dup", + ) + child_of_v2 = _make_assistant( + "uuid-child", + "uuid-v2", + "2026-07-01T10:00:01.000Z", + [ + ToolUseContent( + type="tool_use", + id="toolu_n", + name="Read", + input={"file_path": "/g"}, + ) + ], + message_id="msg_next", + ) + + original = [v1, v2, child_of_v2] + deduped = deduplicate_messages(original) + + assert _uuids(deduped) == ["uuid-v1", "uuid-child"] + assert child_of_v2.parentUuid == "uuid-v1" + _assert_no_dangling_parents(original, deduped)