fix(session): detect and mark broken history chains instead of silently truncating (#6502)

* fix(session): bridge broken parentUuid chains instead of truncating history

reconstructHistory walked parentUuid from the newest leaf and stopped at the first missing ancestor, silently dropping every earlier record. A session file with a broken chain (a partial write, or a lost middle segment) therefore lost all history before the break on resume — in both the terminal /resume and the web-shell/ACP replay, which both go through sessionService.loadSession.

Add a shared hardened chain walk (buildOrderedUuidChain) that, on a missing parent, bridges onto the newest still-present earlier connected component (union-find; position-based). It treats /rewind gap children as a barrier and matches the tail's sidechain-ness, so it never resurrects abandoned rewind branches or crosses the main/subagent boundary. sessionService and background-agent-resume now share it.

loadSession returns historyGaps metadata; the terminal /resume and ACP replay render a localized (i18n) visible divider so the recovered halves are not read as contiguous. The bridged child's parentUuid is rewritten (on the aggregated copy) to the bridged record so rebuildTurnBoundaries/rewind re-root correctly.

Read-side only; write-side durability is a separate follow-up.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* qwen: fix CI failure on PR #6502

* fix(session): use non-recovered copy for history gaps with no bridged island

When a missing-parent gap has no earlier island to bridge onto (bridgedToUuid is null), the divider is the first visible item — the previous copy still said "recovered earlier history is shown above" with nothing above it. Emit a distinct notice for the null-bridge case (both terminal /resume and ACP replay go through formatHistoryGapNotice).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(i18n): add zh-TW translation for the non-recovered history-gap notice

zh-TW is a strict-parity locale, so the new null-bridge notice key must be translated there too (pre-empts a CI strict-parity failure).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(session): address history-gap review nits (accuracy, lazy maps, docs, tests)

- conversation-chain: gap duration now uses the target island's last-occurrence timestamp; a uuid can span several streamed records, and the first occurrence overstated the gap.
- conversation-chain: build posByUuid/lastByUuid lazily on the first gap (healthy sessions skip them); early-return for a caller-supplied leafUuid not backed by any record.
- resumeHistoryUtils: reorder createHistoryGapItem so the convertToHistoryItems JSDoc documents its own function again.
- HistoryReplayer: add tests covering the gap-notice replay path (notice emitted before the gap child; none when there are no gaps).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(acp): thread historyGaps through the qwen/session/loadUpdates replay path

collectHistoryReplayUpdates now accepts gaps from both callers; the loadUpdates ACP surface passed only records, so a bridged (recovered) history was rendered contiguous there with no gap divider. Adds a loadUpdates test asserting the gaps reach the replayer.

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

* fix(session): detect-and-mark broken history chains instead of stitching

Read-side, a record whose parentUuid is physically missing is indistinguishable from a lost /rewind marker — where the "earlier" turns are ones the user deliberately discarded. Speculatively stitching the nearest earlier island back on (the previous approach) could therefore resurrect deleted content (wenshao's [Critical]). This mirrors claude-code, which never guesses: it only reconnects across a gap when durable metadata (snip removedUuids, compact re-root) proves it safe, and otherwise truncates.

Replace the connected-component bridging with detect-only: on a missing parent the walk stops (as it always did) and records a HistoryGap, so the terminal /resume and ACP replay surfaces show a visible "earlier history was lost and could not be recovered" marker instead of silently truncating. No earlier records are reconstructed. Renames the option bridgeGaps -> detectGaps, drops the bridgedToUuid/approxLostMs fields and the now-unused "recovered above" i18n copy, and adds a regression test where the rewind marker is missing and the discarded branch must not be restored.

True recovery (keeping the earlier island) requires durable write-side metadata and is left as a follow-up.

* refactor(session): correct stale gap comments and dedup gap indexing

The detect-only rewrite (13c613c9) left doc comments that still described
the removed stitching path — claiming the earlier history was "bridged" or
"stitched back on". Reword them to match the actual behavior: the break is
detected and marked, the lost segment is not recovered.

Also extract the duplicated gap-by-child map construction (identical in both
HistoryReplayer.replay and resumeHistoryUtils.convertToHistoryItems) into a
shared indexGapsByChild helper alongside formatHistoryGapNotice — the one
still-applicable item from the review suggestion summary.

Comments + one small refactor only; no behavior change.

* fix(session): reset pending @-command state at a history-gap divider

Belt-and-braces for the resume renderer: when convertToHistoryItems emits a
history-gap divider it already flushes the pending tool group; also clear
pendingAtCommands so an unconsumed pre-gap at_command can never be shift()-
paired with the post-gap user turn (which would attach @file reads to a turn
the user never wrote them on).

In the current detect-only design reconstructHistory truncates to the tail
island — the gap child is always the first replayed record, so the buffer is
already empty at the divider and this cannot trigger. The reset keeps the
invariant if that ever changes. Adds a regression test at the
convertToHistoryItems boundary.

* fix(session): don't detect history gaps on the background-agent resume path

Addresses wenshao's review: this non-interactive transcript recovery has no
surface to render a gap marker on (unlike interactive /resume via sessionService
and the ACP replay via HistoryReplayer), so passing detectGaps: true only to
emit a debugLogger.warn and then drop the gaps was an inconsistent half-measure
("half-detection is worse than no detection"). Turn detection off here — the
walk truncates at a broken parent link either way, matching this path's
historical behavior. Gap surfacing stays exactly on the two paths that have a
UI for it.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
jinye 2026-07-09 00:02:58 +08:00 committed by GitHub
parent 016d624021
commit 65c0d36be3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 621 additions and 48 deletions

View file

@ -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);

View file

@ -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) {

View file

@ -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);
});
});
});

View file

@ -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<void> {
async replay(records: ChatRecord[], gaps?: HistoryGap[]): Promise<void> {
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<void> {
await this.messageEmitter.emitAgentMessage(
formatHistoryGapNotice(gap),
timestamp,
);
}
/**
* Replays content from a message (user or assistant).
* Handles text parts, thought parts, and function calls.

View file

@ -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<void> {
async replayHistory(
records: ChatRecord[],
gaps?: HistoryGap[],
): Promise<void> {
this.primeTurnFromHistory(records);
await this.historyReplayer.replay(records);
await this.historyReplayer.replay(records, gaps);
}
rewindToTurn(

View file

@ -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

View file

@ -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 命令

View file

@ -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 命令

View file

@ -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<string, HistoryGap> {
const byChild = new Map<string, HistoryGap>();
for (const gap of gaps ?? []) {
byChild.set(gap.childUuid, gap);
}
return byChild;
}

View file

@ -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: [

View file

@ -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,

View file

@ -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<string, ChatRecord[]>();
// First record per uuid (preserves the historical `?.[0]` selection — this
// path does not aggregate duplicate-uuid records).
const firstByUuid = new Map<string, ChatRecord>();
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<string>();
// 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);
}

View file

@ -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.

View file

@ -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([]);
});
});

View file

@ -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<string, ChatRecord[]>();
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<string>();
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}`);
}

View file

@ -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: [] });
});
});

View file

@ -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<string, ChatRecord>();
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<string>();
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 };
}