From 8e0d5076d65b83a519775b42d83034b79b3452ac Mon Sep 17 00:00:00 2001 From: Seth Troisi Date: Tue, 10 Jun 2025 16:58:39 -0700 Subject: [PATCH] Add [tag] to /save and /resume (#916) --- .../cli/src/ui/hooks/slashCommandProcessor.ts | 23 +++-- packages/core/src/core/logger.test.ts | 93 +++++++++++++++++-- packages/core/src/core/logger.ts | 42 +++++---- 3 files changed, 122 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c468a444..4d677f1e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -490,35 +490,42 @@ Add any other context about the problem here. }, { name: 'save', - description: 'save conversation checkpoint', - action: async (_mainCommand, _subCommand, _args) => { + description: 'save conversation checkpoint. Usage: /save [tag]', + action: async (_mainCommand, subCommand, _args) => { + const tag = (subCommand || '').trim(); 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() || []); + await logger.saveCheckpoint(chat?.getHistory() || [], tag); + addMessage({ + type: MessageType.INFO, + content: `Conversation checkpoint saved${tag ? ' with tag: ' + tag : ''}.`, + timestamp: new Date(), + }); } 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) => { + description: + 'resume from conversation checkpoint. Usage: /resume [tag]', + action: async (_mainCommand, subCommand, _args) => { + const tag = (subCommand || '').trim(); const logger = new Logger(); await logger.initialize(); - const conversation = await logger.loadCheckpoint(); + const conversation = await logger.loadCheckpoint(tag); if (conversation.length === 0) { addMessage({ type: MessageType.INFO, - content: 'No saved conversation found.', + content: `No saved checkpoint found${tag ? ' with tag: ' + tag : ''}.`, timestamp: new Date(), }); return; diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index f1d0e4a5..72eb353e 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -426,23 +426,96 @@ describe('Logger', () => { }); }); + describe('saveCheckpoint', () => { + const conversation: Content[] = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + ]; + + it('should save a checkpoint to the default file when no tag is provided', async () => { + await logger.saveCheckpoint(conversation); + const fileContent = await fs.readFile(TEST_CHECKPOINT_FILE_PATH, 'utf-8'); + expect(JSON.parse(fileContent)).toEqual(conversation); + }); + + it('should save a checkpoint to a tagged file when a tag is provided', async () => { + const tag = 'my-test-tag'; + await logger.saveCheckpoint(conversation, tag); + const taggedFilePath = path.join( + process.cwd(), + GEMINI_DIR, + `${CHECKPOINT_FILE_NAME.replace('.json', '')}-${tag}.json`, + ); + const fileContent = await fs.readFile(taggedFilePath, 'utf-8'); + expect(JSON.parse(fileContent)).toEqual(conversation); + // cleanup + await fs.unlink(taggedFilePath); + }); + + it('should not throw if logger is not initialized', async () => { + const uninitializedLogger = new Logger(); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect( + uninitializedLogger.saveCheckpoint(conversation), + ).resolves.not.toThrow(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.', + ); + consoleErrorSpy.mockRestore(); + }); + }); + 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' }] }, - ]; + const conversation: Content[] = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + ]; + + beforeEach(async () => { + // Create a default checkpoint for some tests 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 load from the default checkpoint file when no tag is provided', async () => { + const loaded = await logger.loadCheckpoint(); + expect(loaded).toEqual(conversation); + }); + + it('should load from a tagged checkpoint file when a tag is provided', async () => { + const tag = 'my-load-tag'; + const taggedConversation = [ + ...conversation, + { role: 'user', parts: [{ text: 'Another message' }] }, + ]; + const taggedFilePath = path.join( + process.cwd(), + GEMINI_DIR, + `${CHECKPOINT_FILE_NAME.replace('.json', '')}-${tag}.json`, + ); + await fs.writeFile(taggedFilePath, JSON.stringify(taggedConversation)); + + const loaded = await logger.loadCheckpoint(tag); + expect(loaded).toEqual(taggedConversation); + + // cleanup + await fs.unlink(taggedFilePath); + }); + + it('should return an empty array if a tagged checkpoint file does not exist', async () => { + const loaded = await logger.loadCheckpoint('non-existent-tag'); + expect(loaded).toEqual([]); + }); + + it('should return an empty array if the default checkpoint file does not exist', async () => { + await fs.unlink(TEST_CHECKPOINT_FILE_PATH); // Ensure it's gone + const loaded = await logger.loadCheckpoint(); + expect(loaded).toEqual([]); }); it('should return an empty array if the file contains invalid JSON', async () => { diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index 9026dc36..ee4ce98c 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -25,6 +25,7 @@ export interface LogEntry { } export class Logger { + private geminiDir: string | undefined; private logFilePath: string | undefined; private checkpointFilePath: string | undefined; private sessionId: number | undefined; @@ -93,12 +94,12 @@ export class Logger { return; } 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); + this.geminiDir = path.resolve(process.cwd(), GEMINI_DIR); + this.logFilePath = path.join(this.geminiDir, LOG_FILE_NAME); + this.checkpointFilePath = path.join(this.geminiDir, CHECKPOINT_FILE_NAME); try { - await fs.mkdir(geminiDir, { recursive: true }); + await fs.mkdir(this.geminiDir, { recursive: true }); let fileExisted = true; try { await fs.access(this.logFilePath); @@ -233,26 +234,32 @@ export class Logger { } } - async saveCheckpoint(conversation: Content[]): Promise { + _checkpointPath(tag: string | undefined): string { + if (!this.checkpointFilePath || !this.geminiDir) { + throw new Error('Checkpoint file path not set.'); + } + if (!tag) { + return this.checkpointFilePath; + } + return path.join(this.geminiDir, `checkpoint-${tag}.json`); + } + + async saveCheckpoint(conversation: Content[], tag?: string): Promise { if (!this.initialized || !this.checkpointFilePath) { console.error( 'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.', ); return; } - + const path = this._checkpointPath(tag); try { - await fs.writeFile( - this.checkpointFilePath, - JSON.stringify(conversation, null), - 'utf-8', - ); + await fs.writeFile(path, JSON.stringify(conversation, null), 'utf-8'); } catch (error) { console.error('Error writing to checkpoint file:', error); } } - async loadCheckpoint(): Promise { + async loadCheckpoint(tag?: string): Promise { if (!this.initialized || !this.checkpointFilePath) { console.error( 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.', @@ -260,12 +267,14 @@ export class Logger { return []; } + const path = this._checkpointPath(tag); + try { - const fileContent = await fs.readFile(this.checkpointFilePath, 'utf-8'); + const fileContent = await fs.readFile(path, '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.`, + `Checkpoint file at ${path} is not a valid JSON array. Returning empty checkpoint.`, ); return []; } @@ -276,10 +285,7 @@ export class Logger { // File doesn't exist, which is fine. Return empty array. return []; } - console.error( - `Failed to read or parse checkpoint file ${this.checkpointFilePath}:`, - error, - ); + console.error(`Failed to read or parse checkpoint file ${path}:`, error); return []; } }