diff --git a/docs/design/conversation-branch-inspection.md b/docs/design/conversation-branch-inspection.md new file mode 100644 index 00000000000..90487fe1ded --- /dev/null +++ b/docs/design/conversation-branch-inspection.md @@ -0,0 +1,68 @@ +# Conversation Branch Inspection + +## Motivation + +Session JSONL files already form a tree through `uuid` and `parentUuid`, but +resume currently reconstructs only one physically selected tail. A restart can +therefore hide valid sibling histories when more than one writer appended to +the same session or when a rewind created a second branch. + +This change adds a read-only topology inspector. It identifies every semantic +leaf, describes its relationship to explicit rewind records, and produces a +small deterministic summary. It does not decide which branch is active. + +## Boundary + +The inspector accepts in-memory `ChatRecord` values and has no filesystem, +session service, model, or writer dependency. Existing resume, fork, transcript +pagination, daemon, ACP, and CLI behavior remains unchanged. + +Selected-branch reconstruction continues to use `buildOrderedUuidChain` with +an explicit `leafUuid`. A later write-side change must obtain an exclusive, +stable transcript snapshot, ask the user or durable policy to select one of the +reported leaves, persist that selection, and seed the resumed writer. None of +those ownership operations belong in the inspector. + +Claude Code has an all-leaves transcript reader for analysis while its normal +resume path still selects a latest non-sidechain leaf. Qwen cannot safely use +that selection rule: an explicit rewind proves a structural relationship, but +in a multi-writer transcript it does not prove that every sibling was +intentionally abandoned. + +## Semantic leaves + +The first physical record for a UUID defines its parent, matching the existing +chain walker. Conflicting duplicate parents are diagnosed rather than guessed. + +Raw terminal records are normalized using a deliberately small neutral-tail +allowlist: `custom_title`, `session_artifact_event`, and +`session_artifact_snapshot`. These records may be appended beside or after a +conversation tail without creating a distinct recoverable conversation. A +terminal run of them collapses to its nearest known non-neutral ancestor. +If no such ancestor exists, the metadata-only run is omitted because it is not +a reconstructable conversation branch. +Collapsed candidates are deduplicated, then any candidate that is a strict +ancestor of another candidate is removed. The result is an antichain of +semantic leaves. + +All other system records remain significant. In particular, rewind, +compression, attribution, and file-history records can carry recovery state +and must not be discarded just because they have no user-visible text. + +Missing parents stop a chain at the reachable tail island. Parent cycles are +reported and bounded. The read side never reconnects missing history or labels +a branch active or abandoned. + +## Summaries and rewind relationships + +Summaries are local and deterministic. They include the closest branch point, +message counts, timestamps, the first real user text after the branch point, +and the latest real user and non-thought assistant text. Notification, cron, +and mid-turn user records are not treated as user prompts. Text is whitespace +normalized and truncated; tool arguments and non-text parts are ignored. +`updatedAt` uses the timestamp of the last physical terminal normalized into +the semantic leaf so neutral metadata activity is not lost. + +A branch is a rewind descendant when its path contains a rewind record. It is +a rewind sibling when its path diverges from the path to a rewind record. These +are structural labels only and never imply that the sibling is obsolete. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0ed26ae50a4..e975b3546c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -261,6 +261,7 @@ export type { } from './services/session-transcript-reader.js'; export * from './utils/conversation-chain.js'; export * from './utils/transcript-records.js'; +export * from './utils/conversation-branches.js'; export * from './services/sessionTitle.js'; export * from './services/sleepInhibitor.js'; // Named exports keep @internal test helpers out of the barrel. diff --git a/packages/core/src/utils/conversation-branches.test.ts b/packages/core/src/utils/conversation-branches.test.ts new file mode 100644 index 00000000000..9d49513b2fe --- /dev/null +++ b/packages/core/src/utils/conversation-branches.test.ts @@ -0,0 +1,498 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { ChatRecord } from '../services/chatRecordingService.js'; +import { inspectConversationBranches } from './conversation-branches.js'; +import { buildOrderedUuidChain } from './conversation-chain.js'; + +function record( + uuid: string, + parentUuid: string | null, + overrides: Partial = {}, +): ChatRecord { + return { + uuid, + parentUuid, + sessionId: 'session', + timestamp: `2026-01-01T00:00:${uuid.length.toString().padStart(2, '0')}.000Z`, + type: 'user', + message: { role: 'user', parts: [{ text: uuid }] }, + cwd: '/workspace', + version: '0.0.0', + ...overrides, + }; +} + +function assistant( + uuid: string, + parentUuid: string | null, + text = uuid, +): ChatRecord { + return record(uuid, parentUuid, { + type: 'assistant', + message: { role: 'model', parts: [{ text }] }, + }); +} + +function system( + uuid: string, + parentUuid: string | null, + subtype: NonNullable, +): ChatRecord { + return record(uuid, parentUuid, { + type: 'system', + subtype, + message: undefined, + }); +} + +describe('inspectConversationBranches', () => { + it('returns no branches or diagnostics for an empty transcript', () => { + expect(inspectConversationBranches([])).toEqual({ + branches: [], + diagnostics: [], + }); + }); + + it('identifies ordinary sibling branches and summarizes their divergence', () => { + const records = [ + record('root-user', null, { + message: { role: 'user', parts: [{ text: 'shared request' }] }, + }), + assistant('shared-answer', 'root-user', 'shared answer'), + record('left-user', 'shared-answer', { + message: { role: 'user', parts: [{ text: 'take the left path' }] }, + }), + assistant('left-leaf', 'left-user', 'left result'), + record('right-user', 'shared-answer', { + message: { role: 'user', parts: [{ text: 'take the right path' }] }, + }), + assistant('right-leaf', 'right-user', 'right result'), + ]; + + const analysis = inspectConversationBranches(records); + + expect(analysis.diagnostics).toEqual([]); + expect(analysis.branches.map((branch) => branch.leafUuid)).toEqual([ + 'left-leaf', + 'right-leaf', + ]); + expect(analysis.branches[0]).toMatchObject({ + branchPointUuid: 'shared-answer', + classification: 'ordinary', + firstUserTextAfterBranchPoint: 'take the left path', + lastUserText: 'take the left path', + lastAssistantText: 'left result', + recordCounts: { user: 2, assistant: 2, toolResult: 0, system: 0 }, + }); + expect(analysis.branches[1]).toMatchObject({ + branchPointUuid: 'shared-answer', + firstUserTextAfterBranchPoint: 'take the right path', + lastAssistantText: 'right result', + }); + }); + + it('summarizes a single linear branch without a branch point', () => { + const records = [ + record('root-user', null, { + message: { role: 'user', parts: [{ text: 'only request' }] }, + }), + assistant('only-answer', 'root-user', 'only answer'), + ]; + + expect(inspectConversationBranches(records).branches).toEqual([ + expect.objectContaining({ + leafUuid: 'only-answer', + branchPointUuid: null, + firstUserTextAfterBranchPoint: 'only request', + }), + ]); + }); + + it('counts tool results in a branch chain', () => { + const records = [ + record('root-user', null), + assistant('tool-call', 'root-user'), + record('tool-result', 'tool-call', { type: 'tool_result' }), + ]; + + expect(inspectConversationBranches(records).branches[0]).toMatchObject({ + recordCounts: { user: 1, assistant: 1, toolResult: 1, system: 0 }, + }); + }); + + it('uses the nearest branch point for nested forks', () => { + const records = [ + record('root', null), + assistant('outer-leaf', 'root'), + record('nested-root', 'root'), + assistant('nested-left', 'nested-root'), + assistant('nested-right', 'nested-root'), + ]; + + const analysis = inspectConversationBranches(records); + expect( + analysis.branches.map((branch) => [ + branch.leafUuid, + branch.branchPointUuid, + ]), + ).toEqual([ + ['outer-leaf', 'root'], + ['nested-left', 'nested-root'], + ['nested-right', 'nested-root'], + ]); + }); + + it('collapses and deduplicates neutral terminal metadata', () => { + const records = [ + record('conversation-leaf', null), + system('title', 'conversation-leaf', 'custom_title'), + system('artifact-event', 'conversation-leaf', 'session_artifact_event'), + system( + 'artifact-snapshot', + 'conversation-leaf', + 'session_artifact_snapshot', + ), + ]; + + expect( + inspectConversationBranches(records).branches.map( + (branch) => branch.leafUuid, + ), + ).toEqual(['conversation-leaf']); + }); + + it('drops neutral-only branches without a conversation ancestor', () => { + const subtypes = [ + 'custom_title', + 'session_artifact_event', + 'session_artifact_snapshot', + ] as const; + + for (const subtype of subtypes) { + const records = [system('metadata-root', null, subtype)]; + expect(inspectConversationBranches(records)).toEqual({ + branches: [], + diagnostics: [], + }); + } + }); + + it('uses the last collapsed physical leaf timestamp as updatedAt', () => { + const records = [ + record('conversation-leaf', null), + system('title', 'conversation-leaf', 'custom_title'), + system('artifact', 'conversation-leaf', 'session_artifact_event'), + ]; + records[0]!.timestamp = '2026-01-01T00:00:01.000Z'; + records[1]!.timestamp = '2026-01-01T00:00:02.000Z'; + records[2]!.timestamp = '2026-01-01T00:00:03.000Z'; + + expect(inspectConversationBranches(records).branches[0]).toMatchObject({ + leafUuid: 'conversation-leaf', + updatedAt: '2026-01-01T00:00:03.000Z', + }); + }); + + it('removes a collapsed ancestor when a real descendant leaf exists', () => { + const records = [ + record('root', null), + system('title', 'root', 'custom_title'), + system('artifact', 'root', 'session_artifact_event'), + assistant('real-leaf', 'root'), + ]; + + expect( + inspectConversationBranches(records).branches.map( + (branch) => branch.leafUuid, + ), + ).toEqual(['real-leaf']); + }); + + it('collapses a neutral chain but preserves significant system terminals', () => { + const records = [ + record('root', null), + system('title-1', 'root', 'custom_title'), + system('title-2', 'title-1', 'custom_title'), + system('compression', 'root', 'chat_compression'), + system('slash', 'root', 'slash_command'), + system('attribution', 'root', 'attribution_snapshot'), + system('file-history', 'root', 'file_history_snapshot'), + ]; + + expect( + inspectConversationBranches(records).branches.map( + (branch) => branch.leafUuid, + ), + ).toEqual(['compression', 'slash', 'attribution', 'file-history']); + }); + + it('classifies rewind descendants and siblings without discarding either', () => { + const records = [ + record('shared', null), + record('old-user', 'shared'), + assistant('old-leaf', 'old-user'), + system('rewind', 'shared', 'rewind'), + record('new-user', 'rewind'), + assistant('new-leaf', 'new-user'), + ]; + + const analysis = inspectConversationBranches(records); + const oldBranch = analysis.branches.find( + (branch) => branch.leafUuid === 'old-leaf', + ); + const newBranch = analysis.branches.find( + (branch) => branch.leafUuid === 'new-leaf', + ); + + expect(oldBranch).toMatchObject({ + classification: 'rewind-sibling', + containsRewindUuids: [], + siblingRewindUuids: ['rewind'], + }); + expect(newBranch).toMatchObject({ + classification: 'rewind-descendant', + containsRewindUuids: ['rewind'], + siblingRewindUuids: [], + }); + }); + + it('reports mixed rewind relationships across nested forks', () => { + const records = [ + record('root', null), + system('first-rewind', 'root', 'rewind'), + assistant('first-leaf', 'first-rewind'), + record('other-path', 'root'), + system('second-rewind', 'other-path', 'rewind'), + assistant('mixed-leaf', 'second-rewind'), + ]; + + const mixed = inspectConversationBranches(records).branches.find( + (branch) => branch.leafUuid === 'mixed-leaf', + ); + expect(mixed).toMatchObject({ + classification: 'mixed-rewind', + containsRewindUuids: ['second-rewind'], + siblingRewindUuids: ['first-rewind'], + }); + }); + + it('does not classify a rewind from another root as a sibling', () => { + const records = [ + record('first-root', null), + assistant('first-leaf', 'first-root'), + record('second-root', null), + system('unrelated-rewind', 'second-root', 'rewind'), + assistant('second-leaf', 'unrelated-rewind'), + ]; + + const firstBranch = inspectConversationBranches(records).branches.find( + (branch) => branch.leafUuid === 'first-leaf', + ); + expect(firstBranch).toMatchObject({ + classification: 'ordinary', + containsRewindUuids: [], + siblingRewindUuids: [], + }); + }); + + it('detects missing parents, cycles, and conflicting duplicate parents', () => { + const records = [ + record('orphan', 'missing'), + record('cycle-a', 'cycle-b'), + record('cycle-b', 'cycle-a'), + record('duplicate', null), + record('other-root', null), + record('duplicate', 'other-root'), + ]; + + const analysis = inspectConversationBranches(records); + + expect(analysis.branches.map((branch) => branch.leafUuid)).toEqual([ + 'orphan', + 'duplicate', + 'other-root', + ]); + expect(analysis.diagnostics).toEqual([ + { + kind: 'missing-parent', + childUuid: 'orphan', + missingParentUuid: 'missing', + }, + { + kind: 'conflicting-parent', + uuid: 'duplicate', + parentUuids: [null, 'other-root'], + }, + { kind: 'parent-cycle', uuids: ['cycle-a', 'cycle-b'] }, + ]); + expect( + buildOrderedUuidChain(records, { + leafUuid: 'orphan', + detectGaps: true, + }), + ).toEqual({ + uuids: ['orphan'], + gaps: [{ childUuid: 'orphan', missingParentUuid: 'missing' }], + }); + }); + + it('does not mix roles when duplicate UUID records have different types', () => { + const records = [ + record('duplicate', null, { + message: { role: 'user', parts: [{ text: 'user text' }] }, + }), + assistant('duplicate', null, 'assistant text'), + ]; + + const [branch] = inspectConversationBranches(records).branches; + expect(branch).toMatchObject({ + lastUserText: 'user text', + recordCounts: { user: 1, assistant: 0, toolResult: 0, system: 0 }, + }); + expect(branch?.lastAssistantText).toBeUndefined(); + }); + + it('filters synthetic prompts and thoughts and truncates summary text', () => { + const longText = 'x'.repeat(220); + const records = [ + record('real-user', null, { + message: { role: 'user', parts: [{ text: ' real\n request ' }] }, + }), + record('notification', 'real-user', { + subtype: 'notification', + message: { role: 'user', parts: [{ text: 'synthetic prompt' }] }, + }), + record('cron', 'notification', { + subtype: 'cron', + message: { role: 'user', parts: [{ text: 'cron prompt' }] }, + }), + record('mid-turn', 'cron', { + subtype: 'mid_turn_user_message', + message: { role: 'user', parts: [{ text: 'mid-turn prompt' }] }, + }), + record('external-notification', 'mid-turn', { + externalInputKind: 'notification', + message: { + role: 'user', + parts: [{ text: 'background agent notification' }], + }, + }), + record('visible-assistant', 'external-notification', { + type: 'assistant', + message: { + role: 'model', + parts: [{ text: 'hidden', thought: true }, { text: longText }], + }, + }), + record('tool-call', 'visible-assistant', { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + name: 'dangerous-looking-tool', + args: { secret: 'must not enter the summary' }, + }, + }, + ], + }, + }), + ]; + + const [branch] = inspectConversationBranches(records).branches; + expect(branch).toMatchObject({ + firstUserTextAfterBranchPoint: 'real request', + lastUserText: 'real request', + recordCounts: { user: 5, assistant: 2, toolResult: 0, system: 0 }, + }); + expect(branch?.lastAssistantText).toBe(`${'x'.repeat(200)}...`); + }); + + it('uses physical leaf order even when timestamps are out of order', () => { + const records = [ + record('root', null), + assistant('first-leaf', 'root', 'first'), + assistant('second-leaf', 'root', 'second'), + ]; + records[1]!.timestamp = '2099-01-01T00:00:00.000Z'; + records[2]!.timestamp = '2000-01-01T00:00:00.000Z'; + + expect( + inspectConversationBranches(records).branches.map( + (branch) => branch.leafUuid, + ), + ).toEqual(['first-leaf', 'second-leaf']); + }); + + it('uses collapsed physical leaf order instead of ancestor order', () => { + const records = [ + record('first-root', null), + record('second-root', null), + system('second-title', 'second-root', 'custom_title'), + system('first-title', 'first-root', 'custom_title'), + ]; + + expect( + inspectConversationBranches(records).branches.map( + (branch) => branch.leafUuid, + ), + ).toEqual(['second-root', 'first-root']); + }); + + it('normalizes the sanitized incident topology from three raw terminals to two branches', () => { + const records = [ + record('shared', null), + record('old-user', 'shared'), + assistant('old-answer', 'old-user'), + system('old-title-tail', 'old-answer', 'custom_title'), + system('rewind', 'shared', 'rewind'), + record('new-user', 'rewind'), + assistant('new-answer', 'new-user'), + system('artifact-side-tail', 'shared', 'session_artifact_event'), + ]; + + const rawTerminals = records.filter( + (candidate) => + !records.some((record) => record.parentUuid === candidate.uuid), + ); + const analysis = inspectConversationBranches(records); + + expect(rawTerminals.map((record) => record.uuid)).toEqual([ + 'old-title-tail', + 'new-answer', + 'artifact-side-tail', + ]); + expect(analysis.branches.map((branch) => branch.leafUuid)).toEqual([ + 'old-answer', + 'new-answer', + ]); + }); + + it('returns leaves accepted by the existing explicit reconstruction path', () => { + const records = [ + record('root', null), + record('left', 'root'), + assistant('left-leaf', 'left'), + record('right', 'root'), + assistant('right-leaf', 'right'), + ]; + + const chains = inspectConversationBranches(records).branches.map((branch) => + buildOrderedUuidChain(records, { + leafUuid: branch.leafUuid, + detectGaps: true, + }), + ); + + expect(chains).toEqual([ + { uuids: ['root', 'left', 'left-leaf'], gaps: [] }, + { uuids: ['root', 'right', 'right-leaf'], gaps: [] }, + ]); + }); +}); diff --git a/packages/core/src/utils/conversation-branches.ts b/packages/core/src/utils/conversation-branches.ts new file mode 100644 index 00000000000..04c44445315 --- /dev/null +++ b/packages/core/src/utils/conversation-branches.ts @@ -0,0 +1,547 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChatRecord } from '../services/chatRecordingService.js'; + +const SUMMARY_TEXT_LIMIT = 200; +const SYNTHETIC_USER_SUBTYPES = new Set([ + 'notification', + 'cron', + 'mid_turn_user_message', +]); +const NEUTRAL_TAIL_SUBTYPES = new Set([ + 'custom_title', + 'session_artifact_event', + 'session_artifact_snapshot', +]); + +export type ConversationBranchClassification = + | 'ordinary' + | 'rewind-descendant' + | 'rewind-sibling' + | 'mixed-rewind'; + +export interface ConversationBranchSummary { + leafUuid: string; + branchPointUuid: string | null; + classification: ConversationBranchClassification; + containsRewindUuids: string[]; + siblingRewindUuids: string[]; + firstUserTextAfterBranchPoint?: string; + lastUserText?: string; + lastAssistantText?: string; + recordCounts: { + user: number; + assistant: number; + toolResult: number; + system: number; + }; + startedAt: string; + updatedAt: string; +} + +export type ConversationBranchDiagnostic = + | { + kind: 'missing-parent'; + childUuid: string; + missingParentUuid: string; + } + | { + kind: 'parent-cycle'; + uuids: string[]; + } + | { + kind: 'conflicting-parent'; + uuid: string; + parentUuids: Array; + }; + +export interface ConversationBranchAnalysis { + branches: ConversationBranchSummary[]; + diagnostics: ConversationBranchDiagnostic[]; +} + +interface ConversationIndex { + firstByUuid: Map; + recordsByUuid: Map; + childrenByUuid: Map; + physicalIndexByUuid: Map; + diagnostics: ConversationBranchDiagnostic[]; +} + +interface ForestPosition { + rootUuid: string; + enteredAt: number; + exitedAt: number; +} + +interface SemanticLeaf { + leafUuid: string; + physicalLeafUuid: string; +} + +export function inspectConversationBranches( + records: readonly ChatRecord[], +): ConversationBranchAnalysis { + if (records.length === 0) return { branches: [], diagnostics: [] }; + + const index = buildConversationIndex(records); + const semanticLeaves = findSemanticLeaves(index); + const leafCountByAncestor = countLeafDescendants( + semanticLeaves.map(({ leafUuid }) => leafUuid), + index.firstByUuid, + ); + const rewindUuids = [...index.firstByUuid.values()] + .filter(isRewindRecord) + .map((record) => record.uuid); + const forestPositions = indexForest(index); + + const branches = semanticLeaves.map(({ leafUuid, physicalLeafUuid }) => + summarizeBranch( + leafUuid, + physicalLeafUuid, + leafCountByAncestor, + rewindUuids, + forestPositions, + index, + ), + ); + + return { branches, diagnostics: index.diagnostics }; +} + +function indexForest(index: ConversationIndex): Map { + const positions = new Map(); + let clock = 0; + for (const [uuid, record] of index.firstByUuid) { + if ( + record.parentUuid !== null && + index.firstByUuid.has(record.parentUuid) + ) { + continue; + } + const stack: Array<{ uuid: string; exiting: boolean }> = [ + { uuid, exiting: false }, + ]; + while (stack.length > 0) { + const frame = stack.pop()!; + if (frame.exiting) { + const position = positions.get(frame.uuid); + if (position) position.exitedAt = clock++; + continue; + } + if (positions.has(frame.uuid)) continue; + positions.set(frame.uuid, { + rootUuid: uuid, + enteredAt: clock++, + exitedAt: -1, + }); + stack.push({ uuid: frame.uuid, exiting: true }); + const children = index.childrenByUuid.get(frame.uuid) ?? []; + for ( + let childIndex = children.length - 1; + childIndex >= 0; + childIndex-- + ) { + stack.push({ uuid: children[childIndex]!, exiting: false }); + } + } + } + return positions; +} + +function buildConversationIndex( + records: readonly ChatRecord[], +): ConversationIndex { + const firstByUuid = new Map(); + const recordsByUuid = new Map(); + const physicalIndexByUuid = new Map(); + + for (const [physicalIndex, record] of records.entries()) { + const grouped = recordsByUuid.get(record.uuid); + if (grouped) { + grouped.push(record); + } else { + firstByUuid.set(record.uuid, record); + recordsByUuid.set(record.uuid, [record]); + physicalIndexByUuid.set(record.uuid, physicalIndex); + } + } + + const diagnostics: ConversationBranchDiagnostic[] = []; + const childrenByUuid = new Map(); + + for (const [uuid, grouped] of recordsByUuid) { + const parentUuids = [ + ...new Set(grouped.map((record) => record.parentUuid)), + ]; + if (parentUuids.length > 1) { + diagnostics.push({ kind: 'conflicting-parent', uuid, parentUuids }); + } + + const parentUuid = firstByUuid.get(uuid)?.parentUuid; + if (parentUuid === undefined || parentUuid === null) continue; + if (!firstByUuid.has(parentUuid)) { + diagnostics.push({ + kind: 'missing-parent', + childUuid: uuid, + missingParentUuid: parentUuid, + }); + continue; + } + const children = childrenByUuid.get(parentUuid); + if (children) { + children.push(uuid); + } else { + childrenByUuid.set(parentUuid, [uuid]); + } + } + + diagnostics.push(...findParentCycles(firstByUuid)); + return { + firstByUuid, + recordsByUuid, + childrenByUuid, + physicalIndexByUuid, + diagnostics, + }; +} + +function findParentCycles( + firstByUuid: ReadonlyMap, +): ConversationBranchDiagnostic[] { + const complete = new Set(); + const diagnostics: ConversationBranchDiagnostic[] = []; + + for (const startUuid of firstByUuid.keys()) { + if (complete.has(startUuid)) continue; + const path: string[] = []; + const positionByUuid = new Map(); + let currentUuid: string | null = startUuid; + + while (currentUuid !== null && firstByUuid.has(currentUuid)) { + if (complete.has(currentUuid)) break; + const existingPosition = positionByUuid.get(currentUuid); + if (existingPosition !== undefined) { + diagnostics.push({ + kind: 'parent-cycle', + uuids: path.slice(existingPosition), + }); + break; + } + positionByUuid.set(currentUuid, path.length); + path.push(currentUuid); + currentUuid = firstByUuid.get(currentUuid)?.parentUuid ?? null; + } + + for (const uuid of path) complete.add(uuid); + } + + return diagnostics; +} + +function findSemanticLeaves(index: ConversationIndex): SemanticLeaf[] { + const candidates = new Set(); + const physicalLeafBySemanticLeaf = new Map(); + for (const uuid of index.firstByUuid.keys()) { + if ((index.childrenByUuid.get(uuid)?.length ?? 0) > 0) continue; + const leafUuid = collapseNeutralTail(uuid, index.firstByUuid); + if (leafUuid === null) continue; + candidates.add(leafUuid); + physicalLeafBySemanticLeaf.set(leafUuid, uuid); + } + + const superseded = new Set(); + for (const candidate of candidates) { + const visited = new Set(); + let currentUuid = index.firstByUuid.get(candidate)?.parentUuid ?? null; + while ( + currentUuid !== null && + index.firstByUuid.has(currentUuid) && + !visited.has(currentUuid) + ) { + if (candidates.has(currentUuid)) superseded.add(currentUuid); + visited.add(currentUuid); + currentUuid = index.firstByUuid.get(currentUuid)?.parentUuid ?? null; + } + } + + const leaves = [...candidates].filter( + (candidate) => !superseded.has(candidate), + ); + + return leaves + .sort((left, right) => { + const leftPhysicalLeaf = physicalLeafBySemanticLeaf.get(left)!; + const rightPhysicalLeaf = physicalLeafBySemanticLeaf.get(right)!; + return ( + index.physicalIndexByUuid.get(leftPhysicalLeaf)! - + index.physicalIndexByUuid.get(rightPhysicalLeaf)! + ); + }) + .map((leafUuid) => ({ + leafUuid, + physicalLeafUuid: physicalLeafBySemanticLeaf.get(leafUuid)!, + })); +} + +function countLeafDescendants( + semanticLeaves: readonly string[], + firstByUuid: ReadonlyMap, +): ReadonlyMap { + const counts = new Map(); + for (const leafUuid of semanticLeaves) { + for (const uuid of buildChain(leafUuid, firstByUuid)) { + counts.set(uuid, (counts.get(uuid) ?? 0) + 1); + } + } + return counts; +} + +function collapseNeutralTail( + leafUuid: string, + firstByUuid: ReadonlyMap, +): string | null { + const visited = new Set(); + let currentUuid = leafUuid; + + while (!visited.has(currentUuid)) { + visited.add(currentUuid); + const record = firstByUuid.get(currentUuid); + if (!record) return null; + if (!isNeutralTailRecord(record)) return currentUuid; + const parentUuid = record.parentUuid; + if (parentUuid === null || !firstByUuid.has(parentUuid)) return null; + currentUuid = parentUuid; + } + + return null; +} + +function summarizeBranch( + leafUuid: string, + physicalLeafUuid: string, + leafCountByAncestor: ReadonlyMap, + rewindUuids: readonly string[], + forestPositions: ReadonlyMap, + index: ConversationIndex, +): ConversationBranchSummary { + const chain = buildChain(leafUuid, index.firstByUuid); + const chainSet = new Set(chain); + const branchPointUuid = findBranchPoint(chain, leafCountByAncestor); + const containsRewindUuids = chain.filter((uuid) => + isRewindRecord(index.firstByUuid.get(uuid)), + ); + const siblingRewindUuids = rewindUuids.filter( + (rewindUuid) => + !chainSet.has(rewindUuid) && + isSiblingPath( + leafUuid, + chain, + rewindUuid, + forestPositions, + index.firstByUuid, + ), + ); + + const firstRecord = index.firstByUuid.get(chain[0])!; + const physicalLeafRecord = index.firstByUuid.get(physicalLeafUuid)!; + const recordCounts = { user: 0, assistant: 0, toolResult: 0, system: 0 }; + for (const uuid of chain) { + const record = index.firstByUuid.get(uuid)!; + if (record.type === 'tool_result') { + recordCounts.toolResult++; + } else { + recordCounts[record.type]++; + } + } + + const firstDistinctIndex = branchPointUuid + ? chain.indexOf(branchPointUuid) + 1 + : 0; + const firstUserTextAfterBranchPoint = findUserText( + chain.slice(firstDistinctIndex), + index, + ); + const lastUserText = findUserText([...chain].reverse(), index); + const lastAssistantText = findAssistantText([...chain].reverse(), index); + + return { + leafUuid, + branchPointUuid, + classification: classifyBranch( + containsRewindUuids.length > 0, + siblingRewindUuids.length > 0, + ), + containsRewindUuids, + siblingRewindUuids, + ...(firstUserTextAfterBranchPoint ? { firstUserTextAfterBranchPoint } : {}), + ...(lastUserText ? { lastUserText } : {}), + ...(lastAssistantText ? { lastAssistantText } : {}), + recordCounts, + startedAt: firstRecord.timestamp, + updatedAt: physicalLeafRecord.timestamp, + }; +} + +function findBranchPoint( + chain: readonly string[], + leafCountByAncestor: ReadonlyMap, +): string | null { + for (let index = chain.length - 2; index >= 0; index--) { + const candidate = chain[index]; + if (candidate && (leafCountByAncestor.get(candidate) ?? 0) > 1) { + return candidate; + } + } + return null; +} + +function findUserText( + uuids: readonly string[], + index: ConversationIndex, +): string | undefined { + for (const uuid of uuids) { + const record = index.firstByUuid.get(uuid); + if (!record || record.type !== 'user' || isSyntheticUserRecord(record)) { + continue; + } + const text = extractText(index.recordsByUuid.get(uuid) ?? [], 'user'); + if (text) return text; + } + return undefined; +} + +function findAssistantText( + uuids: readonly string[], + index: ConversationIndex, +): string | undefined { + for (const uuid of uuids) { + if (index.firstByUuid.get(uuid)?.type !== 'assistant') continue; + const text = extractText(index.recordsByUuid.get(uuid) ?? [], 'assistant'); + if (text) return text; + } + return undefined; +} + +function extractText( + records: readonly ChatRecord[], + type: 'user' | 'assistant', +): string | undefined { + const text = records + .filter( + (record) => + record.type === type && + (type !== 'user' || !isSyntheticUserRecord(record)), + ) + .flatMap((record) => record.message?.parts ?? []) + .map((part) => { + const textPart = part as { text?: unknown; thought?: unknown }; + return typeof textPart.text === 'string' && textPart.thought !== true + ? textPart.text + : ''; + }) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + if (!text) return undefined; + return text.length > SUMMARY_TEXT_LIMIT + ? `${text.slice(0, SUMMARY_TEXT_LIMIT)}...` + : text; +} + +function buildChain( + leafUuid: string, + firstByUuid: ReadonlyMap, +): string[] { + const chain: string[] = []; + const visited = new Set(); + let currentUuid: string | null = leafUuid; + while ( + currentUuid !== null && + firstByUuid.has(currentUuid) && + !visited.has(currentUuid) + ) { + visited.add(currentUuid); + chain.push(currentUuid); + currentUuid = firstByUuid.get(currentUuid)?.parentUuid ?? null; + } + chain.reverse(); + return chain; +} + +function isSiblingPath( + leafUuid: string, + leafChain: readonly string[], + rewindUuid: string, + forestPositions: ReadonlyMap, + firstByUuid: ReadonlyMap, +): boolean { + const leafPosition = forestPositions.get(leafUuid); + const rewindPosition = forestPositions.get(rewindUuid); + if (leafPosition && rewindPosition) { + return ( + leafPosition.rootUuid === rewindPosition.rootUuid && + !isAncestorPosition(leafPosition, rewindPosition) + ); + } + return pathsDiverge(leafChain, buildChain(rewindUuid, firstByUuid)); +} + +function isAncestorPosition( + ancestor: ForestPosition, + descendant: ForestPosition, +): boolean { + return ( + ancestor.enteredAt <= descendant.enteredAt && + ancestor.exitedAt >= descendant.exitedAt + ); +} + +function pathsDiverge( + left: readonly string[], + right: readonly string[], +): boolean { + const rightPositions = new Map(right.map((uuid, index) => [uuid, index])); + for (let leftIndex = left.length - 1; leftIndex >= 0; leftIndex--) { + const uuid = left[leftIndex]; + if (!uuid) continue; + const rightIndex = rightPositions.get(uuid); + if (rightIndex === undefined) continue; + return leftIndex < left.length - 1 && rightIndex < right.length - 1; + } + return false; +} + +function isNeutralTailRecord(record: ChatRecord): boolean { + return ( + record.type === 'system' && + record.subtype !== undefined && + NEUTRAL_TAIL_SUBTYPES.has(record.subtype) + ); +} + +function isSyntheticUserRecord(record: ChatRecord): boolean { + return ( + record.externalInputKind === 'notification' || + (record.subtype !== undefined && + SYNTHETIC_USER_SUBTYPES.has(record.subtype)) + ); +} + +function isRewindRecord(record: ChatRecord | undefined): boolean { + return record?.type === 'system' && record.subtype === 'rewind'; +} + +function classifyBranch( + containsRewind: boolean, + hasSiblingRewind: boolean, +): ConversationBranchClassification { + if (containsRewind && hasSiblingRewind) return 'mixed-rewind'; + if (containsRewind) return 'rewind-descendant'; + if (hasSiblingRewind) return 'rewind-sibling'; + return 'ordinary'; +}