From 36f58a34b411f746c2b01f4cb96b2a5fb4e1822e Mon Sep 17 00:00:00 2001 From: Seth Troisi Date: Tue, 10 Jun 2025 20:24:48 +0000 Subject: [PATCH] logConversation loadConversation /resume clean up for review --- .../cli/src/ui/hooks/slashCommandProcessor.ts | 77 ++++++++++++++++++- packages/cli/src/ui/types.ts | 2 +- packages/core/src/core/logger.test.ts | 67 +++++++++++++++- packages/core/src/core/logger.ts | 56 ++++++++++++++ 4 files changed, 197 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 9e82b6cf..c468a444 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -11,10 +11,11 @@ import process from 'node:process'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { Config, - MCPServerStatus, - getMCPServerStatus, - getMCPDiscoveryState, + Logger, MCPDiscoveryState, + MCPServerStatus, + getMCPDiscoveryState, + getMCPServerStatus, } from '@gemini-cli/core'; import { Message, MessageType, HistoryItemWithoutId } from '../types.js'; import { useSessionStats } from '../contexts/SessionContext.js'; @@ -487,6 +488,76 @@ Add any other context about the problem here. })(); }, }, + { + name: 'save', + description: 'save conversation checkpoint', + action: async (_mainCommand, _subCommand, _args) => { + const logger = new Logger(); + await logger.initialize(); + const chat = await config?.getGeminiClient()?.getChat(); + const history = chat?.getHistory() || []; + if (history.length > 0) { + logger.saveCheckpoint(chat?.getHistory() || []); + } else { + addMessage({ + type: MessageType.INFO, + content: 'No conversation found to save.', + timestamp: new Date(), + }); + return; + } + }, + }, + { + name: 'resume', + description: 'resume from last conversation checkpoint', + action: async (_mainCommand, _subCommand, _args) => { + const logger = new Logger(); + await logger.initialize(); + const conversation = await logger.loadCheckpoint(); + if (conversation.length === 0) { + addMessage({ + type: MessageType.INFO, + content: 'No saved conversation found.', + timestamp: new Date(), + }); + return; + } + const chat = await config?.getGeminiClient()?.getChat(); + clearItems(); + let i = 0; + const rolemap: { [key: string]: MessageType } = { + user: MessageType.USER, + model: MessageType.GEMINI, + }; + for (const item of conversation) { + i += 1; + const text = + item.parts + ?.filter((m) => !!m.text) + .map((m) => m.text) + .join('') || ''; + if (i <= 2) { + // Skip system prompt back and forth. + continue; + } + if (!text) { + // Parsing Part[] back to various non-text output not yet implemented. + continue; + } + addItem( + { + type: (item.role && rolemap[item.role]) || MessageType.GEMINI, + text, + } as HistoryItemWithoutId, + i, + ); + chat?.addHistory(item); + } + console.clear(); + refreshStatic(); + }, + }, { name: 'quit', altName: 'exit', diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 559a30a3..5fae1568 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -131,7 +131,7 @@ export enum MessageType { USER = 'user', ABOUT = 'about', STATS = 'stats', - // Add GEMINI if needed by other commands + GEMINI = 'gemini', } // Simplified message structure for internal feedback diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index 2663a6be..f1d0e4a5 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -16,10 +16,17 @@ import { import { Logger, MessageSenderType, LogEntry } from './logger.js'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { Content } from '@google/genai'; const GEMINI_DIR = '.gemini'; const LOG_FILE_NAME = 'logs.json'; +const CHECKPOINT_FILE_NAME = 'checkpoint.json'; const TEST_LOG_FILE_PATH = path.join(process.cwd(), GEMINI_DIR, LOG_FILE_NAME); +const TEST_CHECKPOINT_FILE_PATH = path.join( + process.cwd(), + GEMINI_DIR, + CHECKPOINT_FILE_NAME, +); async function cleanupLogFile() { try { @@ -29,11 +36,22 @@ async function cleanupLogFile() { // Other errors during unlink are ignored for cleanup purposes } } + try { + await fs.unlink(TEST_CHECKPOINT_FILE_PATH); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + // Other errors during unlink are ignored for cleanup purposes + } + } try { const geminiDirPath = path.join(process.cwd(), GEMINI_DIR); const dirContents = await fs.readdir(geminiDirPath); for (const file of dirContents) { - if (file.startsWith(LOG_FILE_NAME + '.') && file.endsWith('.bak')) { + if ( + (file.startsWith(LOG_FILE_NAME + '.') || + file.startsWith(CHECKPOINT_FILE_NAME + '.')) && + file.endsWith('.bak') + ) { try { await fs.unlink(path.join(geminiDirPath, file)); } catch (_e) { @@ -408,6 +426,53 @@ describe('Logger', () => { }); }); + describe('loadCheckpoint', () => { + it('should load and parse a valid checkpoint file', async () => { + const conversation: Content[] = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + ]; + await fs.writeFile( + TEST_CHECKPOINT_FILE_PATH, + JSON.stringify(conversation), + ); + const loadedCheckpoint = await logger.loadCheckpoint(); + expect(loadedCheckpoint).toEqual(conversation); + }); + + it('should return an empty array if the checkpoint file does not exist', async () => { + const loadedCheckpoint = await logger.loadCheckpoint(); + expect(loadedCheckpoint).toEqual([]); + }); + + it('should return an empty array if the file contains invalid JSON', async () => { + await fs.writeFile(TEST_CHECKPOINT_FILE_PATH, 'invalid json'); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const loadedCheckpoint = await logger.loadCheckpoint(); + expect(loadedCheckpoint).toEqual([]); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to read or parse checkpoint file'), + expect.any(SyntaxError), + ); + consoleErrorSpy.mockRestore(); + }); + + it('should return an empty array if logger is not initialized', async () => { + const uninitializedLogger = new Logger(); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const loadedCheckpoint = await uninitializedLogger.loadCheckpoint(); + expect(loadedCheckpoint).toEqual([]); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.', + ); + consoleErrorSpy.mockRestore(); + }); + }); + describe('close', () => { it('should reset logger state', async () => { await logger.logMessage(MessageSenderType.USER, 'A message'); diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index feb16944..9026dc36 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -6,9 +6,11 @@ import path from 'node:path'; import { promises as fs } from 'node:fs'; +import { Content } from '@google/genai'; const GEMINI_DIR = '.gemini'; const LOG_FILE_NAME = 'logs.json'; +const CHECKPOINT_FILE_NAME = 'checkpoint.json'; export enum MessageSenderType { USER = 'user', @@ -24,6 +26,7 @@ export interface LogEntry { export class Logger { private logFilePath: string | undefined; + private checkpointFilePath: string | undefined; private sessionId: number | undefined; private messageId = 0; // Instance-specific counter for the next messageId private initialized = false; @@ -92,6 +95,7 @@ export class Logger { this.sessionId = Math.floor(Date.now() / 1000); const geminiDir = path.resolve(process.cwd(), GEMINI_DIR); this.logFilePath = path.join(geminiDir, LOG_FILE_NAME); + this.checkpointFilePath = path.join(geminiDir, CHECKPOINT_FILE_NAME); try { await fs.mkdir(geminiDir, { recursive: true }); @@ -229,9 +233,61 @@ export class Logger { } } + async saveCheckpoint(conversation: Content[]): Promise { + if (!this.initialized || !this.checkpointFilePath) { + console.error( + 'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.', + ); + return; + } + + try { + await fs.writeFile( + this.checkpointFilePath, + JSON.stringify(conversation, null), + 'utf-8', + ); + } catch (error) { + console.error('Error writing to checkpoint file:', error); + } + } + + async loadCheckpoint(): Promise { + if (!this.initialized || !this.checkpointFilePath) { + console.error( + 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.', + ); + return []; + } + + try { + const fileContent = await fs.readFile(this.checkpointFilePath, 'utf-8'); + const parsedContent = JSON.parse(fileContent); + if (!Array.isArray(parsedContent)) { + console.warn( + `Checkpoint file at ${this.checkpointFilePath} is not a valid JSON array. Returning empty checkpoint.`, + ); + return []; + } + return parsedContent as Content[]; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + // File doesn't exist, which is fine. Return empty array. + return []; + } + console.error( + `Failed to read or parse checkpoint file ${this.checkpointFilePath}:`, + error, + ); + return []; + } + } + close(): void { this.initialized = false; this.logFilePath = undefined; + this.checkpointFilePath = undefined; this.logs = []; this.sessionId = undefined; this.messageId = 0;