diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 611106a4a6..1c455a9f1c 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -468,7 +468,8 @@ const { mockHistoryReplay } = vi.hoisted(() => ({ })); vi.mock('./session/HistoryReplayer.js', () => ({ HistoryReplayer: vi.fn().mockImplementation((context: unknown) => ({ - replay: (messages: unknown) => mockHistoryReplay(context, messages), + replay: (messages: unknown, gaps: unknown) => + mockHistoryReplay(context, messages, gaps), })), })); @@ -5249,6 +5250,36 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('qwen/session/loadUpdates threads detected historyGaps to the replayer', async () => { + const settings = makeCoreSettings(); + const gaps = [{ childUuid: 'c', missingParentUuid: 'gone' }]; + mockSessionServiceLoad({ + conversation: { + messages: [{ role: 'user' }], + startTime: 'start', + lastUpdated: 'end', + }, + historyGaps: gaps, + }); + mockHistoryReplay.mockResolvedValue(undefined); + const { agent, agentPromise } = await bootCoreSettingsAgent(settings); + + await agent.extMethod('qwen/session/loadUpdates', { + sessionId: VALID_SESSION_ID, + }); + // 3rd arg to the replayer is the historyGaps threaded through + // collectHistoryReplayUpdates — without it this ACP surface renders a + // broken chain as contiguous, with no gap divider. + expect(mockHistoryReplay).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + gaps, + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('qwen/session/loadUpdates surfaces partial + replayError when replay throws', async () => { const settings = makeCoreSettings(); mockSessionServiceLoad({ @@ -7868,8 +7899,12 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { configOptions: expect.anything(), }); // load semantic: history MUST be replayed so SSE subscribers see - // the persisted turns. - expect(lastSessionMock?.replayHistory).toHaveBeenCalledWith(messages); + // the persisted turns. Second arg is the detected history gaps + // (undefined here — this fixture session has an intact chain). + expect(lastSessionMock?.replayHistory).toHaveBeenCalledWith( + messages, + undefined, + ); const recording = lastSessionMock?.getConfig().getChatRecordingService(); expect(recording?.rebuildTurnBoundaries).toHaveBeenCalledWith(messages); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 11b89e0e70..25b6d34632 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -87,6 +87,7 @@ import type { DiscoveredMCPPrompt, WorkspaceRememberContextMode, ChatRecord, + HistoryGap, } from '@qwen-code/qwen-code-core'; import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; import { @@ -335,11 +336,13 @@ async function collectHistoryReplayUpdates({ sessionId, config, records, + gaps, cumulativeUsage, }: { sessionId: string; config: Config; records: ChatRecord[]; + gaps?: HistoryGap[]; cumulativeUsage: CumulativeUsage; }): Promise<{ updates: SessionUpdate[]; replayError?: string }> { const updates: SessionUpdate[] = []; @@ -353,7 +356,7 @@ async function collectHistoryReplayUpdates({ }; try { - await new HistoryReplayer(replayContext).replay(records); + await new HistoryReplayer(replayContext).replay(records, gaps); } catch (error) { const replayError = error instanceof Error ? error.message : String(error); debugLogger.warn( @@ -3141,6 +3144,7 @@ class QwenAgent implements Agent { sessionId: params.sessionId, config, records, + gaps: sessionData?.historyGaps, cumulativeUsage: replayUsage, }); replayUpdates = liftSessionUpdateTimestamps(replay.updates); @@ -7399,6 +7403,7 @@ class QwenAgent implements Agent { sessionId, config: this.config, records: sessionData.conversation.messages, + gaps: sessionData.historyGaps, cumulativeUsage: createReplayCumulativeUsage(), }); @@ -8242,7 +8247,10 @@ class QwenAgent implements Agent { } if (options.replayHistory !== false && sessionData?.conversation.messages) { - await session.replayHistory(sessionData.conversation.messages); + await session.replayHistory( + sessionData.conversation.messages, + sessionData.historyGaps, + ); } if (options.startPostReplayServices !== false) { diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts index c00b239578..4f891c5991 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts @@ -13,6 +13,7 @@ import type { SessionContext } from './types.js'; import type { Config, ChatRecord, + HistoryGap, ToolRegistry, ToolResultDisplay, TodoResultDisplay, @@ -885,4 +886,48 @@ describe('HistoryReplayer', () => { }); }); }); + + describe('history gaps', () => { + const userRec = (uuid: string, text: string): ChatRecord => + ({ + uuid, + parentUuid: null, + sessionId: 'test-session', + timestamp: '2026-07-05T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text }] }, + cwd: '/tmp', + version: '0', + }) as unknown as ChatRecord; + + it('emits a history-gap notice before the gap child record', async () => { + const records = [ + userRec('old', 'older turn'), + userRec('new', 'newer turn'), + ]; + const gaps: HistoryGap[] = [ + { childUuid: 'new', missingParentUuid: 'gone' }, + ]; + + await replayer.replay(records, gaps); + + const texts = sentUpdates().map((u) => { + const content = u['content'] as { text?: string } | undefined; + return content?.text ?? ''; + }); + const gapIdx = texts.findIndex((t) => t.includes('History gap')); + const newIdx = texts.findIndex((t) => t.includes('newer turn')); + expect(gapIdx).toBeGreaterThanOrEqual(0); + expect(newIdx).toBeGreaterThan(gapIdx); + }); + + it('emits no gap notice when there are no gaps', async () => { + await replayer.replay([userRec('a', 'a turn')]); + const hasGap = sentUpdates().some((u) => { + const content = u['content'] as { text?: string } | undefined; + return (content?.text ?? '').includes('History gap'); + }); + expect(hasGap).toBe(false); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 57f68bddae..223b1346d2 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -9,6 +9,7 @@ import type { AgentResultDisplay, SlashCommandRecordPayload, NotificationRecordPayload, + HistoryGap, } from '@qwen-code/qwen-code-core'; import type { Content, @@ -18,6 +19,10 @@ import type { SessionContext } from './types.js'; import { MessageEmitter } from './emitters/MessageEmitter.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { getToolResultCallId } from '../../utils/chat-record-tool-call-id.js'; +import { + formatHistoryGapNotice, + indexGapsByChild, +} from '../../ui/utils/history-gap-notice.js'; export const MISSING_TOOL_RESULT_MESSAGE = 'Tool result missing from saved history; the previous run likely ended ' + @@ -56,13 +61,21 @@ export class HistoryReplayer { * Replays all chat records from a loaded session. * * @param records - Array of chat records to replay + * @param gaps - Optional detected history gaps; a visible notice is emitted + * immediately before each gap's child record so the user sees that an + * earlier segment was lost rather than assuming the halves are contiguous. */ - async replay(records: ChatRecord[]): Promise { + async replay(records: ChatRecord[], gaps?: HistoryGap[]): Promise { this.pendingReplayToolCalls.clear(); + const gapByChildUuid = indexGapsByChild(gaps); try { let replayError: unknown; try { for (const record of records) { + const gap = gapByChildUuid.get(record.uuid); + if (gap) { + await this.emitHistoryGapNotice(gap, record.timestamp); + } await this.replayRecord(record); } } catch (error) { @@ -180,6 +193,24 @@ export class HistoryReplayer { } } + /** + * Emits a visible notice marking a break in the persisted history chain: an + * earlier segment was physically lost (storage interruption) and could not be + * recovered, so the surviving turns below must not be read as contiguous with + * whatever came before the gap. Uses the agent message channel — the same one + * used for other system notices (see MessageEmitter.emitStopHookLoop) — so no + * new session-update kind is needed. + */ + private async emitHistoryGapNotice( + gap: HistoryGap, + timestamp?: string, + ): Promise { + await this.messageEmitter.emitAgentMessage( + formatHistoryGapNotice(gap), + timestamp, + ); + } + /** * Replays content from a message (user or assistant). * Handles text parts, thought parts, and function calls. diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2260023992..c1410bd362 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -18,6 +18,7 @@ import type { ToolCallConfirmationDetails, ToolResult, ChatRecord, + HistoryGap, AgentEventEmitter, StopHookOutput, HookExecutionRequest, @@ -1075,9 +1076,12 @@ export class Session implements SessionContext { ); } - async replayHistory(records: ChatRecord[]): Promise { + async replayHistory( + records: ChatRecord[], + gaps?: HistoryGap[], + ): Promise { this.primeTurnFromHistory(records); - await this.historyReplayer.replay(records); + await this.historyReplayer.replay(records, gaps); } rewindToTurn( diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 22ad72f628..82b538c19c 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2552,6 +2552,8 @@ export default { reqs: 'reqs', in: 'in', out: 'out', + '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': + '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.', // ============================================================================ // reload-plugins command diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 4f15dfbb5c..8d1bb7b212 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2145,6 +2145,8 @@ export default { ' (not in model registry)': '(不在模型註冊表中)', 'start server': '啟動伺服器', 'No compression needed.': '無需壓縮。', + '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': + '⚠️ 歷史記錄缺口:此處之前的會話記錄已遺失(儲存中斷),且無法找回。', // ============================================================================ // reload-plugins 命令 diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 6243ab5f1a..5c9c2b9ad3 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2347,6 +2347,8 @@ export default { '中国 (China) - 阿里云百炼': '中国 - 阿里云百炼', '阿里云百炼 (aliyun.com)': '阿里云百炼(aliyun.com)', 'No compression needed.': '无需压缩。', + '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': + '⚠️ 历史记录缺口:此处之前的会话记录已丢失(存储中断),且无法找回。', // ============================================================================ // reload-plugins 命令 diff --git a/packages/cli/src/ui/utils/history-gap-notice.ts b/packages/cli/src/ui/utils/history-gap-notice.ts new file mode 100644 index 0000000000..4f93537c63 --- /dev/null +++ b/packages/cli/src/ui/utils/history-gap-notice.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HistoryGap } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; + +/** + * Localized, user-facing notice for a detected history gap — an earlier segment + * of the session was physically lost (storage interruption) and could not be + * recovered. Shown at the top of the reachable history. Shared by the terminal + * `/resume` divider and the ACP replay notice so both surfaces read identically + * and go through the i18n path. + */ +export function formatHistoryGapNotice(_gap: HistoryGap): string { + return t( + '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.', + ); +} + +/** + * Indexes detected history gaps by the uuid of the child record each one + * precedes, so a replay/render loop can O(1) test whether a divider belongs + * before a given record. Shared by the terminal `/resume` builder and the ACP + * replayer so both surfaces key the divider off the same field. + */ +export function indexGapsByChild( + gaps: readonly HistoryGap[] | undefined, +): Map { + const byChild = new Map(); + for (const gap of gaps ?? []) { + byChild.set(gap.childUuid, gap); + } + return byChild; +} diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 02ea815ce3..057753b1b3 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -44,6 +44,80 @@ describe('resumeHistoryUtils', () => { } as unknown as AnyDeclarativeTool; }); + it('inserts a history-gap divider before the gap child record', () => { + // The gap child is the first reachable record; the notice sits above it and + // states the earlier history could not be recovered. + const conversation = { + messages: [ + { + type: 'user', + uuid: 'b1', + message: { parts: [{ text: 'first surviving turn' } as Part] }, + }, + ], + } as unknown as ConversationRecord; + + const session: ResumedSessionData = { + conversation, + historyGaps: [{ childUuid: 'b1', missingParentUuid: 'gone' }], + } as ResumedSessionData; + + const items = buildResumedHistoryItems(session, makeConfig({}), 1_000); + + expect(items).toHaveLength(2); + expect(items[0].type).toBe(MessageType.INFO); + // Test locale has no translations loaded → t() returns the English source. + const text = (items[0] as { text: string }).text; + expect(text).toContain('History gap'); + expect(text).toContain('could not be recovered'); + expect(items[1]).toMatchObject({ + type: 'user', + text: 'first surviving turn', + }); + }); + + it('does not pair a pre-gap @-command with the post-gap user turn', () => { + // Defense-in-depth: reconstructHistory truncates to the tail island, so a + // pre-gap at_command is normally never replayed (the gap child is the first + // record). But convertToHistoryItems must stay robust if a divider ever + // lands with an unconsumed at_command buffered — the post-gap user turn must + // NOT inherit the pre-gap @file reads. + const conversation = { + messages: [ + { + type: 'system', + subtype: 'at_command', + uuid: 'a1', + systemPayload: { + userText: 'pre-gap @old.ts summarize', + filesRead: ['/pre/old.ts'], + status: 'success', + }, + }, + { + type: 'user', + uuid: 'b1', + message: { parts: [{ text: 'post-gap message' } as Part] }, + }, + ], + } as unknown as ConversationRecord; + + const session: ResumedSessionData = { + conversation, + historyGaps: [{ childUuid: 'b1', missingParentUuid: 'gone' }], + } as ResumedSessionData; + + const items = buildResumedHistoryItems(session, makeConfig({}), 1_000); + + // Divider, then the post-gap user turn as authored — no @-command text + // leaked in, no file-read tool group synthesized. + const texts = items.map((i) => (i as { text?: string }).text ?? ''); + expect(texts.some((t) => t.includes('pre-gap @old.ts'))).toBe(false); + expect(items.some((i) => i.type === 'tool_group')).toBe(false); + const userItem = items.find((i) => i.type === 'user') as { text: string }; + expect(userItem.text).toBe('post-gap message'); + }); + it('converts conversation into history items with incremental ids', () => { const conversation = { messages: [ diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 733746f649..47c6ceb1b9 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -14,6 +14,7 @@ import type { ToolResultDisplay, SlashCommandRecordPayload, AtCommandRecordPayload, + HistoryGap, } from '@qwen-code/qwen-code-core'; import type { HistoryItem, @@ -23,6 +24,10 @@ import type { } from '../types.js'; import { ToolCallStatus, MessageType } from '../types.js'; import { t } from '../../i18n/index.js'; +import { + formatHistoryGapNotice, + indexGapsByChild, +} from './history-gap-notice.js'; /** * Extracts text content from a Content object's parts (excluding thought parts). @@ -138,6 +143,18 @@ function restoreHistoryItem(raw: unknown): HistoryItemWithoutId | undefined { return clone as unknown as HistoryItemWithoutId; } +/** + * INFO divider shown at a detected history gap: an earlier segment of the + * session was physically lost (storage interruption) and could not be + * recovered. Mirrors the ACP replay notice so both surfaces read the same. + */ +function createHistoryGapItem(gap: HistoryGap): HistoryItemInfo { + return { + type: MessageType.INFO, + text: formatHistoryGapNotice(gap), + }; +} + /** * Converts ChatRecord messages to UI history items for display. * @@ -151,8 +168,10 @@ function restoreHistoryItem(raw: unknown): HistoryItemWithoutId | undefined { function convertToHistoryItems( conversation: ConversationRecord, config: Config | null, + historyGaps?: HistoryGap[], ): HistoryItemWithoutId[] { const items: HistoryItemWithoutId[] = []; + const gapByChildUuid = indexGapsByChild(historyGaps); const pendingAtCommands: AtCommandRecordPayload[] = []; let atCommandCounter = 0; @@ -224,6 +243,27 @@ function convertToHistoryItems( }; for (const record of conversation.messages) { + // A detected history gap begins at this record — surface a visible divider + // so the surviving turns below are not read as contiguous across the lost + // segment. Flush any pending tool group first so the divider is not + // swallowed into it. + const gap = gapByChildUuid.get(record.uuid); + if (gap) { + if (currentToolGroup.length > 0) { + items.push({ type: 'tool_group', tools: [...currentToolGroup] }); + currentToolGroup = []; + } + // Reset pending @-command state at the boundary as well: the divider + // means the records below begin a fresh reachable island, so an + // unconsumed pre-gap at_command must never be shift()-paired with the + // post-gap user turn (which would attach @file reads to a turn the user + // never wrote them on). Today reconstructHistory truncates to the tail, + // so the buffer is already empty here; this keeps the invariant if that + // ever changes. + pendingAtCommands.length = 0; + items.push(createHistoryGapItem(gap)); + } + if (record.type === 'system') { if (record.subtype === 'slash_command') { // Flush any pending tool group to avoid mixing contexts. @@ -498,7 +538,11 @@ export function buildResumedHistoryItems( const getNextId = (): number => baseTimestamp + idCounter++; // Convert conversation directly to history items - const historyItems = convertToHistoryItems(sessionData.conversation, config); + const historyItems = convertToHistoryItems( + sessionData.conversation, + config, + sessionData.historyGaps, + ); for (const item of historyItems) { items.push({ ...item, diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 977d1cb08c..9edd0e08c4 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -25,6 +25,7 @@ import { attachJsonlTranscriptWriter, } from './agent-transcript.js'; import type { ChatRecord } from '../services/chatRecordingService.js'; +import { buildOrderedUuidChain } from '../utils/conversation-chain.js'; import { getInitialChatHistory } from '../utils/environmentContext.js'; import { getGitBranch } from '../utils/gitUtils.js'; import { PermissionMode, type StopHookOutput } from '../hooks/types.js'; @@ -211,29 +212,22 @@ function reconstructHistory( ): ChatRecord[] { if (records.length === 0) return []; - const recordsByUuid = new Map(); + // First record per uuid (preserves the historical `?.[0]` selection — this + // path does not aggregate duplicate-uuid records). + const firstByUuid = new Map(); for (const record of records) { - const existing = recordsByUuid.get(record.uuid) ?? []; - existing.push(record); - recordsByUuid.set(record.uuid, existing); + if (!firstByUuid.has(record.uuid)) firstByUuid.set(record.uuid, record); } - let currentUuid: string | null = - leafUuid ?? records[records.length - 1]!.uuid; - const uuidChain: string[] = []; - const visited = new Set(); + // Gap detection is intentionally OFF here. Unlike the interactive `/resume` + // (sessionService) and ACP replay (HistoryReplayer) paths, this background + // transcript recovery has no surface to render a gap marker on, so detecting + // gaps only to drop them would be an inconsistent half-measure. The walk + // truncates at a broken parent link either way; here it just truncates. + const { uuids } = buildOrderedUuidChain(records, { leafUuid }); - while (currentUuid && !visited.has(currentUuid)) { - visited.add(currentUuid); - uuidChain.push(currentUuid); - const recordsForUuid = recordsByUuid.get(currentUuid); - if (!recordsForUuid?.length) break; - currentUuid = recordsForUuid[0]!.parentUuid; - } - - uuidChain.reverse(); - return uuidChain - .map((uuid) => recordsByUuid.get(uuid)?.[0]) + return uuids + .map((uuid) => firstByUuid.get(uuid)) .filter((record): record is ChatRecord => !!record); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2fe91f5a6b..eb8b832850 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -229,6 +229,7 @@ export * from './services/visionBridge/image-part-utils.js'; export * from './services/visionBridge/image-capability.js'; export * from './services/sessionRecap.js'; export * from './services/sessionService.js'; +export * from './utils/conversation-chain.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/services/sessionService.corruption.test.ts b/packages/core/src/services/sessionService.corruption.test.ts index f1034af7d6..45ea362bfd 100644 --- a/packages/core/src/services/sessionService.corruption.test.ts +++ b/packages/core/src/services/sessionService.corruption.test.ts @@ -21,6 +21,7 @@ import path from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { SessionService } from './sessionService.js'; import type { ChatRecord } from './chatRecordingService.js'; +import type { HistoryGap } from '../utils/conversation-chain.js'; let tmpRoot: string; @@ -233,3 +234,50 @@ describe('SessionService.readLastRecordUuid (corruption recovery)', () => { expect(svc.readLastRecordUuid(file)).toBe('boundary-final'); }); }); + +describe('SessionService.reconstructHistory (history-gap detection)', () => { + // reconstructHistory is private; cast to reach it directly, matching the + // pattern above. Integration point under test: the sessionService delegate + // to buildOrderedUuidChain + aggregateRecords, plus the returned gaps. + type Privates = { + reconstructHistory: ( + records: ChatRecord[], + opts?: { leafUuid?: string; detectGaps?: boolean }, + ) => { messages: ChatRecord[]; gaps: HistoryGap[] }; + }; + let svc: Privates; + + beforeEach(() => { + svc = new SessionService('/tmp/x') as unknown as Privates; + }); + + // Two disconnected islands, the 965867 shape: island A (older) is a clean + // root chain; island B (newer) begins with a record whose parentUuid points + // at a record that is not in the file at all. + const twoIslands: ChatRecord[] = [ + recordFor('a1', 'user', null), + recordFor('a2', 'assistant', 'a1'), + recordFor('b1', 'user', 'missing-parent-uuid'), + recordFor('b2', 'assistant', 'b1'), + ]; + + it('reports the gap but does NOT reconstruct the earlier island (detectGaps on)', () => { + const { messages, gaps } = svc.reconstructHistory(twoIslands, { + detectGaps: true, + }); + // Only the reachable tail island — the earlier island is not stitched back. + expect(messages.map((m) => m.uuid)).toEqual(['b1', 'b2']); + expect(gaps).toEqual([ + { childUuid: 'b1', missingParentUuid: 'missing-parent-uuid' }, + ]); + // The gap child's parentUuid is left as-is (not rewritten to a guess). + const child = messages.find((m) => m.uuid === 'b1'); + expect(child?.parentUuid).toBe('missing-parent-uuid'); + }); + + it('preserves today truncation behavior when detectGaps is off', () => { + const { messages, gaps } = svc.reconstructHistory(twoIslands); + expect(messages.map((m) => m.uuid)).toEqual(['b1', 'b2']); + expect(gaps).toEqual([]); + }); +}); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index c230dde7b2..56a0aba3a5 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -12,6 +12,10 @@ import { randomUUID } from 'node:crypto'; import readline from 'node:readline'; import type { Content, Part } from '@google/genai'; import * as jsonl from '../utils/jsonl-utils.js'; +import { + buildOrderedUuidChain, + type HistoryGap, +} from '../utils/conversation-chain.js'; import type { ChatCompressionRecordPayload, ChatRecord, @@ -179,6 +183,13 @@ export interface ResumedSessionData { lastCompletedUuid: string | null; /** Deserialized file history snapshots for resume (enables /rewind across sessions) */ fileHistorySnapshots?: FileHistorySnapshot[]; + /** + * Breaks in the persisted parentUuid chain that were detected during + * reconstruction (an earlier segment of history was physically lost and could + * not be recovered). Lets the surface render a visible gap divider. Undefined + * when the chain was intact. + */ + historyGaps?: HistoryGap[]; } /** @@ -931,12 +942,19 @@ export class SessionService { /** * Reconstructs a linear conversation from tree-structured records. + * + * Delegates the parentUuid walk to {@link buildOrderedUuidChain}. With + * `detectGaps`, a walk that stops on a physically-missing parent records the + * break in the returned `gaps` (see {@link HistoryGap}) so the surface can + * mark it — the earlier records are NOT reconstructed (reconnecting them + * could resurrect turns the user rewound away). Without `detectGaps` the + * result is identical to the historical walk. */ private reconstructHistory( records: ChatRecord[], - leafUuid?: string, - ): ChatRecord[] { - if (records.length === 0) return []; + opts?: { leafUuid?: string; detectGaps?: boolean }, + ): { messages: ChatRecord[]; gaps: HistoryGap[] } { + if (records.length === 0) return { messages: [], gaps: [] }; const recordsByUuid = new Map(); for (const record of records) { @@ -945,29 +963,17 @@ export class SessionService { recordsByUuid.set(record.uuid, existing); } - let currentUuid: string | null = - leafUuid ?? records[records.length - 1].uuid; - const uuidChain: string[] = []; - const visited = new Set(); + const { uuids, gaps } = buildOrderedUuidChain(records, opts); - while (currentUuid && !visited.has(currentUuid)) { - visited.add(currentUuid); - uuidChain.push(currentUuid); - const recordsForUuid = recordsByUuid.get(currentUuid); - if (!recordsForUuid || recordsForUuid.length === 0) break; - currentUuid = recordsForUuid[0].parentUuid; - } - - uuidChain.reverse(); const messages: ChatRecord[] = []; - for (const uuid of uuidChain) { + for (const uuid of uuids) { const recordsForUuid = recordsByUuid.get(uuid); if (recordsForUuid && recordsForUuid.length > 0) { messages.push(this.aggregateRecords(recordsForUuid)); } } - return messages; + return { messages, gaps }; } /** @@ -1001,11 +1007,26 @@ export class SessionService { } // Reconstruct linear history - const messages = this.reconstructHistory(records); + const { messages, gaps } = this.reconstructHistory(records, { + detectGaps: true, + }); if (messages.length === 0) { return; } + if (gaps.length > 0) { + debugLogger.warn( + `loadSession: detected ${gaps.length} unrecoverable history gap(s) ` + + `for session ${sessionId}: ` + + gaps + .map( + (g) => + `child=${g.childUuid} missingParent=${g.missingParentUuid}`, + ) + .join('; '), + ); + } + const lastMessage = messages[messages.length - 1]; const stats = fs.statSync(filePath); @@ -1059,6 +1080,7 @@ export class SessionService { lastCompletedUuid: lastMessage.uuid, fileHistorySnapshots: cappedSnapshots.length > 0 ? cappedSnapshots : undefined, + historyGaps: gaps.length > 0 ? gaps : undefined, }; } @@ -1411,7 +1433,8 @@ export class SessionService { // Copy only the active branch. Rewind leaves old records in the JSONL as // abandoned parentUuid branches; copying raw records would resurrect them. - const sourceRecords = this.reconstructHistory(records); + // detectGaps stays off here so a fork copies exactly the active branch. + const { messages: sourceRecords } = this.reconstructHistory(records); if (sourceRecords.length === 0) { throw new Error(`Source session not found or empty: ${sourceSessionId}`); } diff --git a/packages/core/src/utils/conversation-chain.test.ts b/packages/core/src/utils/conversation-chain.test.ts new file mode 100644 index 0000000000..7da6afdc03 --- /dev/null +++ b/packages/core/src/utils/conversation-chain.test.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { buildOrderedUuidChain } from './conversation-chain.js'; +import type { ChatRecord } from '../services/chatRecordingService.js'; + +function rec(uuid: string, parentUuid: string | null): ChatRecord { + return { + uuid, + parentUuid, + sessionId: 's', + timestamp: '2026-01-01T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: uuid }] }, + cwd: '/tmp', + version: '0.0.0', + }; +} + +describe('buildOrderedUuidChain', () => { + it('returns [] for empty input', () => { + expect(buildOrderedUuidChain([])).toEqual({ uuids: [], gaps: [] }); + }); + + it('walks a clean chain identically regardless of detectGaps', () => { + const records = [rec('a1', null), rec('a2', 'a1'), rec('a3', 'a2')]; + const off = buildOrderedUuidChain(records, { detectGaps: false }); + const on = buildOrderedUuidChain(records, { detectGaps: true }); + + expect(off.uuids).toEqual(['a1', 'a2', 'a3']); + expect(off.gaps).toEqual([]); + expect(on.uuids).toEqual(['a1', 'a2', 'a3']); + expect(on.gaps).toEqual([]); + }); + + it('truncates at a missing parent and does NOT report a gap when detectGaps is off', () => { + const records = [ + rec('a1', null), + rec('a2', 'a1'), + rec('b1', 'MISSING'), + rec('b2', 'b1'), + ]; + const res = buildOrderedUuidChain(records, { detectGaps: false }); + // Walk from the last physical record (b2) stops at b1's missing parent. + expect(res.uuids).toEqual(['b1', 'b2']); + expect(res.gaps).toEqual([]); + }); + + it('records the gap but does NOT stitch the earlier island (detectGaps on)', () => { + const records = [ + rec('a1', null), + rec('a2', 'a1'), + rec('b1', 'MISSING'), + rec('b2', 'b1'), + ]; + const res = buildOrderedUuidChain(records, { detectGaps: true }); + + // Only the reachable tail island — the earlier island is NOT reconstructed. + expect(res.uuids).toEqual(['b1', 'b2']); + expect(res.uuids).not.toContain('a1'); + expect(res.uuids).not.toContain('a2'); + expect(res.gaps).toEqual([ + { childUuid: 'b1', missingParentUuid: 'MISSING' }, + ]); + }); + + it('does NOT restore a rewound-away branch when the rewind marker is missing', () => { + // wenshao's repro: a completed branch u1->a1->u2->a2, then a `rewind` record + // (dropped in the same write failure), then the post-rewind turn u3 whose + // parent is the missing rewind uuid. u3 is an ordinary record, so the old + // (stitching) behavior would bridge to a2 and resurrect the discarded + // branch. Detect-only must return just the post-rewind branch. + const records = [ + rec('u1', null), + rec('a1', 'u1'), + rec('u2', 'a1'), + rec('a2', 'u2'), + rec('u3', 'missing-rewind'), // leaf; parent = dropped rewind marker + ]; + const res = buildOrderedUuidChain(records, { detectGaps: true }); + + expect(res.uuids).toEqual(['u3']); + for (const discarded of ['u1', 'a1', 'u2', 'a2']) { + expect(res.uuids).not.toContain(discarded); + } + expect(res.gaps).toEqual([ + { childUuid: 'u3', missingParentUuid: 'missing-rewind' }, + ]); + }); + + it('returns a partial transcript on a parentUuid cycle without hanging', () => { + const records = [rec('a1', 'a2'), rec('a2', 'a1')]; + const res = buildOrderedUuidChain(records, { detectGaps: true }); + expect(res.uuids.length).toBeLessThanOrEqual(2); + }); + + it('honors an explicit leafUuid', () => { + const records = [rec('a1', null), rec('a2', 'a1'), rec('a3', 'a2')]; + const res = buildOrderedUuidChain(records, { leafUuid: 'a2' }); + expect(res.uuids).toEqual(['a1', 'a2']); + }); + + it('returns empty for a leafUuid not backed by any record', () => { + const records = [rec('a1', null), rec('a2', 'a1')]; + const res = buildOrderedUuidChain(records, { leafUuid: 'nope' }); + expect(res).toEqual({ uuids: [], gaps: [] }); + }); +}); diff --git a/packages/core/src/utils/conversation-chain.ts b/packages/core/src/utils/conversation-chain.ts new file mode 100644 index 0000000000..0132ef5083 --- /dev/null +++ b/packages/core/src/utils/conversation-chain.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChatRecord } from '../services/chatRecordingService.js'; + +/** + * A break in the persisted parentUuid chain: a record whose `parentUuid` is + * non-null but points at a uuid that does not exist anywhere in the file. + * + * This happens when a middle segment of the session log is physically lost + * (storage rollback, relocation, or a dropped write) while later turns keep + * referencing the now-missing tail. Walking from the newest leaf stops at the + * break, so everything before it is unreachable. + * + * The chain walk records the break here (with `detectGaps`) so the surface can + * render a visible "history incomplete" marker instead of silently truncating. + * It deliberately does NOT reconstruct the earlier records: read-side, a + * missing parent is indistinguishable from a lost `/rewind` marker (where the + * "earlier" turns are ones the user deliberately discarded), so stitching them + * back could resurrect deleted content. Safe recovery requires durable + * write-side metadata and is tracked separately. + */ +export interface HistoryGap { + /** The record whose parent was missing (the UI anchors the marker here). */ + childUuid: string; + /** The parentUuid value that could not be found in the file. */ + missingParentUuid: string; +} + +export interface OrderedChainResult { + /** Uuids in root→leaf order (the reachable tail island only). */ + uuids: string[]; + /** Detected chain breaks. Empty for healthy sessions. */ + gaps: HistoryGap[]; +} + +export interface BuildOrderedChainOptions { + /** Start the walk from this uuid instead of the last physical record. */ + leafUuid?: string; + /** + * When true, a walk that stops on a physically-missing parent records a + * {@link HistoryGap} so the surface can surface a marker. It does NOT stitch + * an earlier island back on — see the HistoryGap docstring for why that is + * unsafe read-side. Off by default so callers that want the raw active branch + * (e.g. fork) keep today's behavior exactly. + */ + detectGaps?: boolean; +} + +/** + * Linearizes tree-structured session records into an ordered uuid chain by + * walking `parentUuid` back from the newest leaf to a null root. + * + * On a genuinely missing parent the walk stops (as it always has). With + * `detectGaps` it additionally records the break so the caller can mark it; + * it never guesses an earlier island to reconnect. + */ +export function buildOrderedUuidChain( + records: ChatRecord[], + opts?: BuildOrderedChainOptions, +): OrderedChainResult { + if (records.length === 0) return { uuids: [], gaps: [] }; + + const detectGaps = opts?.detectGaps ?? false; + + // First record per uuid (matches the historical `recordsForUuid[0]` + // selection semantics). + const firstByUuid = new Map(); + for (const r of records) { + if (!firstByUuid.has(r.uuid)) firstByUuid.set(r.uuid, r); + } + + const uuids: string[] = []; + const gaps: HistoryGap[] = []; + const visited = new Set(); + + const startUuid = opts?.leafUuid ?? records[records.length - 1].uuid; + // A caller-supplied leafUuid not backed by any record yields no chain. + if (!firstByUuid.has(startUuid)) return { uuids: [], gaps: [] }; + let currentUuid: string | null = startUuid; + + while (currentUuid) { + if (visited.has(currentUuid)) break; // cycle guard (partial transcript) + visited.add(currentUuid); + uuids.push(currentUuid); + + const rec = firstByUuid.get(currentUuid); + if (!rec) break; + + const parent = rec.parentUuid; + if (!parent) break; // reached a real root + + if (firstByUuid.has(parent)) { + currentUuid = parent; // normal step + continue; + } + + // GAP: parent is set but physically missing. Record it (so the surface can + // mark the break) but never stitch across it — see HistoryGap docstring. + if (detectGaps) { + gaps.push({ childUuid: currentUuid, missingParentUuid: parent }); + } + break; + } + + uuids.reverse(); + return { uuids, gaps }; +}