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)