diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 2efdc24eb3..9067828f2f 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -183,9 +183,9 @@ const resumeCheckpointCommand: SlashCommand = { const config = context.services.agentContext?.config; await logger.initialize(); const checkpoint = await logger.loadCheckpoint(tag); - const conversation = checkpoint.history; + const conversation = checkpoint.history ?? []; - if (conversation.length === 0) { + if (conversation.length === 0 && !checkpoint.messages) { return { type: 'message', messageType: 'info', @@ -233,6 +233,8 @@ const resumeCheckpointCommand: SlashCommand = { type: 'load_history', history: uiHistory, clientHistory: conversation, + messages: checkpoint.messages, + version: checkpoint.version, }; }, completion: async (context, partialArg) => { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6e880ed4bb..04ae67a758 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -549,7 +549,16 @@ export const useSlashCommandProcessor = ( } } case 'load_history': { - config?.getGeminiClient()?.setHistory(result.clientHistory); + const client = config?.getGeminiClient(); + if (result.version === '2.0' && client) { + await client.resumeChat( + [...result.clientHistory], + undefined, + result.messages, + ); + } else { + client?.setHistory(result.clientHistory); + } fullCommandContext.ui.clear(); result.history.forEach((item, index) => { fullCommandContext.ui.addItem(item, index); diff --git a/packages/core/src/commands/types.ts b/packages/core/src/commands/types.ts index 62bda279af..32dd76a3a9 100644 --- a/packages/core/src/commands/types.ts +++ b/packages/core/src/commands/types.ts @@ -5,6 +5,8 @@ */ import type { Content, PartListUnion } from '@google/genai'; +import type { MessageRecord } from '../services/chatRecordingTypes.js'; + /** * The return type for a command action that results in scheduling a tool call. */ @@ -37,6 +39,8 @@ export interface LoadHistoryActionReturn { type: 'load_history'; history: HistoryType; clientHistory: readonly Content[]; // The history for the generative client + messages?: MessageRecord[]; + version?: '2.0'; } /** diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index 53a0a10208..e84dcf9317 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -546,6 +546,33 @@ describe('Logger', () => { expect(loaded).toEqual({ history: conversation, messages: [] }); }); + it('should load a version 2.0 checkpoint and reconstruct history', async () => { + const tag = 'v2-tag'; + const v2Checkpoint = { + version: '2.0', + history: [{ role: 'user', parts: [{ text: 'preserved' }] }], + messages: [ + { + id: '1', + type: 'user', + content: 'hello', + timestamp: '2025-01-01T12:00:00.000Z', + }, + ], + }; + const taggedFilePath = path.join( + TEST_GEMINI_DIR, + 'checkpoint-v2-tag.json', + ); + await fs.writeFile(taggedFilePath, JSON.stringify(v2Checkpoint, null, 2)); + + const loaded = await logger.loadCheckpoint(tag); + expect(loaded.messages).toEqual(v2Checkpoint.messages); + expect(loaded.history).toEqual([ + { role: 'user', parts: [{ text: 'hello' }] }, + ]); + }); + it('should return an empty message list if a tagged checkpoint file does not exist', async () => { const loaded = await logger.loadCheckpoint('nonexistent-tag'); expect(loaded).toEqual({ history: [], messages: [] }); diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index feaf6c5a61..cc1e38b794 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -11,6 +11,7 @@ import type { AuthType } from './contentGenerator.js'; import type { Storage } from '../config/storage.js'; import { debugLogger } from '../utils/debugLogger.js'; import { coreEvents } from '../utils/events.js'; +import { reconstructHistory } from '../utils/history-reconstruction.js'; import { type ConversationRecord, @@ -32,7 +33,11 @@ export interface LogEntry { } export interface Checkpoint { - history: readonly Content[]; + /** + * The standard history array used for model requests. + * Only included in legacy checkpoints (pre-2.0). + */ + history?: readonly Content[]; authType?: AuthType; /** * The rich message records which are the source of truth for the session. @@ -372,34 +377,37 @@ export class Logger { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedContent = JSON.parse(fileContent); - // Handle legacy format (just an array of Content) if (Array.isArray(parsedContent)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return { history: parsedContent as Content[], messages: [] }; } - if ( - typeof parsedContent === 'object' && - parsedContent !== null && - 'history' in parsedContent - ) { + if (typeof parsedContent === 'object' && parsedContent !== null) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const checkpoint = parsedContent as Checkpoint; + const raw = parsedContent as Record; + if (raw['version'] === '2.0') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const msgs = (raw['messages'] as MessageRecord[]) ?? []; + return { + ...raw, + history: reconstructHistory(msgs), + messages: msgs, + }; + } return { - ...checkpoint, - messages: checkpoint.messages ?? [], + ...raw, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + history: (raw['history'] as Content[]) ?? [], + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + messages: (raw['messages'] as MessageRecord[]) ?? [], }; } - debugLogger.warn( - `Checkpoint file at ${path} has an unknown format. Returning empty checkpoint.`, - ); return { history: [], messages: [] }; } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT') { - // This is okay, it just means the checkpoint doesn't exist in either format. return { history: [], messages: [] }; } debugLogger.error(