diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 8bdc031b738..4e5c1bf4e51 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -193,6 +193,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_create', 'session_scope_override', 'session_load', + 'session_resume', 'unstable_session_resume', 'session_list', 'session_prompt', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index fff5a2d2b9b..b14e609f18d 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -6763,6 +6763,12 @@ class QwenAgent implements Agent { // at cold start. buildDisabledSkillNamesProvider(this.settings), ); + // ACP sessions run with piped stdio (non-TTY), so the default + // interactive-based gating disables file checkpointing. Enable it + // explicitly so /rewind works across daemon session resume. + if (typeof config.enableFileCheckpointing === 'function') { + config.enableFileCheckpointing(); + } // Inject the workspace-shared MCP transport pool BEFORE // `config.initialize()` so the ToolRegistry picks it up. if ( diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 52e8e85331b..4007e130899 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -307,6 +307,12 @@ describe('Session', () => { .fn() .mockReturnValue(mockBackgroundShellRegistry), getMonitorRegistry: vi.fn().mockReturnValue(mockMonitorRegistry), + getFileHistoryService: vi.fn().mockReturnValue({ + makeSnapshot: vi.fn().mockResolvedValue(undefined), + getSnapshots: vi.fn().mockReturnValue([]), + restoreFromSnapshots: vi.fn(), + rewind: vi.fn(), + }), } as unknown as Config; mockClient = { @@ -446,9 +452,11 @@ describe('Session', () => { expect(result).toEqual({ targetTurnIndex: 1, apiTruncateIndex: 2 }); expect(mockChat.truncateHistory).toHaveBeenCalledWith(2); expect(mockChat.stripThoughtsFromHistory).toHaveBeenCalled(); - expect(mockChatRecordingService.rewindRecording).toHaveBeenCalledWith(1, { - truncatedCount: 2, - }); + expect(mockChatRecordingService.rewindRecording).toHaveBeenCalledWith( + 1, + { truncatedCount: 2 }, + [], + ); }); it('preserves startup context when rewinding to the first user turn', () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index bb5c8562f3f..bd40620f8ac 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -614,9 +614,20 @@ export class Session implements SessionContext { chat.truncateHistory(apiTruncateIndex); chat.stripThoughtsFromHistory(); - this.config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { - truncatedCount: Math.max(0, apiHistory.length - apiTruncateIndex), - }); + const fileHistoryService = this.config.getFileHistoryService(); + const survivingSnapshots = fileHistoryService + .getSnapshots() + .slice(0, targetTurnIndex + 1); + + fileHistoryService.restoreFromSnapshots(survivingSnapshots); + + this.config + .getChatRecordingService() + ?.rewindRecording( + targetTurnIndex, + { truncatedCount: Math.max(0, apiHistory.length - apiTruncateIndex) }, + survivingSnapshots, + ); return { targetTurnIndex, apiTruncateIndex }; } @@ -1054,6 +1065,27 @@ export class Session implements SessionContext { } } + // Snapshot file state before this turn (mirrors the makeSnapshot + // block in GeminiClient.sendMessageStream). Placed after + // slash-command and hook early-returns so locally handled commands + // don't create phantom snapshots that desync the snapshot index. + try { + const fileHistoryService = this.config.getFileHistoryService(); + await fileHistoryService.makeSnapshot(promptId); + try { + const latestSnapshot = fileHistoryService.getSnapshots().at(-1); + if (latestSnapshot) { + this.config + .getChatRecordingService() + ?.recordFileHistorySnapshot(latestSnapshot); + } + } catch (e) { + debugLogger.error(`FileHistory: recordSnapshot failed: ${e}`); + } + } catch (e) { + debugLogger.error(`FileHistory: makeSnapshot failed: ${e}`); + } + // Prepend session-level system reminders (plan mode / subagent / // arena) so the model sees them, matching the behaviour of // `GeminiClient.sendMessageStream` in the CLI/TUI path. Without this, diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 6cc71501aab..13aafcdfffe 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -35,9 +35,9 @@ export const SERVE_CAPABILITY_REGISTRY = { session_create: { since: 'v1' }, session_scope_override: { since: 'v1' }, session_load: { since: 'v1' }, - // ACP backs this with `connection.unstable_resumeSession`. Surface - // the unstable prefix so clients don't pin against a `v1` shape that - // the underlying ACP method may still change. + session_resume: { since: 'v1' }, + // Deprecated alias — kept until @agentclientprotocol/sdk graduates + // the underlying ACP method from unstable_resumeSession to resumeSession. unstable_session_resume: { since: 'v1' }, session_list: { since: 'v1' }, session_prompt: { since: 'v1' }, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 60ea4d3690c..05edc44a7aa 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -107,6 +107,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_create', 'session_scope_override', 'session_load', + 'session_resume', 'unstable_session_resume', 'session_list', 'session_prompt', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4d347d187bf..b9a29a979b8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -441,7 +441,11 @@ export const AppContainer = (props: AppContainerProps) => { ); // Additional hooks moved from App.tsx - const { stats: sessionStats, startNewSession } = useSessionStats(); + const { + stats: sessionStats, + startNewSession, + seedPromptCount, + } = useSessionStats(); const logger = useLogger(config.storage, sessionStats.sessionId); const branchName = useGitBranchName(config.getTargetDir()); const worktreeSession = useWorktreeSession(config); @@ -549,6 +553,15 @@ export const AppContainer = (props: AppContainerProps) => { ); historyManager.loadHistory(historyItems); + // Seed the prompt counter from the resumed conversation so new + // promptIds don't collide with restored file history snapshots. + const userTurnCount = resumedSessionData.conversation.messages.filter( + (m) => m.type === 'user' && m.subtype !== 'mid_turn_user_message', + ).length; + if (userTurnCount > 0) { + seedPromptCount(userTurnCount); + } + // Re-arm any `/goal` that was active when the prior session ended. try { restoreGoalFromHistory(historyItems, config, historyManager.addItem); @@ -2622,9 +2635,16 @@ export const AppContainer = (props: AppContainerProps) => { Date.now(), ); - config.getChatRecordingService()?.rewindRecording(targetTurnIndex, { - truncatedCount: originalLength - truncatedUi.length, - }); + config.getChatRecordingService()?.rewindRecording( + targetTurnIndex, + { truncatedCount: originalLength - truncatedUi.length }, + !hasRestoreFailure + ? config + .getFileHistoryService() + .getSnapshots() + .slice(0, targetTurnIndex + 1) + : undefined, + ); } // Show file restore result after conversation truncation so the diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx index c348d5b4d29..8e20e535f73 100644 --- a/packages/cli/src/ui/contexts/SessionContext.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.tsx @@ -191,6 +191,7 @@ interface SessionStatsContextValue { startNewSession: (sessionId: string) => void; startNewPrompt: () => void; getPromptCount: () => number; + seedPromptCount: (count: number) => void; } // --- Context Definition --- @@ -271,14 +272,22 @@ export const SessionStatsProvider: React.FC<{ [stats.promptCount], ); + const seedPromptCount = useCallback((count: number) => { + setStats((prevState) => ({ + ...prevState, + promptCount: Math.max(prevState.promptCount, count), + })); + }, []); + const value = useMemo( () => ({ stats, startNewSession, startNewPrompt, getPromptCount, + seedPromptCount, }), - [stats, startNewSession, startNewPrompt, getPromptCount], + [stats, startNewSession, startNewPrompt, getPromptCount, seedPromptCount], ); return ( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 381b428ae78..5a8985358d6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1151,7 +1151,7 @@ export class Config { private fileDiscoveryService: FileDiscoveryService | null = null; private sessionService: SessionService | undefined = undefined; private chatRecordingService: ChatRecordingService | undefined = undefined; - private readonly fileCheckpointingEnabled: boolean; + private fileCheckpointingEnabled: boolean; private fileHistoryService: FileHistoryService | undefined; private readonly proxy: string | undefined; private readonly cwd: string; @@ -3604,6 +3604,11 @@ export class Config { return this.fileCheckpointingEnabled; } + enableFileCheckpointing(): void { + this.fileCheckpointingEnabled = true; + this.fileHistoryService = undefined; + } + getFileHistoryService(): FileHistoryService { if (!this.fileHistoryService) { this.fileHistoryService = new FileHistoryService( @@ -3611,6 +3616,15 @@ export class Config { this.fileCheckpointingEnabled, this.cwd, ); + const snapshots = this.sessionData?.fileHistorySnapshots; + if (snapshots?.length && this.fileHistoryService.isEnabled()) { + this.fileHistoryService.restoreFromSnapshots(snapshots); + void this.fileHistoryService.validateRestoredSnapshots().catch((e) => { + this.debugLogger.error( + `FileHistory: validateRestoredSnapshots failed: ${e}`, + ); + }); + } } return this.fileHistoryService; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index c68f963bd3e..e88ca938304 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1661,6 +1661,19 @@ export class GeminiClient { if (messageType === SendMessageType.UserQuery) { try { await this.config.getFileHistoryService().makeSnapshot(prompt_id); + try { + const latestSnapshot = this.config + .getFileHistoryService() + .getSnapshots() + .at(-1); + if (latestSnapshot) { + this.config + .getChatRecordingService() + ?.recordFileHistorySnapshot(latestSnapshot); + } + } catch (e) { + debugLogger.error(`FileHistory: recordSnapshot failed: ${e}`); + } } catch (e) { debugLogger.error(`FileHistory: makeSnapshot failed: ${e}`); } diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 22fc53e2b21..be94a709a23 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -28,6 +28,11 @@ import type { import type { Status } from '../core/coreToolScheduler.js'; import type { AgentResultDisplay, FileDiff } from '../tools/tools.js'; import type { UiEvent } from '../telemetry/uiTelemetry.js'; +import type { + FileHistorySnapshot, + SerializedFileHistorySnapshot, +} from './fileHistoryService.js'; +import { serializeSnapshot } from './fileHistoryService.js'; const debugLogger = createDebugLogger('CHAT_RECORDING'); @@ -235,7 +240,8 @@ export interface ChatRecord { | 'custom_title' | 'rewind' | 'agent_bootstrap' - | 'agent_launch_prompt'; + | 'agent_launch_prompt' + | 'file_history_snapshot'; /** Working directory at time of message */ cwd: string; /** CLI version for compatibility tracking */ @@ -281,7 +287,8 @@ export interface ChatRecord { | CustomTitleRecordPayload | NotificationRecordPayload | RewindRecordPayload - | AgentBootstrapRecordPayload; + | AgentBootstrapRecordPayload + | FileHistorySnapshotRecordPayload; /** Background subagent that produced this record (e.g. "explore-7f3c"). */ agentId?: string; @@ -428,6 +435,14 @@ export interface RewindRecordPayload { truncatedCount: number; } +/** + * Stored payload for file history snapshot persistence. + * Each entry records one or more snapshots for session resume. + */ +export interface FileHistorySnapshotRecordPayload { + snapshots: SerializedFileHistorySnapshot[]; +} + /** * Service for recording the current chat session to disk. * @@ -1140,7 +1155,11 @@ export class ChatRecordingService { * nothing before it), 1 means keep the first user turn, etc. * @param payload Additional metadata to persist with the rewind record. */ - rewindRecording(targetTurnIndex: number, payload: RewindRecordPayload): void { + rewindRecording( + targetTurnIndex: number, + payload: RewindRecordPayload, + survivingFileHistorySnapshots?: FileHistorySnapshot[], + ): void { try { // Re-root: point back to the record just before the target user turn. this.lastRecordUuid = this.turnParentUuids[targetTurnIndex] ?? null; @@ -1161,6 +1180,12 @@ export class ChatRecordingService { }; this.appendRecord(record); + + // Re-record surviving file history snapshots on the active branch so + // they are visible to reconstructHistory on resume. + if (survivingFileHistorySnapshots?.length) { + this.recordFileHistorySnapshotBatch(survivingFileHistorySnapshots); + } } catch (error) { debugLogger.error('Error saving rewind record:', error); } @@ -1346,4 +1371,23 @@ export class ChatRecordingService { debugLogger.error('Error saving attribution snapshot:', error); } } + + recordFileHistorySnapshot(snapshot: FileHistorySnapshot): void { + this.recordFileHistorySnapshotBatch([snapshot]); + } + + recordFileHistorySnapshotBatch(snapshots: FileHistorySnapshot[]): void { + if (snapshots.length === 0) return; + try { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'file_history_snapshot', + systemPayload: { snapshots: snapshots.map(serializeSnapshot) }, + }; + this.appendRecord(record); + } catch (error) { + debugLogger.error('Error saving file history snapshot batch:', error); + } + } } diff --git a/packages/core/src/services/fileHistoryService.ts b/packages/core/src/services/fileHistoryService.ts index 8c51388990b..efacb0a9812 100644 --- a/packages/core/src/services/fileHistoryService.ts +++ b/packages/core/src/services/fileHistoryService.ts @@ -98,8 +98,70 @@ export interface TurnDiff { }; } -const MAX_SNAPSHOTS = 100; +export const MAX_SNAPSHOTS = 100; export const FILE_HISTORY_DIR = 'file-history'; + +// --------------------------------------------------------------------------- +// Serialization types for JSONL persistence +// --------------------------------------------------------------------------- + +export interface SerializedFileHistorySnapshot { + promptId: string; + trackedFileBackups: Record; + timestamp: string; +} + +export interface SerializedFileHistoryBackup { + backupFileName: string | null; + version: number; + backupTime: string; + failed?: boolean; +} + +export function serializeSnapshot( + s: FileHistorySnapshot, +): SerializedFileHistorySnapshot { + return { + promptId: s.promptId, + timestamp: s.timestamp.toISOString(), + trackedFileBackups: Object.fromEntries( + Object.entries(s.trackedFileBackups).map(([path, backup]) => [ + path, + { + backupFileName: backup.backupFileName, + version: backup.version, + backupTime: backup.backupTime.toISOString(), + failed: backup.failed || undefined, + }, + ]), + ), + }; +} + +function safeParseDate(iso: string): Date { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? new Date(0) : d; +} + +export function deserializeSnapshots( + arr: SerializedFileHistorySnapshot[], +): FileHistorySnapshot[] { + return arr.map((s) => ({ + promptId: s.promptId, + timestamp: safeParseDate(s.timestamp), + trackedFileBackups: Object.fromEntries( + Object.entries(s.trackedFileBackups).map(([path, backup]) => [ + path, + { + backupFileName: backup.backupFileName, + version: backup.version, + backupTime: safeParseDate(backup.backupTime), + failed: backup.failed, + }, + ]), + ), + })); +} /** Per-turn read-fanout cap. Each candidate file may read up to two backups, * so 500 files ≈ 1000 concurrent opens — safely under the typical 4096 fd * ceiling and well below `ulimit -n` defaults on Linux/macOS. */ @@ -521,6 +583,52 @@ export class FileHistoryService { }; } + async validateRestoredSnapshots(): Promise { + // Collect unique backup file names to stat (dedup: many snapshots share + // the same backup file via the inheritance optimization in makeSnapshot). + const uniqueNames = new Set(); + for (const snapshot of this.state.snapshots) { + for (const backup of Object.values(snapshot.trackedFileBackups)) { + if (backup.backupFileName !== null && !backup.failed) { + uniqueNames.add(backup.backupFileName); + } + } + } + if (uniqueNames.size === 0) return; + + // Parallel stat with bounded concurrency to avoid fd exhaustion. + const BATCH_SIZE = 200; + const missing = new Set(); + const names = [...uniqueNames]; + for (let i = 0; i < names.length; i += BATCH_SIZE) { + const batch = names.slice(i, i + BATCH_SIZE); + const results = await Promise.all( + batch.map((name) => + pathExists(resolveBackupPath(name, this.sessionId)), + ), + ); + for (let j = 0; j < batch.length; j++) { + if (!results[j]) missing.add(batch[j]); + } + } + + if (missing.size === 0) return; + + // Single synchronous pass to mark failures — minimizes the mutation + // window so concurrent makeSnapshot/trackEdit see a consistent state. + for (const snapshot of this.state.snapshots) { + for (const backup of Object.values(snapshot.trackedFileBackups)) { + if (backup.backupFileName && missing.has(backup.backupFileName)) { + backup.failed = true; + } + } + } + + debugLogger.warn( + `FileHistory: ${missing.size} restored backup file(s) missing on disk`, + ); + } + async trackEdit(filePath: string): Promise { if (!this.enabled) return; diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index fa5ca8edad3..69f4b3276cc 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -15,9 +15,16 @@ import * as jsonl from '../utils/jsonl-utils.js'; import type { ChatCompressionRecordPayload, ChatRecord, + FileHistorySnapshotRecordPayload, TitleSource, UiTelemetryRecordPayload, } from './chatRecordingService.js'; +import type { FileHistorySnapshot } from './fileHistoryService.js'; +import { + deserializeSnapshots, + FILE_HISTORY_DIR, + MAX_SNAPSHOTS, +} from './fileHistoryService.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { @@ -129,6 +136,8 @@ export interface ResumedSessionData { filePath: string; /** UUID of the last completed message - new messages should use this as parentUuid */ lastCompletedUuid: string | null; + /** Deserialized file history snapshots for resume (enables /rewind across sessions) */ + fileHistorySnapshots?: FileHistorySnapshot[]; } /** @@ -157,6 +166,57 @@ const MAX_PROMPT_SCAN_LINES = 10; */ const TAIL_READ_SIZE = 64 * 1024; +async function copyFileHistoryBackups( + sourceSessionId: string, + targetSessionId: string, +): Promise { + const fsPromises = await import('node:fs/promises'); + const sourceDir = path.join( + Storage.getGlobalQwenDir(), + FILE_HISTORY_DIR, + sourceSessionId, + ); + const targetDir = path.join( + Storage.getGlobalQwenDir(), + FILE_HISTORY_DIR, + targetSessionId, + ); + + let entries: string[]; + try { + entries = await fsPromises.readdir(sourceDir); + } catch (e: unknown) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return; + debugLogger.warn(`copyFileHistoryBackups: readdir failed: ${e}`); + return; + } + if (entries.length === 0) return; + + try { + await fsPromises.mkdir(targetDir, { recursive: true }); + } catch (e) { + debugLogger.warn(`copyFileHistoryBackups: mkdir failed: ${e}`); + return; + } + await Promise.all( + entries.map(async (name) => { + const src = path.join(sourceDir, name); + const dst = path.join(targetDir, name); + try { + await fsPromises.link(src, dst); + } catch { + try { + await fsPromises.copyFile(src, dst); + } catch (copyErr) { + debugLogger.warn( + `copyFileHistoryBackups: failed to copy ${name}: ${copyErr}`, + ); + } + } + }), + ); +} + /** * Service for managing chat sessions. * @@ -716,10 +776,48 @@ export class SessionService { messages, }; + // Extract file history snapshots for /rewind across resume + const fileHistorySnapshots: FileHistorySnapshot[] = []; + const seenPromptIds = new Map(); + for (const msg of messages) { + if ( + msg.type === 'system' && + msg.subtype === 'file_history_snapshot' && + msg.systemPayload + ) { + const payload = msg.systemPayload as FileHistorySnapshotRecordPayload; + if (!Array.isArray(payload?.snapshots)) continue; + let deserialized: FileHistorySnapshot[]; + try { + deserialized = deserializeSnapshots(payload.snapshots); + } catch (e) { + debugLogger.warn( + `loadSession: skipping malformed file_history_snapshot: ${e}`, + ); + continue; + } + for (const s of deserialized) { + const existingIdx = seenPromptIds.get(s.promptId); + if (existingIdx !== undefined) { + fileHistorySnapshots[existingIdx] = s; + } else { + seenPromptIds.set(s.promptId, fileHistorySnapshots.length); + fileHistorySnapshots.push(s); + } + } + } + } + const cappedSnapshots = + fileHistorySnapshots.length > MAX_SNAPSHOTS + ? fileHistorySnapshots.slice(-MAX_SNAPSHOTS) + : fileHistorySnapshots; + return { conversation, filePath, lastCompletedUuid: lastMessage.uuid, + fileHistorySnapshots: + cappedSnapshots.length > 0 ? cappedSnapshots : undefined, }; } @@ -948,6 +1046,8 @@ export class SessionService { fs.closeSync(fd); } + await copyFileHistoryBackups(sourceSessionId, newSessionId); + return { filePath: targetPath, copiedCount: forked.length }; }