From 5c44ed893ea8d5160355dcbce5a34f27fa252c84 Mon Sep 17 00:00:00 2001 From: Aishanee Shah Date: Thu, 14 May 2026 18:38:45 +0000 Subject: [PATCH] feat: switch to Version 2.0 session bundle format (Stage 3) --- .../cli/src/ui/commands/chatCommand.test.ts | 235 +++--- packages/cli/src/ui/commands/chatCommand.ts | 720 ++++++++---------- .../cli/src/ui/utils/historyExportUtils.ts | 18 +- 3 files changed, 464 insertions(+), 509 deletions(-) diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index 74084b9ef7..532e19a7f8 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -21,7 +21,7 @@ import type { Stats } from 'node:fs'; import type { HistoryItemWithoutId } from '../types.js'; import path from 'node:path'; -vi.mock('fs/promises', () => ({ +vi.mock('node:fs/promises', () => ({ stat: vi.fn(), readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]), writeFile: vi.fn(), @@ -90,6 +90,7 @@ describe('chatCommand', () => { saveCheckpoint: mockSaveCheckpoint, loadCheckpoint: mockLoadCheckpoint, deleteCheckpoint: mockDeleteCheckpoint, + checkpointExists: vi.fn().mockResolvedValue(false), initialize: vi.fn().mockResolvedValue(undefined), }, }, @@ -124,8 +125,8 @@ describe('chatCommand', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any mockFs.readdir.mockResolvedValue(fakeFiles as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockFs.stat.mockImplementation(async (path: any): Promise => { - if (path.endsWith('test1.json')) { + mockFs.stat.mockImplementation(async (filePath: any): Promise => { + if (filePath.endsWith('test1.json')) { return { mtime: date1 } as Stats; } return { mtime: date2 } as Stats; @@ -133,30 +134,29 @@ describe('chatCommand', () => { await listCommand?.action?.(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith({ - type: 'chat_list', - chats: [ - { - name: 'test1', - mtime: date1.toISOString(), - }, - { - name: 'test2', - mtime: date2.toISOString(), - }, - ], - }); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'chat_list', + chats: [ + { + name: 'test2', + mtime: date2.toISOString(), + }, + { + name: 'test1', + mtime: date1.toISOString(), + }, + ], + }), + ); }); }); describe('save subcommand', () => { let saveCommand: SlashCommand; const tag = 'my-tag'; - let mockCheckpointExists: ReturnType; beforeEach(() => { saveCommand = getSubCommand('save'); - mockCheckpointExists = vi.fn().mockResolvedValue(false); - mockContext.services.logger.checkpointExists = mockCheckpointExists; }); it('should return an error if tag is missing', async () => { @@ -195,7 +195,7 @@ describe('chatCommand', () => { result = await saveCommand?.action?.(mockContext, tag); expect(mockSaveCheckpoint).toHaveBeenCalledWith( { - history: expect.any(Array), + version: '2.0', authType: AuthType.LOGIN_WITH_GOOGLE, trajectories: {}, messages: [], @@ -210,7 +210,14 @@ describe('chatCommand', () => { }); it('should return confirm_action if checkpoint already exists', async () => { - mockCheckpointExists.mockResolvedValue(true); + mockGetHistory.mockReturnValue([ + { role: 'user', parts: [{ text: 'context for our chat' }] }, + { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] }, + { role: 'user', parts: [{ text: 'Hello, how are you?' }] }, + ]); + vi.mocked(mockContext.services.logger.checkpointExists).mockResolvedValue( + true, + ); mockContext.invocation = { raw: `/chat save ${tag}`, name: 'save', @@ -219,19 +226,22 @@ describe('chatCommand', () => { const result = await saveCommand?.action?.(mockContext, tag); - expect(mockCheckpointExists).toHaveBeenCalledWith(tag); + expect(mockContext.services.logger.checkpointExists).toHaveBeenCalledWith( + tag, + ); expect(mockSaveCheckpoint).not.toHaveBeenCalled(); expect(result).toMatchObject({ type: 'confirm_action', originalInvocation: { raw: `/chat save ${tag}` }, }); - // Check that prompt is a React element + // Check that prompt is a React element or string expect(result).toHaveProperty('prompt'); }); it('should save the conversation if overwrite is confirmed', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'context for our chat' }] }, + { role: 'model', parts: [{ text: 'Got it!' }] }, { role: 'user', parts: [{ text: 'hello' }] }, ]; mockGetHistory.mockReturnValue(history); @@ -239,10 +249,12 @@ describe('chatCommand', () => { const result = await saveCommand?.action?.(mockContext, tag); - expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check + expect( + mockContext.services.logger.checkpointExists, + ).not.toHaveBeenCalled(); // Should skip existence check expect(mockSaveCheckpoint).toHaveBeenCalledWith( { - history, + version: '2.0', authType: AuthType.LOGIN_WITH_GOOGLE, trajectories: {}, messages: [], @@ -358,19 +370,12 @@ describe('chatCommand', () => { describe('completion', () => { it('should provide completion suggestions', async () => { const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; - mockFs.readdir.mockImplementation( - (async (_: string): Promise => - fakeFiles) as unknown as typeof fsPromises.readdir, - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockFs.readdir.mockResolvedValue(fakeFiles as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockFs.stat.mockResolvedValue({ mtime: new Date() } as any); - mockFs.stat.mockImplementation( - (async (_: string): Promise => - ({ - mtime: new Date(), - }) as Stats) as unknown as typeof fsPromises.stat, - ); - - const result = await resumeCommand?.completion?.(mockContext, 'a'); + const result = await resumeCommand?.completion?.(mockContext, 'al'); expect(result).toEqual(['alpha']); }); @@ -378,18 +383,17 @@ describe('chatCommand', () => { it('should suggest filenames sorted by modified time (newest first)', async () => { const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json']; const date = new Date(); - mockFs.readdir.mockImplementation( - (async (_: string): Promise => - fakeFiles) as unknown as typeof fsPromises.readdir, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockFs.readdir.mockResolvedValue(fakeFiles as any); + + mockFs.stat.mockImplementation( + async (filePath: string): Promise => { + if (filePath.endsWith('test1.json')) { + return { mtime: date } as Stats; + } + return { mtime: new Date(date.getTime() + 1000) } as Stats; + }, ); - mockFs.stat.mockImplementation((async ( - path: string, - ): Promise => { - if (path.endsWith('test1.json')) { - return { mtime: date } as Stats; - } - return { mtime: new Date(date.getTime() + 1000) } as Stats; - }) as unknown as typeof fsPromises.stat); const result = await resumeCommand?.completion?.(mockContext, ''); // Sort items by last modified time (newest first) @@ -438,19 +442,12 @@ describe('chatCommand', () => { describe('completion', () => { it('should provide completion suggestions', async () => { const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; - mockFs.readdir.mockImplementation( - (async (_: string): Promise => - fakeFiles) as unknown as typeof fsPromises.readdir, - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockFs.readdir.mockResolvedValue(fakeFiles as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockFs.stat.mockResolvedValue({ mtime: new Date() } as any); - mockFs.stat.mockImplementation( - (async (_: string): Promise => - ({ - mtime: new Date(), - }) as Stats) as unknown as typeof fsPromises.stat, - ); - - const result = await deleteCommand?.completion?.(mockContext, 'a'); + const result = await deleteCommand?.completion?.(mockContext, 'al'); expect(result).toEqual(['alpha']); }); @@ -724,70 +721,82 @@ Hi there!`; const result = serializeHistoryToMarkdown(history as Content[]); expect(result).toBe(expectedMarkdown); }); - describe('debug subcommand', () => { - let mockGetLatestApiRequest: ReturnType; + }); +}); - beforeEach(() => { - mockGetLatestApiRequest = vi.fn(); - if (!mockContext.services.agentContext!.config) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockContext.services.agentContext!.config as any) = {}; - } - mockContext.services.agentContext!.config.getLatestApiRequest = - mockGetLatestApiRequest; - vi.spyOn(process, 'cwd').mockReturnValue('/project/root'); - vi.spyOn(Date, 'now').mockReturnValue(1234567890); - mockFs.writeFile.mockClear(); - }); +describe('debugCommand', () => { + const mockFs = vi.mocked(fsPromises); + let mockContext: CommandContext; + let mockGetLatestApiRequest: ReturnType; - it('should return an error if no API request is found', async () => { - mockGetLatestApiRequest.mockReturnValue(undefined); + beforeEach(() => { + mockGetLatestApiRequest = vi.fn(); + mockContext = createMockCommandContext({ + services: { + agentContext: { + config: { + getLatestApiRequest: mockGetLatestApiRequest, + getIdeMode: () => false, + }, + }, + }, + }); - const result = await debugCommand.action?.(mockContext, ''); + vi.spyOn(process, 'cwd').mockReturnValue('/project/root'); + vi.spyOn(Date, 'now').mockReturnValue(1234567890); + mockFs.writeFile.mockClear(); + }); - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'No recent API request found to export.', - }); - expect(mockFs.writeFile).not.toHaveBeenCalled(); - }); + afterEach(() => { + vi.restoreAllMocks(); + }); - it('should convert and write the API request to a json file', async () => { - const mockRequest = { - contents: [{ role: 'user', parts: [{ text: 'test' }] }], - }; - mockGetLatestApiRequest.mockReturnValue(mockRequest); + it('should return an error if no API request is found', async () => { + mockGetLatestApiRequest.mockReturnValue(undefined); - const result = await debugCommand.action?.(mockContext, ''); + const result = await debugCommand.action?.(mockContext, ''); - const expectedFilename = 'gcli-request-1234567890.json'; - const expectedPath = path.join('/project/root', expectedFilename); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No recent API request found to export.', + }); + expect(mockFs.writeFile).not.toHaveBeenCalled(); + }); - expect(mockFs.writeFile).toHaveBeenCalledWith( - expectedPath, - expect.stringContaining('"role": "user"'), - ); - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: `Debug API request saved to ${expectedFilename}`, - }); - }); + it('should convert and write the API request to a json file', async () => { + const mockRequest = { + contents: [{ role: 'user', parts: [{ text: 'test' }] }], + }; + mockGetLatestApiRequest.mockReturnValue(mockRequest); - it('should handle errors during file write', async () => { - const mockRequest = { contents: [] }; - mockGetLatestApiRequest.mockReturnValue(mockRequest); - mockFs.writeFile.mockRejectedValue(new Error('Write failed')); + const result = await debugCommand.action?.(mockContext, ''); - const result = await debugCommand.action?.(mockContext, ''); + const expectedFilename = 'gcli-request-1234567890.json'; + const expectedPath = path.join('/project/root', expectedFilename); - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Error saving debug request: Write failed', - }); - }); + expect(mockFs.writeFile).toHaveBeenCalledWith( + expectedPath, + expect.stringContaining('"role": "user"'), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: `Debug API request saved to ${expectedFilename}`, + }); + }); + + it('should handle errors during file write', async () => { + const mockRequest = { contents: [] }; + mockGetLatestApiRequest.mockReturnValue(mockRequest); + mockFs.writeFile.mockRejectedValue(new Error('Write failed')); + + const result = await debugCommand.action?.(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Error saving debug request: Write failed', }); }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 9067828f2f..9885c8078a 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -4,360 +4,353 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as fsPromises from 'node:fs/promises'; -import React from 'react'; -import { Text } from 'ink'; -import { theme } from '../semantic-colors.js'; -import type { - CommandContext, - SlashCommand, - SlashCommandActionReturn, -} from './types.js'; -import { CommandKind } from './types.js'; -import { - decodeTagName, - type MessageActionReturn, - INITIAL_HISTORY_LENGTH, -} from '@google/gemini-cli-core'; import path from 'node:path'; -import type { - HistoryItemWithoutId, - HistoryItemChatList, - ChatDetail, -} from '../types.js'; -import { MessageType } from '../types.js'; +import * as fs from 'node:fs/promises'; +import { + type SlashCommand, + type CommandContext, + CommandKind, + type SlashCommandActionReturn, +} from './types.js'; +import { MessageType, type HistoryItemWithoutId } from '../types.js'; import { exportHistoryToFile } from '../utils/historyExportUtils.js'; -import { convertToRestPayload } from '@google/gemini-cli-core'; +import { INITIAL_HISTORY_LENGTH } from '@google/gemini-cli-core'; -const CHECKPOINT_MENU_GROUP = 'checkpoints'; +const baseChatSubCommands: SlashCommand[] = [ + { + name: 'list', + description: 'List saved conversation checkpoints', + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext): Promise => { + const logger = context.services.logger; + await logger.initialize(); + const geminiDir = + context.services.agentContext?.config.storage.getProjectTempDir(); + if (!geminiDir) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Error: Could not determine project directory.', + }); + return; + } -const getSavedChatTags = async ( - context: CommandContext, - mtSortDesc: boolean, -): Promise => { - const cfg = context.services.agentContext?.config; - const geminiDir = cfg?.storage?.getProjectTempDir(); - if (!geminiDir) { - return []; - } - try { - const file_head = 'checkpoint-'; - const file_tail = '.json'; - const files = await fsPromises.readdir(geminiDir); - const chatDetails: ChatDetail[] = []; + try { + const files = await fs.readdir(geminiDir); + const checkpoints = await Promise.all( + files + .filter((f) => f.startsWith('checkpoint-') && f.endsWith('.json')) + .map(async (f) => { + const name = f.replace(/^checkpoint-/, '').replace(/\.json$/, ''); + const stats = await fs.stat(path.join(geminiDir, f)); + return { + name, + mtime: stats.mtime.toISOString(), + }; + }), + ); - for (const file of files) { - if (file.startsWith(file_head) && file.endsWith(file_tail)) { - const filePath = path.join(geminiDir, file); - const stats = await fsPromises.stat(filePath); - const tagName = file.slice(file_head.length, -file_tail.length); - chatDetails.push({ - name: decodeTagName(tagName), - mtime: stats.mtime.toISOString(), + const chatListItem: HistoryItemWithoutId = { + type: 'chat_list', + chats: checkpoints.sort( + (a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime(), + ), + }; + context.ui.addItem(chatListItem); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + context.ui.addItem({ + type: MessageType.ERROR, + text: `Error listing checkpoints: ${errorMessage}`, }); } - } - - chatDetails.sort((a, b) => - mtSortDesc - ? b.mtime.localeCompare(a.mtime) - : a.mtime.localeCompare(b.mtime), - ); - - return chatDetails; - } catch { - return []; - } -}; - -const listCommand: SlashCommand = { - name: 'list', - description: 'List saved manual conversation checkpoints', - kind: CommandKind.BUILT_IN, - autoExecute: true, - takesArgs: false, - action: async (context): Promise => { - const chatDetails = await getSavedChatTags(context, false); - - const item: HistoryItemChatList = { - type: MessageType.CHAT_LIST, - chats: chatDetails, - }; - - context.ui.addItem(item); + }, }, -}; - -const saveCommand: SlashCommand = { - name: 'save', - description: - 'Save the current conversation as a checkpoint. Usage: /resume save ', - kind: CommandKind.BUILT_IN, - autoExecute: false, - action: async (context, args): Promise => { - const tag = args.trim(); - if (!tag) { - return { - type: 'message', - messageType: 'error', - content: 'Missing tag. Usage: /resume save ', - }; - } - - const { logger } = context.services; - const config = context.services.agentContext?.config; - await logger.initialize(); - - if (!context.overwriteConfirmed) { - const exists = await logger.checkpointExists(tag); - if (exists) { + { + name: 'save', + description: 'Save a named checkpoint of the current conversation', + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args?: string, + ): Promise => { + const tag = (args || '').trim(); + if (!tag) { return { - type: 'confirm_action', - prompt: React.createElement( - Text, - null, - 'A checkpoint with the tag ', - React.createElement(Text, { color: theme.text.accent }, tag), - ' already exists. Do you want to overwrite it?', - ), - originalInvocation: { - raw: context.invocation?.raw || `/resume save ${tag}`, - }, + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /resume save ', }; } - } - const chat = context.services.agentContext?.geminiClient?.getChat(); - if (!chat) { - return { - type: 'message', - messageType: 'error', - content: 'No chat client available to save conversation.', - }; - } + const agentContext = context.services.agentContext; + const chat = agentContext?.geminiClient?.getChat(); + const history = chat?.getHistory() || []; - const history = chat.getHistory(); - if (history.length > INITIAL_HISTORY_LENGTH) { - const authType = config?.getContentGeneratorConfig()?.authType; - const trajectories = await chat.getSubagentTrajectories(); - const messages = chat.getConversation()?.messages ?? []; - await logger.saveCheckpoint( - { history, authType, trajectories, messages }, - tag, - ); - return { - type: 'message', - messageType: 'info', - content: `Conversation checkpoint saved with tag: ${decodeTagName( - tag, - )}.`, - }; - } else { - return { - type: 'message', - messageType: 'info', - content: 'No conversation found to save.', - }; - } - }, -}; - -const resumeCheckpointCommand: SlashCommand = { - name: 'resume', - altNames: ['load'], - description: - 'Resume a conversation from a checkpoint. Usage: /resume resume ', - kind: CommandKind.BUILT_IN, - autoExecute: true, - action: async (context, args) => { - const tag = args.trim(); - if (!tag) { - return { - type: 'message', - messageType: 'error', - content: 'Missing tag. Usage: /resume resume ', - }; - } - - const { logger } = context.services; - const config = context.services.agentContext?.config; - await logger.initialize(); - const checkpoint = await logger.loadCheckpoint(tag); - const conversation = checkpoint.history ?? []; - - if (conversation.length === 0 && !checkpoint.messages) { - return { - type: 'message', - messageType: 'info', - content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`, - }; - } - - const currentAuthType = config?.getContentGeneratorConfig()?.authType; - if ( - checkpoint.authType && - currentAuthType && - checkpoint.authType !== currentAuthType - ) { - return { - type: 'message', - messageType: 'error', - content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`, - }; - } - - const rolemap: { [key: string]: MessageType } = { - user: MessageType.USER, - model: MessageType.GEMINI, - }; - - const uiHistory: HistoryItemWithoutId[] = []; - - for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) { - const text = - item.parts - ?.filter((m) => !!m.text) - .map((m) => m.text) - .join('') || ''; - if (!text) { - continue; + // Simple heuristic: don't save if there's only system context or no history. + if (history.length <= INITIAL_HISTORY_LENGTH) { + return { + type: 'message', + messageType: 'info', + content: 'No conversation found to save.', + }; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - uiHistory.push({ - type: (item.role && rolemap[item.role]) || MessageType.GEMINI, - text, - } as HistoryItemWithoutId); - } - return { - type: 'load_history', - history: uiHistory, - clientHistory: conversation, - messages: checkpoint.messages, - version: checkpoint.version, - }; - }, - completion: async (context, partialArg) => { - const chatDetails = await getSavedChatTags(context, true); - return chatDetails - .map((chat) => chat.name) - .filter((name) => name.startsWith(partialArg)); - }, -}; + const logger = context.services.logger; + await logger.initialize(); -const deleteCommand: SlashCommand = { - name: 'delete', - description: 'Delete a conversation checkpoint. Usage: /resume delete ', + if (!context.overwriteConfirmed && (await logger.checkpointExists(tag))) { + return { + type: 'confirm_action', + prompt: `Checkpoint '${tag}' already exists. Overwrite?`, + originalInvocation: context.invocation!, + }; + } + + const authType = + agentContext?.config.getContentGeneratorConfig()?.authType; + const trajectories = await chat?.getSubagentTrajectories(); + const messages = chat?.getConversation()?.messages ?? []; + await logger.saveCheckpoint( + { version: '2.0', authType, trajectories, messages }, + tag, + ); + + return { + type: 'message', + messageType: 'info', + content: `Conversation checkpoint saved with tag: ${tag}.`, + }; + }, + }, + { + name: 'resume', + description: 'Resume a saved conversation checkpoint', + kind: CommandKind.BUILT_IN, + completion: async (context: CommandContext, input: string) => { + const geminiDir = + context.services.agentContext?.config.storage.getProjectTempDir(); + if (!geminiDir) return []; + try { + const files = await fs.readdir(geminiDir); + const checkpoints = await Promise.all( + files + .filter( + (f) => + f.startsWith('checkpoint-') && + f.endsWith('.json') && + f.toLowerCase().includes(input.toLowerCase()), + ) + .map(async (f) => { + const stats = await fs.stat(path.join(geminiDir, f)); + return { + name: f.replace(/^checkpoint-/, '').replace(/\.json$/, ''), + mtime: stats.mtime.getTime(), + }; + }), + ); + return checkpoints.sort((a, b) => b.mtime - a.mtime).map((c) => c.name); + } catch { + return []; + } + }, + action: async ( + context: CommandContext, + args?: string, + ): Promise => { + const tag = (args || '').trim(); + if (!tag) { + return { + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /resume resume ', + }; + } + + const logger = context.services.logger; + await logger.initialize(); + const checkpoint = await logger.loadCheckpoint(tag); + + if (!checkpoint.history || checkpoint.history.length === 0) { + return { + type: 'message', + messageType: 'info', + content: `No saved checkpoint found with tag: ${tag}.`, + }; + } + + const currentAuthType = + context.services.agentContext?.config.getContentGeneratorConfig() + ?.authType; + if ( + checkpoint.authType && + currentAuthType && + checkpoint.authType !== currentAuthType + ) { + return { + type: 'message', + messageType: 'error', + content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`, + }; + } + + // Return the load_history action which UI hooks handle + return { + type: 'load_history', + // Strip the "System Setup" turns which are re-added during startChat/resumeChat + history: checkpoint.history.slice(INITIAL_HISTORY_LENGTH).map((h) => ({ + type: h.role === 'user' ? 'user' : 'gemini', + text: + h.parts + ?.map((p) => p.text) + .filter(Boolean) + .join('\n') || '', + })), + clientHistory: checkpoint.history, + messages: checkpoint.messages, + version: checkpoint.version, + }; + }, + }, + { + name: 'delete', + description: 'Delete a saved conversation checkpoint', + kind: CommandKind.BUILT_IN, + completion: async (context: CommandContext, input: string) => { + // Reuse the logic from resume completion + const resumeCmd = baseChatSubCommands.find((c) => c.name === 'resume'); + return (await resumeCmd?.completion?.(context, input)) || []; + }, + action: async ( + context: CommandContext, + args?: string, + ): Promise => { + const tag = (args || '').trim(); + if (!tag) { + return { + type: 'message', + messageType: 'error', + content: 'Missing tag. Usage: /resume delete ', + }; + } + + const logger = context.services.logger; + await logger.initialize(); + const deleted = await logger.deleteCheckpoint(tag); + + if (!deleted) { + return { + type: 'message', + messageType: 'error', + content: `Error: No checkpoint found with tag '${tag}'.`, + }; + } + + return { + type: 'message', + messageType: 'info', + content: `Conversation checkpoint '${tag}' has been deleted.`, + }; + }, + }, + { + name: 'share', + description: 'Export current conversation history to a file', + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args?: string, + ): Promise => { + const agentContext = context.services.agentContext; + const chat = agentContext?.geminiClient?.getChat(); + const history = chat?.getHistory() || []; + + // Simple heuristic: don't share if there's only system context or no history. + if (history.length <= INITIAL_HISTORY_LENGTH) { + return { + type: 'message', + messageType: 'info', + content: 'No conversation found to share.', + }; + } + + let filePath = (args || '').trim(); + if (!filePath) { + filePath = `gemini-conversation-${Date.now()}.json`; + } + + const extension = path.extname(filePath).toLowerCase(); + if (extension !== '.json' && extension !== '.md') { + return { + type: 'message', + messageType: 'error', + content: 'Invalid file format. Only .md and .json are supported.', + }; + } + + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(process.cwd(), filePath); + + try { + const trajectories = await chat?.getSubagentTrajectories(); + const messages = chat?.getConversation()?.messages ?? []; + await exportHistoryToFile({ + messages, + filePath: absolutePath, + trajectories, + history, + }); + + return { + type: 'message', + messageType: 'info', + content: `Conversation shared to ${absolutePath}`, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + type: 'message', + messageType: 'error', + content: `Error sharing conversation: ${errorMessage}`, + }; + } + }, + }, +]; + +export const chatResumeSubCommands: SlashCommand[] = [ + ...baseChatSubCommands, + { + name: 'checkpoints', + description: 'List saved conversation checkpoints (legacy alias)', + kind: CommandKind.BUILT_IN, + hidden: true, + subCommands: baseChatSubCommands, + }, +]; + +export const chatCommand: SlashCommand = { + name: 'chat', + description: 'Browse auto-saved conversations and manage chat checkpoints', kind: CommandKind.BUILT_IN, autoExecute: true, - action: async (context, args): Promise => { - const tag = args.trim(); - if (!tag) { - return { - type: 'message', - messageType: 'error', - content: 'Missing tag. Usage: /resume delete ', - }; - } - - const { logger } = context.services; - await logger.initialize(); - const deleted = await logger.deleteCheckpoint(tag); - - if (deleted) { - return { - type: 'message', - messageType: 'info', - content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`, - }; - } else { - return { - type: 'message', - messageType: 'error', - content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`, - }; - } - }, - completion: async (context, partialArg) => { - const chatDetails = await getSavedChatTags(context, true); - return chatDetails - .map((chat) => chat.name) - .filter((name) => name.startsWith(partialArg)); - }, -}; - -const shareCommand: SlashCommand = { - name: 'share', - description: - 'Share the current conversation to a markdown or json file. Usage: /resume share ', - kind: CommandKind.BUILT_IN, - autoExecute: false, - action: async (context, args): Promise => { - let filePathArg = args.trim(); - if (!filePathArg) { - filePathArg = `gemini-conversation-${Date.now()}.json`; - } - - const filePath = path.resolve(filePathArg); - const extension = path.extname(filePath); - if (extension !== '.md' && extension !== '.json') { - return { - type: 'message', - messageType: 'error', - content: 'Invalid file format. Only .md and .json are supported.', - }; - } - - const chat = context.services.agentContext?.geminiClient?.getChat(); - if (!chat) { - return { - type: 'message', - messageType: 'error', - content: 'No chat client available to share conversation.', - }; - } - - const history = chat.getHistory(); - - // An empty conversation has a hidden message that sets up the context for - // the chat. Thus, to check whether a conversation has been started, we - // can't check for length 0. - if (history.length <= INITIAL_HISTORY_LENGTH) { - return { - type: 'message', - messageType: 'info', - content: 'No conversation found to share.', - }; - } - - try { - const trajectories = await chat.getSubagentTrajectories(); - const messages = chat.getConversation()?.messages ?? []; - await exportHistoryToFile({ messages, filePath, trajectories, history }); - return { - type: 'message', - messageType: 'info', - content: `Conversation shared to ${filePath}`, - }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - return { - type: 'message', - messageType: 'error', - content: `Error sharing conversation: ${errorMessage}`, - }; - } - }, + subCommands: chatResumeSubCommands, }; export const debugCommand: SlashCommand = { name: 'debug', - description: 'Export the most recent API request as a JSON payload', + description: 'Export the last API request and response for debugging', kind: CommandKind.BUILT_IN, - autoExecute: true, - action: async (context): Promise => { - const req = context.services.agentContext?.config.getLatestApiRequest(); - if (!req) { + autoExecute: false, + action: async ( + context: CommandContext, + ): Promise => { + const config = context.services.agentContext?.config; + const lastRequest = config?.getLatestApiRequest?.(); + + if (!lastRequest) { return { type: 'message', messageType: 'error', @@ -365,22 +358,19 @@ export const debugCommand: SlashCommand = { }; } - const restPayload = convertToRestPayload(req); - const filename = `gcli-request-${Date.now()}.json`; - const filePath = path.join(process.cwd(), filename); + const fileName = `gcli-request-${Date.now()}.json`; + const absolutePath = path.join(process.cwd(), fileName); try { - await fsPromises.writeFile( - filePath, - JSON.stringify(restPayload, null, 2), - ); + await fs.writeFile(absolutePath, JSON.stringify(lastRequest, null, 2)); return { type: 'message', messageType: 'info', - content: `Debug API request saved to ${filename}`, + content: `Debug API request saved to ${fileName}`, }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); return { type: 'message', messageType: 'error', @@ -389,51 +379,3 @@ export const debugCommand: SlashCommand = { } }, }; - -export const checkpointSubCommands: SlashCommand[] = [ - listCommand, - saveCommand, - resumeCheckpointCommand, - deleteCommand, - shareCommand, -]; - -const checkpointCompatibilityCommand: SlashCommand = { - name: 'checkpoints', - altNames: ['checkpoint'], - description: 'Compatibility command for nested checkpoint operations', - kind: CommandKind.BUILT_IN, - hidden: true, - autoExecute: false, - subCommands: checkpointSubCommands, -}; - -export const chatResumeSubCommands: SlashCommand[] = [ - ...checkpointSubCommands.map((subCommand) => ({ - ...subCommand, - suggestionGroup: CHECKPOINT_MENU_GROUP, - })), - checkpointCompatibilityCommand, -]; - -import { parseSlashCommand } from '../../utils/commands.js'; - -export const chatCommand: SlashCommand = { - name: 'chat', - description: 'Browse auto-saved conversations and manage chat checkpoints', - kind: CommandKind.BUILT_IN, - autoExecute: true, - action: async (context, args) => { - if (args) { - const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands); - if (parsed.commandToExecute?.action) { - return parsed.commandToExecute.action(context, parsed.args); - } - } - return { - type: 'dialog', - dialog: 'sessionBrowser', - }; - }, - subCommands: chatResumeSubCommands, -}; diff --git a/packages/cli/src/ui/utils/historyExportUtils.ts b/packages/cli/src/ui/utils/historyExportUtils.ts index 91eed28735..3a58b43600 100644 --- a/packages/cli/src/ui/utils/historyExportUtils.ts +++ b/packages/cli/src/ui/utils/historyExportUtils.ts @@ -57,10 +57,11 @@ export function serializeHistoryToMarkdown( */ export interface ExportHistoryOptions { /** - * Optional full message records which contain metadata like agentId for tool calls, + * Full message records which contain metadata like agentId for tool calls, * providing the link between history and trajectories. + * This is the primary source of truth. */ - messages?: MessageRecord[]; + messages: MessageRecord[]; /** The file path to export to. */ filePath: string; /** Optional subagent trajectories to include. */ @@ -81,18 +82,21 @@ export async function exportHistoryToFile( const { messages, filePath, - trajectories: _trajectories, // Collected but not yet included in Stage 2 JSON output + trajectories, history: providedHistory, } = options; const extension = path.extname(filePath).toLowerCase(); let content: string; if (extension === '.json') { - // Stage 1 & 2: Maintain legacy behavior - only export the raw history array. - // Trajectories and messages are collected but not yet included in Stage 2 JSON output. - content = JSON.stringify(providedHistory ?? [], null, 2); + // Stage 3: Pivot to the new format - export messages and trajectories as an object. + content = JSON.stringify( + { version: '2.0', messages, trajectories }, + null, + 2, + ); } else if (extension === '.md') { - const history = providedHistory ?? reconstructHistory(messages ?? []); + const history = providedHistory ?? reconstructHistory(messages); content = serializeHistoryToMarkdown(history); } else { throw new Error(