From ab50e3d62ec60b6e326cd812331c507aabae0c13 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Wed, 8 Oct 2025 08:13:16 -0600 Subject: [PATCH 01/11] feat(sessions): add resuming to geminiChat and add CLI flags for session management --- packages/cli/src/config/config.ts | 39 ++ packages/cli/src/gemini.test.tsx | 7 + packages/cli/src/gemini.tsx | 45 +- packages/cli/src/nonInteractiveCli.test.ts | 2 + packages/cli/src/nonInteractiveCli.ts | 18 +- .../src/ui/hooks/useSessionBrowser.test.ts | 591 ++++++++++++++++++ .../cli/src/ui/hooks/useSessionBrowser.ts | 178 ++++++ packages/cli/src/utils/sessionCleanup.test.ts | 76 +++ packages/cli/src/utils/sessionUtils.test.ts | 363 +++++++++++ packages/cli/src/utils/sessionUtils.ts | 177 +++++- packages/cli/src/utils/sessions.ts | 96 +++ packages/core/src/config/config.ts | 20 +- packages/core/src/core/client.ts | 18 +- packages/core/src/core/geminiChat.ts | 8 +- 14 files changed, 1629 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useSessionBrowser.test.ts create mode 100644 packages/cli/src/ui/hooks/useSessionBrowser.ts create mode 100644 packages/cli/src/utils/sessionUtils.test.ts create mode 100644 packages/cli/src/utils/sessions.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bda3b8a5b2a..8be14bbbdc6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -83,6 +83,9 @@ export interface CliArgs { extensions: string[] | undefined; listExtensions: boolean | undefined; proxy: string | undefined; + resume: string | 'latest' | undefined; + listSessions: boolean | undefined; + deleteSession: string | undefined; includeDirectories: string[] | undefined; screenReader: boolean | undefined; useSmartEdit: boolean | undefined; @@ -268,6 +271,35 @@ export async function parseArguments(settings: Settings): Promise { type: 'boolean', description: 'List all available extensions and exit.', }) + .option('resume', { + alias: 'r', + type: 'string', + // `skipValidation` so that we can distinguish between it being passed with a value, without + // one, and not being passed at all. + skipValidation: true, + description: + 'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)', + coerce: (value: string): string => { + // When --resume passed with a value (`gemini --resume 123`): value = "123" (string) + // When --resume passed without a value (`gemini --resume`): value = "" (string) + // When --resume not passed at all: this `coerce` function is not called at all, and + // `yargsInstance.argv.resume` is undefined. + if (value === '') { + return 'latest'; + } + return value; + }, + }) + .option('list-sessions', { + type: 'boolean', + description: + 'List available sessions for the current project and exit.', + }) + .option('delete-session', { + type: 'string', + description: + 'Delete a session by index number (use --list-sessions to see available sessions).', + }) .option('include-directories', { type: 'array', string: true, @@ -327,6 +359,11 @@ export async function parseArguments(settings: Settings): Promise { if (argv['prompt'] && argv['promptInteractive']) { return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together'; } + if (argv.resume && !argv.prompt && !process.stdin.isTTY) { + throw new Error( + 'When resuming a session, you must provide a message via --prompt (-p) or stdin', + ); + } if (argv.yolo && argv['approvalMode']) { return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.'; } @@ -727,6 +764,8 @@ export async function loadCliConfig( maxSessionTurns: settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, + listSessions: argv.listSessions || false, + deleteSession: argv.deleteSession, extensions: allExtensions, blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 4d6b894393e..62d27209465 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -151,6 +151,8 @@ describe('gemini.tsx main function', () => { getSandbox: () => false, getDebugMode: () => false, getListExtensions: () => false, + getListSessions: () => false, + getDeleteSession: () => undefined, getMcpServers: () => ({}), initialize: vi.fn(), getIdeMode: () => false, @@ -283,6 +285,8 @@ describe('gemini.tsx main function kitty protocol', () => { getSandbox: () => false, getDebugMode: () => false, getListExtensions: () => false, + getListSessions: () => false, + getDeleteSession: () => undefined, getMcpServers: () => ({}), initialize: vi.fn(), getIdeMode: () => false, @@ -329,6 +333,9 @@ describe('gemini.tsx main function kitty protocol', () => { screenReader: undefined, useSmartEdit: undefined, useWriteTodos: undefined, + resume: undefined, + listSessions: undefined, + deleteSession: undefined, outputFormat: undefined, }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 40813d73649..aebfb8c8d60 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -33,7 +33,7 @@ import { runExitCleanup, } from './utils/cleanup.js'; import { getCliVersion } from './utils/version.js'; -import type { Config } from '@google/gemini-cli-core'; +import type { Config, ResumedSessionData } from '@google/gemini-cli-core'; import { sessionId, logUserPrompt, @@ -53,6 +53,7 @@ import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.j import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; +import { SessionSelector } from './utils/sessionUtils.js'; import { computeWindowTitle } from './utils/windowTitle.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; @@ -65,6 +66,7 @@ import { relaunchOnExitCode, } from './utils/relaunch.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { deleteSession, listSessions } from './utils/sessions.js'; import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js'; export function validateDnsResolutionOrder( @@ -362,6 +364,19 @@ export async function main() { process.exit(0); } + // Handle --list-sessions flag + if (config.getListSessions()) { + await listSessions(config); + process.exit(0); + } + + // Handle --delete-session flag + const sessionToDelete = config.getDeleteSession(); + if (sessionToDelete) { + await deleteSession(config, sessionToDelete); + process.exit(0); + } + const wasRaw = process.stdin.isRaw; let kittyProtocolDetectionComplete: Promise | undefined; if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { @@ -404,6 +419,26 @@ export async function main() { ...(await getUserStartupWarnings()), ]; + // Handle --resume flag + let resumedSessionData: ResumedSessionData | undefined = undefined; + if (argv.resume) { + const sessionSelector = new SessionSelector(config); + try { + const result = await sessionSelector.resolveSession(argv.resume); + resumedSessionData = { + conversation: result.sessionData, + filePath: result.sessionPath, + }; + // Use the existing session ID to continue recording to the same session + config.setSessionId(resumedSessionData.conversation.sessionId); + } catch (error) { + console.error( + `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + process.exit(1); + } + } + // Render UI, passing necessary config values. Check that there is no command line question. if (config.isInteractive()) { // Need kitty detection to be complete before we can start the interactive UI. @@ -456,7 +491,13 @@ export async function main() { console.log('Session ID: %s', sessionId); } - await runNonInteractive(nonInteractiveConfig, settings, input, prompt_id); + await runNonInteractive( + nonInteractiveConfig, + settings, + input, + prompt_id, + resumedSessionData, + ); // Call cleanup before process.exit, which causes cleanup to not run await runExitCleanup(); process.exit(0); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 31414017215..ac6e8e35b76 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -67,6 +67,7 @@ describe('runNonInteractive', () => { let processStdoutSpy: vi.SpyInstance; let mockGeminiClient: { sendMessageStream: vi.Mock; + resumeChat: vi.Mock; getChatRecordingService: vi.Mock; }; @@ -93,6 +94,7 @@ describe('runNonInteractive', () => { mockGeminiClient = { sendMessageStream: vi.fn(), + resumeChat: vi.fn().mockResolvedValue(undefined), getChatRecordingService: vi.fn(() => ({ initialize: vi.fn(), recordMessage: vi.fn(), diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 9a5e5fad09e..08bde627d56 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config, ToolCallRequestInfo } from '@google/gemini-cli-core'; +import type { + Config, + ToolCallRequestInfo, + ResumedSessionData, +} from '@google/gemini-cli-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; import { @@ -21,6 +25,7 @@ import { import type { Content, Part } from '@google/genai'; +import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js'; import { handleSlashCommand } from './nonInteractiveCliCommands.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; @@ -36,6 +41,7 @@ export async function runNonInteractive( settings: LoadedSettings, input: string, prompt_id: string, + resumedSessionData?: ResumedSessionData, ): Promise { return promptIdContext.run(prompt_id, async () => { const consolePatcher = new ConsolePatcher({ @@ -55,6 +61,16 @@ export async function runNonInteractive( const geminiClient = config.getGeminiClient(); + // Initialize chat. Resume if resume data is passed. + if (resumedSessionData) { + await geminiClient.resumeChat( + convertSessionToHistoryFormats( + resumedSessionData.conversation.messages, + ).clientHistory, + resumedSessionData, + ); + } + const abortController = new AbortController(); let query: Part[] | undefined; diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts new file mode 100644 index 00000000000..6cf878dcd12 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -0,0 +1,591 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { convertSessionToHistoryFormats } from './useSessionBrowser.js'; +import { MessageType, ToolCallStatus } from '../types.js'; +import type { MessageRecord } from '@google/gemini-cli-core'; + +describe('convertSessionToHistoryFormats', () => { + it('should convert empty messages array', () => { + const result = convertSessionToHistoryFormats([]); + + expect(result.uiHistory).toEqual([]); + expect(result.clientHistory).toEqual([]); + }); + + it('should convert basic user and gemini messages', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Hello', + type: 'user', + }, + { + id: 'msg-2', + timestamp: '2025-01-01T00:02:00Z', + content: 'Hi there!', + type: 'gemini', + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(2); + expect(result.uiHistory[0]).toEqual({ + type: MessageType.USER, + text: 'Hello', + }); + expect(result.uiHistory[1]).toEqual({ + type: MessageType.GEMINI, + text: 'Hi there!', + }); + + expect(result.clientHistory).toHaveLength(2); + expect(result.clientHistory[0]).toEqual({ + role: 'user', + parts: [{ text: 'Hello' }], + }); + expect(result.clientHistory[1]).toEqual({ + role: 'model', + parts: [{ text: 'Hi there!' }], + }); + }); + + it('should filter out slash commands from client history', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: '/help', + type: 'user', + }, + { + id: 'msg-2', + timestamp: '2025-01-01T00:02:00Z', + content: '?quit', + type: 'user', + }, + { + id: 'msg-3', + timestamp: '2025-01-01T00:03:00Z', + content: 'Regular message', + type: 'user', + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + // All messages should appear in UI history + expect(result.uiHistory).toHaveLength(3); + + // Only non-slash commands should appear in client history + expect(result.clientHistory).toHaveLength(1); + expect(result.clientHistory[0]).toEqual({ + role: 'user', + parts: [{ text: 'Regular message' }], + }); + }); + + it('should handle tool calls correctly', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: "I'll help you with that.", + type: 'gemini', + toolCalls: [ + { + id: 'tool-1', + name: 'bash', + displayName: 'Execute Command', + description: 'Run bash command', + args: { command: 'ls -la' }, + status: 'success', + timestamp: '2025-01-01T00:01:30Z', + resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .', + renderOutputAsMarkdown: false, + }, + { + id: 'tool-2', + name: 'read', + displayName: 'Read File', + description: 'Read file contents', + args: { path: '/etc/hosts' }, + status: 'error', + timestamp: '2025-01-01T00:01:45Z', + resultDisplay: 'Permission denied', + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(2); // text message + tool group + expect(result.uiHistory[0]).toEqual({ + type: MessageType.GEMINI, + text: "I'll help you with that.", + }); + + expect(result.uiHistory[1].type).toBe('tool_group'); + // This if-statement is only necessary because TypeScript can't tell that the toBe() assertion + // protects the .tools access below. + if (result.uiHistory[1].type === 'tool_group') { + expect(result.uiHistory[1].tools).toHaveLength(2); + expect(result.uiHistory[1].tools[0]).toEqual({ + callId: 'tool-1', + name: 'Execute Command', + description: 'Run bash command', + renderOutputAsMarkdown: false, + status: ToolCallStatus.Success, + resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .', + confirmationDetails: undefined, + }); + expect(result.uiHistory[1].tools[1]).toEqual({ + callId: 'tool-2', + name: 'Read File', + description: 'Read file contents', + renderOutputAsMarkdown: true, // default value + status: ToolCallStatus.Error, + resultDisplay: 'Permission denied', + confirmationDetails: undefined, + }); + } + }); + + it('should skip empty tool calls arrays', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Message with empty tools', + type: 'gemini', + toolCalls: [], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(1); // Only text message + expect(result.uiHistory[0]).toEqual({ + type: MessageType.GEMINI, + text: 'Message with empty tools', + }); + }); + + it('should not add tool calls for user messages', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'User message', + type: 'user', + // This would be invalid in real usage, but testing robustness + toolCalls: [ + { + id: 'tool-1', + name: 'invalid', + args: {}, + status: 'success', + timestamp: '2025-01-01T00:01:30Z', + }, + ], + } as MessageRecord, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(1); // Only user message, no tool group + expect(result.uiHistory[0]).toEqual({ + type: MessageType.USER, + text: 'User message', + }); + }); + + it('should handle missing tool call fields gracefully', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Message with minimal tool', + type: 'gemini', + toolCalls: [ + { + id: 'tool-1', + name: 'minimal_tool', + args: {}, + status: 'success', + timestamp: '2025-01-01T00:01:30Z', + // Missing optional fields + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(2); + expect(result.uiHistory[1].type).toBe('tool_group'); + if (result.uiHistory[1].type === 'tool_group') { + expect(result.uiHistory[1].tools[0]).toEqual({ + callId: 'tool-1', + name: 'minimal_tool', // Falls back to name when displayName missing + description: '', // Default empty string + renderOutputAsMarkdown: true, // Default value + status: ToolCallStatus.Success, + resultDisplay: undefined, + confirmationDetails: undefined, + }); + } else { + throw new Error('unreachable'); + } + }); + + describe('tool calls in client history', () => { + it('should convert tool calls to correct Gemini client history format', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'List files', + type: 'user', + }, + { + id: 'msg-2', + timestamp: '2025-01-01T00:02:00Z', + content: "I'll list the files for you.", + type: 'gemini', + toolCalls: [ + { + id: 'tool-1', + name: 'list_directory', + args: { path: '/home/user' }, + result: { + functionResponse: { + id: 'list_directory-1753650620141-f3b8b9e73919d', + name: 'list_directory', + response: { + output: 'file1.txt\nfile2.txt', + }, + }, + }, + status: 'success', + timestamp: '2025-01-01T00:02:30Z', + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + // Should have: user message, model with function call, user with function response + expect(result.clientHistory).toHaveLength(3); + + // User message + expect(result.clientHistory[0]).toEqual({ + role: 'user', + parts: [{ text: 'List files' }], + }); + + // Model message with function call + expect(result.clientHistory[1]).toEqual({ + role: 'model', + parts: [ + { text: "I'll list the files for you." }, + { + functionCall: { + name: 'list_directory', + args: { path: '/home/user' }, + id: 'tool-1', + }, + }, + ], + }); + + // Function response + expect(result.clientHistory[2]).toEqual({ + role: 'user', + parts: [ + { + functionResponse: { + id: 'list_directory-1753650620141-f3b8b9e73919d', + name: 'list_directory', + response: { output: 'file1.txt\nfile2.txt' }, + }, + }, + ], + }); + }); + + it('should handle tool calls without text content', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: '', + type: 'gemini', + toolCalls: [ + { + id: 'tool-1', + name: 'bash', + args: { command: 'ls' }, + result: 'file1.txt\nfile2.txt', + status: 'success', + timestamp: '2025-01-01T00:01:30Z', + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.clientHistory).toHaveLength(2); + + // Model message with only function call (no text) + expect(result.clientHistory[0]).toEqual({ + role: 'model', + parts: [ + { + functionCall: { + name: 'bash', + args: { command: 'ls' }, + id: 'tool-1', + }, + }, + ], + }); + + // Function response + expect(result.clientHistory[1]).toEqual({ + role: 'user', + parts: [ + { + functionResponse: { + id: 'tool-1', + name: 'bash', + response: { + output: 'file1.txt\nfile2.txt', + }, + }, + }, + ], + }); + }); + + it('should handle multiple tool calls in one message', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Running multiple commands', + type: 'gemini', + toolCalls: [ + { + id: 'tool-1', + name: 'bash', + args: { command: 'pwd' }, + result: '/home/user', + status: 'success', + timestamp: '2025-01-01T00:01:30Z', + }, + { + id: 'tool-2', + name: 'bash', + args: { command: 'ls' }, + result: [ + { + functionResponse: { + id: 'tool-2', + name: 'bash', + response: { + output: 'file1.txt', + }, + }, + }, + { + functionResponse: { + id: 'tool-2', + name: 'bash', + response: { + output: 'file2.txt', + }, + }, + }, + ], + status: 'success', + timestamp: '2025-01-01T00:01:35Z', + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + // Should have: model with both function calls, then one response + expect(result.clientHistory).toHaveLength(2); + + // Model message with both function calls + expect(result.clientHistory[0]).toEqual({ + role: 'model', + parts: [ + { text: 'Running multiple commands' }, + { + functionCall: { + name: 'bash', + args: { command: 'pwd' }, + id: 'tool-1', + }, + }, + { + functionCall: { + name: 'bash', + args: { command: 'ls' }, + id: 'tool-2', + }, + }, + ], + }); + + // First function response + expect(result.clientHistory[1]).toEqual({ + role: 'user', + parts: [ + { + functionResponse: { + id: 'tool-1', + name: 'bash', + response: { output: '/home/user' }, + }, + }, + { + functionResponse: { + id: 'tool-2', + name: 'bash', + response: { output: 'file1.txt' }, + }, + }, + { + functionResponse: { + id: 'tool-2', + name: 'bash', + response: { output: 'file2.txt' }, + }, + }, + ], + }); + }); + + it('should handle Part array results from tools', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Reading file', + type: 'gemini', + toolCalls: [ + { + id: 'tool-1', + name: 'read_file', + args: { path: 'test.txt' }, + result: [ + { + functionResponse: { + id: 'tool-1', + name: 'read_file', + response: { + output: 'Hello', + }, + }, + }, + { + functionResponse: { + id: 'tool-1', + name: 'read_file', + response: { + output: ' World', + }, + }, + }, + ], + status: 'success', + timestamp: '2025-01-01T00:01:30Z', + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.clientHistory).toHaveLength(2); + + // Function response should extract both function responses + expect(result.clientHistory[1]).toEqual({ + role: 'user', + parts: [ + { + functionResponse: { + id: 'tool-1', + name: 'read_file', + response: { + output: 'Hello', + }, + }, + }, + { + functionResponse: { + id: 'tool-1', + name: 'read_file', + response: { + output: ' World', + }, + }, + }, + ], + }); + }); + + it('should skip tool calls without results', () => { + const messages: MessageRecord[] = [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Testing tool', + type: 'gemini', + toolCalls: [ + { + id: 'tool-1', + name: 'test_tool', + args: { arg: 'value' }, + // No result field + status: 'error', + timestamp: '2025-01-01T00:01:30Z', + }, + ], + }, + ]; + + const result = convertSessionToHistoryFormats(messages); + + // Should only have the model message with function call, no function response + expect(result.clientHistory).toHaveLength(1); + + expect(result.clientHistory[0]).toEqual({ + role: 'model', + parts: [ + { text: 'Testing tool' }, + { + functionCall: { + name: 'test_tool', + args: { arg: 'value' }, + id: 'tool-1', + }, + }, + ], + }); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts new file mode 100644 index 00000000000..9f159aac4dd --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HistoryItemWithoutId } from '../types.js'; +import type { ConversationRecord } from '@google/gemini-cli-core'; +import type { Part } from '@google/genai'; +import { partListUnionToString } from '@google/gemini-cli-core'; +import { MessageType, ToolCallStatus } from '../types.js'; +/** + * Converts session/conversation data into UI history and Gemini client history formats. + */ +export function convertSessionToHistoryFormats( + messages: ConversationRecord['messages'], +): { + uiHistory: HistoryItemWithoutId[]; + clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>; +} { + const uiHistory: HistoryItemWithoutId[] = []; + + for (const msg of messages) { + // Add the message only if it has content + const contentString = partListUnionToString(msg.content); + if (msg.content && contentString.trim()) { + let messageType: MessageType; + switch (msg.type) { + case 'user': + messageType = MessageType.USER; + break; + default: + messageType = MessageType.GEMINI; + break; + } + + uiHistory.push({ + type: messageType, + text: contentString, + }); + } + + // Add tool calls if present + if ( + msg.type !== 'user' && + 'toolCalls' in msg && + msg.toolCalls && + msg.toolCalls.length > 0 + ) { + uiHistory.push({ + type: 'tool_group', + tools: msg.toolCalls.map((tool) => ({ + callId: tool.id, + name: tool.displayName || tool.name, + description: tool.description || '', + renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true, + status: + tool.status === 'success' + ? ToolCallStatus.Success + : ToolCallStatus.Error, + resultDisplay: tool.resultDisplay, + confirmationDetails: undefined, + })), + }); + } + } + + // Convert to Gemini client history format + const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = []; + + for (const msg of messages) { + // Skip system/error messages and user slash commands + // if (msg.type === 'system' || msg.type === 'error') { + // continue; + // } + + if (msg.type === 'user') { + // Skip user slash commands + const contentString = partListUnionToString(msg.content); + if ( + contentString.trim().startsWith('/') || + contentString.trim().startsWith('?') + ) { + continue; + } + + // Add regular user message + clientHistory.push({ + role: 'user', + parts: [{ text: contentString }], + }); + } else if (msg.type === 'gemini') { + // Handle Gemini messages with potential tool calls + const hasToolCalls = + 'toolCalls' in msg && msg.toolCalls && msg.toolCalls.length > 0; + + if (hasToolCalls) { + // Create model message with function calls + const modelParts: Part[] = []; + + // Add text content if present + const contentString = partListUnionToString(msg.content); + if (msg.content && contentString.trim()) { + modelParts.push({ text: contentString }); + } + + // Add function calls + for (const toolCall of msg.toolCalls!) { + modelParts.push({ + functionCall: { + name: toolCall.name, + args: toolCall.args, + ...(toolCall.id && { id: toolCall.id }), + }, + }); + } + + clientHistory.push({ + role: 'model', + parts: modelParts, + }); + + // Create single function response message with all tool call responses + const functionResponseParts: Part[] = []; + for (const toolCall of msg.toolCalls!) { + if (toolCall.result) { + // Convert PartListUnion result to function response format + let responseData: Part; + + if (typeof toolCall.result === 'string') { + responseData = { + functionResponse: { + id: toolCall.id, + name: toolCall.name, + response: { + output: toolCall.result, + }, + }, + }; + } else if (Array.isArray(toolCall.result)) { + // toolCall.result is an array containing properly formatted + // function responses + functionResponseParts.push(...(toolCall.result as Part[])); + continue; + } else { + // Fallback for non-array results + responseData = toolCall.result; + } + + functionResponseParts.push(responseData); + } + } + + // Only add user message if we have function responses + if (functionResponseParts.length > 0) { + clientHistory.push({ + role: 'user', + parts: functionResponseParts, + }); + } + } else { + // Regular Gemini message without tool calls + const contentString = partListUnionToString(msg.content); + if (msg.content && contentString.trim()) { + clientHistory.push({ + role: 'model', + parts: [{ text: contentString }], + }); + } + } + } + } + + return { + uiHistory, + clientHistory, + }; +} diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts index 01939df5eab..34aff03d63c 100644 --- a/packages/cli/src/utils/sessionCleanup.test.ts +++ b/packages/cli/src/utils/sessionCleanup.test.ts @@ -44,27 +44,43 @@ function createTestSessions(): SessionInfo[] { return [ { id: 'current123', + file: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12`, fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`, + startTime: now.toISOString(), lastUpdated: now.toISOString(), + firstUserMessage: 'Current session', isCurrentSession: true, + index: 1, }, { id: 'recent456', + file: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45`, fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`, + startTime: oneWeekAgo.toISOString(), lastUpdated: oneWeekAgo.toISOString(), + firstUserMessage: 'Recent session', isCurrentSession: false, + index: 2, }, { id: 'old789abc', + file: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab`, fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`, + startTime: twoWeeksAgo.toISOString(), lastUpdated: twoWeeksAgo.toISOString(), + firstUserMessage: 'Old session', isCurrentSession: false, + index: 3, }, { id: 'ancient12', + file: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1`, fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`, + startTime: oneMonthAgo.toISOString(), lastUpdated: oneMonthAgo.toISOString(), + firstUserMessage: 'Ancient session', isCurrentSession: false, + index: 4, }, ]; } @@ -409,27 +425,43 @@ describe('Session Cleanup', () => { const testSessions: SessionInfo[] = [ { id: 'current', + file: `${SESSION_FILE_PREFIX}current`, fileName: `${SESSION_FILE_PREFIX}current.json`, + startTime: now.toISOString(), lastUpdated: now.toISOString(), + firstUserMessage: 'Current', isCurrentSession: true, + index: 1, }, { id: 'session5d', + file: `${SESSION_FILE_PREFIX}5d`, fileName: `${SESSION_FILE_PREFIX}5d.json`, + startTime: fiveDaysAgo.toISOString(), lastUpdated: fiveDaysAgo.toISOString(), + firstUserMessage: '5 days', isCurrentSession: false, + index: 2, }, { id: 'session8d', + file: `${SESSION_FILE_PREFIX}8d`, fileName: `${SESSION_FILE_PREFIX}8d.json`, + startTime: eightDaysAgo.toISOString(), lastUpdated: eightDaysAgo.toISOString(), + firstUserMessage: '8 days', isCurrentSession: false, + index: 3, }, { id: 'session15d', + file: `${SESSION_FILE_PREFIX}15d`, fileName: `${SESSION_FILE_PREFIX}15d.json`, + startTime: fifteenDaysAgo.toISOString(), lastUpdated: fifteenDaysAgo.toISOString(), + firstUserMessage: '15 days', isCurrentSession: false, + index: 4, }, ]; @@ -507,27 +539,43 @@ describe('Session Cleanup', () => { const testSessions: SessionInfo[] = [ { id: 'current', + file: `${SESSION_FILE_PREFIX}current`, fileName: `${SESSION_FILE_PREFIX}current.json`, + startTime: now.toISOString(), lastUpdated: now.toISOString(), + firstUserMessage: 'Current', isCurrentSession: true, + index: 1, }, { id: 'session1d', + file: `${SESSION_FILE_PREFIX}1d`, fileName: `${SESSION_FILE_PREFIX}1d.json`, + startTime: oneDayAgo.toISOString(), lastUpdated: oneDayAgo.toISOString(), + firstUserMessage: '1 day', isCurrentSession: false, + index: 2, }, { id: 'session7d', + file: `${SESSION_FILE_PREFIX}7d`, fileName: `${SESSION_FILE_PREFIX}7d.json`, + startTime: sevenDaysAgo.toISOString(), lastUpdated: sevenDaysAgo.toISOString(), + firstUserMessage: '7 days', isCurrentSession: false, + index: 3, }, { id: 'session13d', + file: `${SESSION_FILE_PREFIX}13d`, fileName: `${SESSION_FILE_PREFIX}13d.json`, + startTime: thirteenDaysAgo.toISOString(), lastUpdated: thirteenDaysAgo.toISOString(), + firstUserMessage: '13 days', isCurrentSession: false, + index: 4, }, ]; @@ -579,9 +627,13 @@ describe('Session Cleanup', () => { const sessions: SessionInfo[] = [ { id: 'current', + file: `${SESSION_FILE_PREFIX}current`, fileName: `${SESSION_FILE_PREFIX}current.json`, + startTime: now.toISOString(), lastUpdated: now.toISOString(), + firstUserMessage: 'Current', isCurrentSession: true, + index: 1, }, ]; @@ -590,9 +642,13 @@ describe('Session Cleanup', () => { const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); sessions.push({ id: `session${i}`, + file: `${SESSION_FILE_PREFIX}${i}d`, fileName: `${SESSION_FILE_PREFIX}${i}d.json`, + startTime: daysAgo.toISOString(), lastUpdated: daysAgo.toISOString(), + firstUserMessage: `${i} days`, isCurrentSession: false, + index: i + 1, }); } @@ -693,33 +749,53 @@ describe('Session Cleanup', () => { const testSessions: SessionInfo[] = [ { id: 'current', + file: `${SESSION_FILE_PREFIX}current`, fileName: `${SESSION_FILE_PREFIX}current.json`, + startTime: now.toISOString(), lastUpdated: now.toISOString(), + firstUserMessage: 'Current', isCurrentSession: true, + index: 1, }, { id: 'session3d', + file: `${SESSION_FILE_PREFIX}3d`, fileName: `${SESSION_FILE_PREFIX}3d.json`, + startTime: threeDaysAgo.toISOString(), lastUpdated: threeDaysAgo.toISOString(), + firstUserMessage: '3 days', isCurrentSession: false, + index: 2, }, { id: 'session5d', + file: `${SESSION_FILE_PREFIX}5d`, fileName: `${SESSION_FILE_PREFIX}5d.json`, + startTime: fiveDaysAgo.toISOString(), lastUpdated: fiveDaysAgo.toISOString(), + firstUserMessage: '5 days', isCurrentSession: false, + index: 3, }, { id: 'session7d', + file: `${SESSION_FILE_PREFIX}7d`, fileName: `${SESSION_FILE_PREFIX}7d.json`, + startTime: sevenDaysAgo.toISOString(), lastUpdated: sevenDaysAgo.toISOString(), + firstUserMessage: '7 days', isCurrentSession: false, + index: 4, }, { id: 'session12d', + file: `${SESSION_FILE_PREFIX}12d`, fileName: `${SESSION_FILE_PREFIX}12d.json`, + startTime: twelveDaysAgo.toISOString(), lastUpdated: twelveDaysAgo.toISOString(), + firstUserMessage: '12 days', isCurrentSession: false, + index: 5, }, ]; diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts new file mode 100644 index 00000000000..5c53e8bd54f --- /dev/null +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -0,0 +1,363 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + SessionSelector, + extractFirstUserMessage, + formatRelativeTime, +} from './sessionUtils.js'; +import type { Config, MessageRecord } from '@google/gemini-cli-core'; +import { SESSION_FILE_PREFIX } from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +describe('SessionSelector', () => { + let tmpDir: string; + let config: Config; + + beforeEach(async () => { + // Create a temporary directory for testing + tmpDir = path.join(process.cwd(), '.tmp-test-sessions'); + await fs.mkdir(tmpDir, { recursive: true }); + + // Mock config + config = { + storage: { + getProjectTempDir: () => tmpDir, + }, + getSessionId: () => 'current-session-id', + } as Partial as Config; + }); + + afterEach(async () => { + // Clean up test files + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch (_error) { + // Ignore cleanup errors + } + }); + + it('should resolve session by UUID', async () => { + const sessionId1 = randomUUID(); + const sessionId2 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session1 = { + sessionId: sessionId1, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Test message 1', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const session2 = { + sessionId: sessionId2, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Test message 2', + id: 'msg2', + timestamp: '2024-01-01T11:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(session1, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`, + ), + JSON.stringify(session2, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + + // Test resolving by UUID + const result1 = await sessionSelector.resolveSession(sessionId1); + expect(result1.sessionData.sessionId).toBe(sessionId1); + expect(result1.sessionData.messages[0].content).toBe('Test message 1'); + + const result2 = await sessionSelector.resolveSession(sessionId2); + expect(result2.sessionData.sessionId).toBe(sessionId2); + expect(result2.sessionData.messages[0].content).toBe('Test message 2'); + }); + + it('should resolve session by index', async () => { + const sessionId1 = randomUUID(); + const sessionId2 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session1 = { + sessionId: sessionId1, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'First session', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const session2 = { + sessionId: sessionId2, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Second session', + id: 'msg2', + timestamp: '2024-01-01T11:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(session1, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`, + ), + JSON.stringify(session2, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + + // Test resolving by index (1-based) + const result1 = await sessionSelector.resolveSession('1'); + expect(result1.sessionData.messages[0].content).toBe('First session'); + + const result2 = await sessionSelector.resolveSession('2'); + expect(result2.sessionData.messages[0].content).toBe('Second session'); + }); + + it('should resolve latest session', async () => { + const sessionId1 = randomUUID(); + const sessionId2 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session1 = { + sessionId: sessionId1, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'First session', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const session2 = { + sessionId: sessionId2, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Latest session', + id: 'msg2', + timestamp: '2024-01-01T11:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(session1, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`, + ), + JSON.stringify(session2, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + + // Test resolving latest + const result = await sessionSelector.resolveSession('latest'); + expect(result.sessionData.messages[0].content).toBe('Latest session'); + }); + + it('should throw error for invalid session identifier', async () => { + const sessionId1 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session1 = { + sessionId: sessionId1, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Test message 1', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(session1, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + + await expect( + sessionSelector.resolveSession('invalid-uuid'), + ).rejects.toThrow('Invalid session identifier "invalid-uuid"'); + + await expect(sessionSelector.resolveSession('999')).rejects.toThrow( + 'Invalid session identifier "999"', + ); + }); +}); + +describe('extractFirstUserMessage', () => { + it('should extract first non-resume user message', () => { + const messages = [ + { + type: 'user', + content: '/resume', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + { + type: 'user', + content: 'Hello world', + id: 'msg2', + timestamp: '2024-01-01T10:01:00.000Z', + }, + ] as MessageRecord[]; + + expect(extractFirstUserMessage(messages)).toBe('Hello world'); + }); + + it('should truncate long messages', () => { + const longMessage = 'a'.repeat(150); + const messages = [ + { + type: 'user', + content: longMessage, + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ] as MessageRecord[]; + + const result = extractFirstUserMessage(messages); + expect(result).toBe('a'.repeat(97) + '...'); + expect(result.length).toBe(100); + }); + + it('should return "Empty conversation" for no user messages', () => { + const messages = [ + { + type: 'gemini', + content: 'Hello', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ] as MessageRecord[]; + + expect(extractFirstUserMessage(messages)).toBe('Empty conversation'); + }); +}); + +describe('formatRelativeTime', () => { + it('should format time correctly', () => { + const now = new Date(); + + // 5 minutes ago + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); + expect(formatRelativeTime(fiveMinutesAgo.toISOString())).toBe( + '5 minutes ago', + ); + + // 1 minute ago + const oneMinuteAgo = new Date(now.getTime() - 1 * 60 * 1000); + expect(formatRelativeTime(oneMinuteAgo.toISOString())).toBe('1 minute ago'); + + // 2 hours ago + const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); + expect(formatRelativeTime(twoHoursAgo.toISOString())).toBe('2 hours ago'); + + // 1 hour ago + const oneHourAgo = new Date(now.getTime() - 1 * 60 * 60 * 1000); + expect(formatRelativeTime(oneHourAgo.toISOString())).toBe('1 hour ago'); + + // 3 days ago + const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); + expect(formatRelativeTime(threeDaysAgo.toISOString())).toBe('3 days ago'); + + // 1 day ago + const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); + expect(formatRelativeTime(oneDayAgo.toISOString())).toBe('1 day ago'); + + // Just now (within 60 seconds) + const thirtySecondsAgo = new Date(now.getTime() - 30 * 1000); + expect(formatRelativeTime(thirtySecondsAgo.toISOString())).toBe('Just now'); + }); +}); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 44843ae323d..20cac7cf75d 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -4,9 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { + Config, + ConversationRecord, + MessageRecord, +} from '@google/gemini-cli-core'; import { SESSION_FILE_PREFIX, - type ConversationRecord, + partListUnionToString, } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -17,12 +22,20 @@ import path from 'node:path'; export interface SessionInfo { /** Unique session identifier (filename without .json) */ id: string; + /** Filename without extension */ + file: string; /** Full filename including .json extension */ fileName: string; + /** ISO timestamp when session started */ + startTime: string; /** ISO timestamp when session was last updated */ lastUpdated: string; + /** Cleaned first user message content */ + firstUserMessage: string; /** Whether this is the currently active session */ isCurrentSession: boolean; + /** Display index in the list */ + index: number; } /** @@ -35,6 +48,55 @@ export interface SessionFileEntry { sessionInfo: SessionInfo | null; } +/** + * Result of resolving a session selection argument. + */ +export interface SessionSelectionResult { + sessionPath: string; + sessionData: ConversationRecord; +} + +/** + * Extracts the first meaningful user message from conversation messages. + */ +export const extractFirstUserMessage = (messages: MessageRecord[]): string => { + const userMessage = messages.find((msg) => { + const content = partListUnionToString(msg.content); + return msg.type === 'user' && content?.trim() && content !== '/resume'; + }); + + if (!userMessage) { + return 'Empty conversation'; + } + + // Truncate long messages for display + const content = partListUnionToString(userMessage.content).trim(); + return content.length > 100 ? content.slice(0, 97) + '...' : content; +}; + +/** + * Formats a timestamp as relative time (e.g., "2 hours ago", "3 days ago"). + */ +export const formatRelativeTime = (timestamp: string): string => { + const now = new Date(); + const time = new Date(timestamp); + const diffMs = now.getTime() - time.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 0) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } else if (diffHours > 0) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } else if (diffMinutes > 0) { + return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + } else { + return 'Just now'; + } +}; + /** * Loads all session files (including corrupted ones) from the chats directory. * @returns Array of session file entries, with sessionInfo null for corrupted files @@ -69,15 +131,20 @@ export const getAllSessionFiles = async ( return { fileName: file, sessionInfo: null }; } + const firstUserMessage = extractFirstUserMessage(content.messages); const isCurrentSession = currentSessionId ? file.includes(currentSessionId.slice(0, 8)) : false; const sessionInfo: SessionInfo = { id: content.sessionId, + file: file.replace('.json', ''), fileName: file, + startTime: content.startTime, lastUpdated: content.lastUpdated, + firstUserMessage, isCurrentSession, + index: 0, // Will be set after sorting valid sessions }; return { fileName: file, sessionInfo }; @@ -87,6 +154,7 @@ export const getAllSessionFiles = async ( } }, ); + return await Promise.all(sessionPromises); } catch (error) { // It's expected that the directory might not exist, which is not an error. @@ -116,5 +184,112 @@ export const getSessionFiles = async ( ) .map((entry) => entry.sessionInfo); + // Sort by startTime (oldest first) for stable session numbering + validSessions.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + + // Set the correct 1-based indexes after sorting + validSessions.forEach((session, index) => { + session.index = index + 1; + }); + return validSessions; }; + +/** + * Utility class for session discovery and selection. + */ +export class SessionSelector { + constructor(private config: Config) {} + + /** + * Lists all available sessions for the current project. + */ + async listSessions(): Promise { + const chatsDir = path.join( + this.config.storage.getProjectTempDir(), + 'chats', + ); + return getSessionFiles(chatsDir, this.config.getSessionId()); + } + + /** + * Resolves a resume argument to a specific session. + * + * @param resumeArg - Can be "latest", a full UUID, or an index number (1-based) + * @returns Promise resolving to session selection result + */ + async resolveSession(resumeArg: string): Promise { + const sessions = await this.listSessions(); + + if (sessions.length === 0) { + throw new Error('No previous sessions found for this project.'); + } + + // Sort by startTime (oldest first, so newest sessions get highest numbers) + sessions.sort( + (a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + + let selectedSession: SessionInfo; + + if (resumeArg === 'latest') { + selectedSession = sessions[sessions.length - 1]; + } else { + // Try to find by UUID first + const sessionByUuid = sessions.find( + (session) => session.id === resumeArg, + ); + if (sessionByUuid) { + selectedSession = sessionByUuid; + } else { + // Parse as index number (1-based) - only allow numeric indexes + const index = parseInt(resumeArg, 10); + if ( + !isNaN(index) && + index.toString() === resumeArg && + index > 0 && + index <= sessions.length + ) { + selectedSession = sessions[index - 1]; + } else { + throw new Error( + `Invalid session identifier "${resumeArg}". Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest.`, + ); + } + } + } + + return this.selectSession(selectedSession); + } + + /** + * Loads session data for a selected session. + */ + private async selectSession( + sessionInfo: SessionInfo, + ): Promise { + const chatsDir = path.join( + this.config.storage.getProjectTempDir(), + 'chats', + ); + const sessionPath = path.join(chatsDir, sessionInfo.fileName); + + try { + const sessionData: ConversationRecord = JSON.parse( + await fs.readFile(sessionPath, 'utf8'), + ); + + return { + sessionPath, + sessionData, + }; + } catch (error) { + throw new Error( + `Failed to load session ${sessionInfo.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } +} diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts new file mode 100644 index 00000000000..bb583dc72f6 --- /dev/null +++ b/packages/cli/src/utils/sessions.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatRecordingService, type Config } from '@google/gemini-cli-core'; +import { + formatRelativeTime, + SessionSelector, + type SessionInfo, +} from './sessionUtils.js'; + +export async function listSessions(config: Config): Promise { + const sessionSelector = new SessionSelector(config); + const sessions = await sessionSelector.listSessions(); + + if (sessions.length === 0) { + console.log('No previous sessions found for this project.'); + return; + } + + console.log(`\nAvailable sessions for this project (${sessions.length}):\n`); + + sessions + .sort( + (a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ) + .forEach((session, index) => { + const current = session.isCurrentSession ? ', current' : ''; + const time = formatRelativeTime(session.lastUpdated); + console.log( + ` ${index + 1}. ${session.firstUserMessage} (${time}${current}) [${session.id}]`, + ); + }); +} + +export async function deleteSession( + config: Config, + sessionIndex: string, +): Promise { + const sessionSelector = new SessionSelector(config); + const sessions = await sessionSelector.listSessions(); + + if (sessions.length === 0) { + console.error('No sessions found for this project.'); + return; + } + + // Sort sessions by start time to match list-sessions ordering + const sortedSessions = sessions.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + + let sessionToDelete: SessionInfo; + + // Try to find by UUID first + const sessionByUuid = sortedSessions.find( + (session) => session.id === sessionIndex, + ); + if (sessionByUuid) { + sessionToDelete = sessionByUuid; + } else { + // Parse session index + const index = parseInt(sessionIndex, 10); + if (isNaN(index) || index < 1 || index > sessions.length) { + console.error( + `Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`, + ); + return; + } + sessionToDelete = sortedSessions[index - 1]; + } + + // Prevent deleting the current session + if (sessionToDelete.isCurrentSession) { + console.error('Cannot delete the current active session.'); + return; + } + + try { + // Use ChatRecordingService to delete the session + const chatRecordingService = new ChatRecordingService(config); + chatRecordingService.deleteSession(sessionToDelete.file); + + const time = formatRelativeTime(sessionToDelete.lastUpdated); + console.log( + `Deleted session ${sessionToDelete.index}: ${sessionToDelete.firstUserMessage} (${time})`, + ); + } catch (error) { + console.error( + `Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1765e329a5a..18e5501bca9 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -232,6 +232,8 @@ export interface ConfigParameters { maxSessionTurns?: number; experimentalZedIntegration?: boolean; listExtensions?: boolean; + listSessions?: boolean; + deleteSession?: string; extensions?: GeminiCLIExtension[]; blockedMcpServers?: Array<{ name: string; extensionName: string }>; noBrowser?: boolean; @@ -266,7 +268,7 @@ export class Config { private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; private agentRegistry!: AgentRegistry; - private readonly sessionId: string; + private sessionId: string; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGenerator!: ContentGenerator; @@ -317,6 +319,8 @@ export class Config { private inFallbackMode = false; private readonly maxSessionTurns: number; private readonly listExtensions: boolean; + private readonly listSessions: boolean; + private readonly deleteSession: string | undefined; private readonly _extensions: GeminiCLIExtension[]; private readonly _blockedMcpServers: Array<{ name: string; @@ -410,6 +414,8 @@ export class Config { this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; this.listExtensions = params.listExtensions ?? false; + this.listSessions = params.listSessions ?? false; + this.deleteSession = params.deleteSession; this._extensions = params.extensions ?? []; this._blockedMcpServers = params.blockedMcpServers ?? []; this.noBrowser = params.noBrowser ?? false; @@ -558,6 +564,10 @@ export class Config { return this.sessionId; } + setSessionId(sessionId: string): void { + this.sessionId = sessionId; + } + shouldLoadMemoryFromIncludeDirectories(): boolean { return this.loadMemoryFromIncludeDirectories; } @@ -842,6 +852,14 @@ export class Config { return this.listExtensions; } + getListSessions(): boolean { + return this.listSessions; + } + + getDeleteSession(): string | undefined { + return this.deleteSession; + } + getExtensionManagement(): boolean { return this.extensionManagement; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 7f37dd7b60c..503d5fe99de 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -27,7 +27,10 @@ import { GeminiChat } from './geminiChat.js'; import { retryWithBackoff } from '../utils/retry.js'; import { getErrorMessage } from '../utils/errors.js'; import { tokenLimit } from './tokenLimits.js'; -import type { ChatRecordingService } from '../services/chatRecordingService.js'; +import type { + ChatRecordingService, + ResumedSessionData, +} from '../services/chatRecordingService.js'; import type { ContentGenerator } from './contentGenerator.js'; import { DEFAULT_GEMINI_FLASH_MODEL, @@ -199,6 +202,13 @@ export class GeminiClient { this.chat = await this.startChat(); } + async resumeChat( + history: Content[], + resumedSessionData?: ResumedSessionData, + ): Promise { + this.chat = await this.startChat(history, resumedSessionData); + } + getChatRecordingService(): ChatRecordingService | undefined { return this.chat?.getChatRecordingService(); } @@ -222,7 +232,10 @@ export class GeminiClient { }); } - async startChat(extraHistory?: Content[]): Promise { + async startChat( + extraHistory?: Content[], + resumedSessionData?: ResumedSessionData, + ): Promise { this.forceFullIdeContext = true; this.hasFailedCompressionAttempt = false; @@ -278,6 +291,7 @@ My setup is complete. I will provide my first command in the next turn. tools, }, history, + resumedSessionData, ); } catch (error) { await reportError( diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 01285fa5268..d2c1d34fde1 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -31,7 +31,10 @@ import { logContentRetry, logContentRetryFailure, } from '../telemetry/loggers.js'; -import { ChatRecordingService } from '../services/chatRecordingService.js'; +import { + ChatRecordingService, + type ResumedSessionData, +} from '../services/chatRecordingService.js'; import { ContentRetryEvent, ContentRetryFailureEvent, @@ -192,10 +195,11 @@ export class GeminiChat { private readonly config: Config, private readonly generationConfig: GenerateContentConfig = {}, private history: Content[] = [], + resumedSessionData?: ResumedSessionData, ) { validateHistory(history); this.chatRecordingService = new ChatRecordingService(config); - this.chatRecordingService.initialize(); + this.chatRecordingService.initialize(resumedSessionData); } setSystemInstruction(sysInstr: string) { From 21930872e5f5add6180fb92607ffaff0ccc7501b Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:01:38 -0600 Subject: [PATCH 02/11] Update packages/cli/src/utils/sessions.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/cli/src/utils/sessions.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts index bb583dc72f6..a61d89cc501 100644 --- a/packages/cli/src/utils/sessions.ts +++ b/packages/cli/src/utils/sessions.ts @@ -64,7 +64,12 @@ export async function deleteSession( } else { // Parse session index const index = parseInt(sessionIndex, 10); - if (isNaN(index) || index < 1 || index > sessions.length) { + if ( + isNaN(index) || + index.toString() !== sessionIndex || + index < 1 || + index > sortedSessions.length + ) { console.error( `Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`, ); From 1daebb41e1096c47124e70ea6897edc009a2361b Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:35:36 -0600 Subject: [PATCH 03/11] Address bot review --- packages/cli/src/utils/sessionUtils.ts | 82 ++++++++++++++++++-------- packages/cli/src/utils/sessions.ts | 40 +++---------- 2 files changed, 63 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 20cac7cf75d..3be8ca7a9f3 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -215,12 +215,13 @@ export class SessionSelector { } /** - * Resolves a resume argument to a specific session. + * Finds a session by identifier (UUID or numeric index). * - * @param resumeArg - Can be "latest", a full UUID, or an index number (1-based) - * @returns Promise resolving to session selection result + * @param identifier - Can be a full UUID or an index number (1-based) + * @returns Promise resolving to the found SessionInfo + * @throws Error if the session is not found or identifier is invalid */ - async resolveSession(resumeArg: string): Promise { + async findSession(identifier: string): Promise { const sessions = await this.listSessions(); if (sessions.length === 0) { @@ -228,37 +229,66 @@ export class SessionSelector { } // Sort by startTime (oldest first, so newest sessions get highest numbers) - sessions.sort( + const sortedSessions = sessions.sort( (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), ); + // Try to find by UUID first + const sessionByUuid = sortedSessions.find( + (session) => session.id === identifier, + ); + if (sessionByUuid) { + return sessionByUuid; + } + + // Parse as index number (1-based) - only allow numeric indexes + const index = parseInt(identifier, 10); + if ( + !isNaN(index) && + index.toString() === identifier && + index > 0 && + index <= sortedSessions.length + ) { + return sortedSessions[index - 1]; + } + + throw new Error( + `Invalid session identifier "${identifier}". Use --list-sessions to see available sessions.`, + ); + } + + /** + * Resolves a resume argument to a specific session. + * + * @param resumeArg - Can be "latest", a full UUID, or an index number (1-based) + * @returns Promise resolving to session selection result + */ + async resolveSession(resumeArg: string): Promise { let selectedSession: SessionInfo; if (resumeArg === 'latest') { + const sessions = await this.listSessions(); + + if (sessions.length === 0) { + throw new Error('No previous sessions found for this project.'); + } + + // Sort by startTime (oldest first, so newest sessions get highest numbers) + sessions.sort( + (a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + selectedSession = sessions[sessions.length - 1]; } else { - // Try to find by UUID first - const sessionByUuid = sessions.find( - (session) => session.id === resumeArg, - ); - if (sessionByUuid) { - selectedSession = sessionByUuid; - } else { - // Parse as index number (1-based) - only allow numeric indexes - const index = parseInt(resumeArg, 10); - if ( - !isNaN(index) && - index.toString() === resumeArg && - index > 0 && - index <= sessions.length - ) { - selectedSession = sessions[index - 1]; - } else { - throw new Error( - `Invalid session identifier "${resumeArg}". Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest.`, - ); - } + try { + selectedSession = await this.findSession(resumeArg); + } catch (error) { + // Re-throw with more detailed message for resume command + throw new Error( + `Invalid session identifier "${resumeArg}". Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest. Error: ${error}`, + ); } } diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts index a61d89cc501..95c97baa32b 100644 --- a/packages/cli/src/utils/sessions.ts +++ b/packages/cli/src/utils/sessions.ts @@ -41,41 +41,15 @@ export async function deleteSession( sessionIndex: string, ): Promise { const sessionSelector = new SessionSelector(config); - const sessions = await sessionSelector.listSessions(); - - if (sessions.length === 0) { - console.error('No sessions found for this project.'); - return; - } - - // Sort sessions by start time to match list-sessions ordering - const sortedSessions = sessions.sort( - (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), - ); let sessionToDelete: SessionInfo; - - // Try to find by UUID first - const sessionByUuid = sortedSessions.find( - (session) => session.id === sessionIndex, - ); - if (sessionByUuid) { - sessionToDelete = sessionByUuid; - } else { - // Parse session index - const index = parseInt(sessionIndex, 10); - if ( - isNaN(index) || - index.toString() !== sessionIndex || - index < 1 || - index > sortedSessions.length - ) { - console.error( - `Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`, - ); - return; - } - sessionToDelete = sortedSessions[index - 1]; + try { + sessionToDelete = await sessionSelector.findSession(sessionIndex); + } catch (error) { + console.error( + error instanceof Error ? error.message : 'Unknown error occurred.', + ); + return; } // Prevent deleting the current session From 0c9d47097aa49878a5c264de60ea4e18b7dcb152 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:15:14 -0600 Subject: [PATCH 04/11] Add a test argv.resume && !argv.prompt && !process.stdin.isTTY --- packages/cli/src/config/config.test.ts | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0ec7924fb20..b1ed5224f35 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -440,6 +440,36 @@ describe('parseArguments', () => { mockConsoleError.mockRestore(); }); + it('should throw an error when resuming a session without prompt in non-interactive mode', async () => { + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = false; + process.argv = ['node', 'script.js', '--resume', 'session-id']; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + try { + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'When resuming a session, you must provide a message via --prompt (-p) or stdin', + ), + ); + } finally { + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + process.stdin.isTTY = originalIsTTY; + } + }); + it('should support comma-separated values for --allowed-tools', async () => { process.argv = [ 'node', From 393377aafde4c9c59f79339bd7239b379ce5c3d0 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Fri, 17 Oct 2025 07:22:03 -0600 Subject: [PATCH 05/11] Setup interactive resume --- packages/cli/src/gemini.test.tsx | 2 + packages/cli/src/gemini.tsx | 3 + packages/cli/src/ui/AppContainer.test.tsx | 318 ++++++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 53 +++- 4 files changed, 375 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 1def3976d73..19080ae7cbd 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -450,6 +450,7 @@ describe('startInteractiveUI', () => { mockSettings, mockStartupWarnings, mockWorkspaceRoot, + undefined, mockInitializationResult, ); @@ -484,6 +485,7 @@ describe('startInteractiveUI', () => { mockSettings, mockStartupWarnings, mockWorkspaceRoot, + undefined, mockInitializationResult, ); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 329236e210d..10759444d41 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -145,6 +145,7 @@ export async function startInteractiveUI( settings: LoadedSettings, startupWarnings: string[], workspaceRoot: string = process.cwd(), + resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, ) { // Disable line wrapping. @@ -183,6 +184,7 @@ export async function startInteractiveUI( settings={settings} startupWarnings={startupWarnings} version={version} + resumedSessionData={resumedSessionData} initializationResult={initializationResult} /> @@ -461,6 +463,7 @@ export async function main() { settings, startupWarnings, process.cwd(), + resumedSessionData, initializationResult, ); return; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 101725d0ccc..4322c813178 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -536,6 +536,324 @@ describe('AppContainer State Management', () => { }); }); + describe('Session Resumption', () => { + it('handles resumed session data correctly', () => { + const mockResumedSessionData = { + conversation: { + sessionId: 'test-session-123', + projectHash: 'test-project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-01T00:00:01Z', + messages: [ + { + id: 'msg-1', + type: 'user' as const, + content: 'Hello', + timestamp: '2024-01-01T00:00:00Z', + }, + { + id: 'msg-2', + type: 'gemini' as const, + content: 'Hi there!', + role: 'model' as const, + parts: [{ text: 'Hi there!' }], + timestamp: '2024-01-01T00:00:01Z', + }, + ], + }, + filePath: '/tmp/test-session.json', + }; + + expect(() => { + render( + , + ); + }).not.toThrow(); + }); + + it('renders without resumed session data', () => { + expect(() => { + render( + , + ); + }).not.toThrow(); + }); + + it('initializes chat recording service when config has it', () => { + const mockChatRecordingService = { + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + }; + + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + resumeChat: vi.fn(), + getUserTier: vi.fn(), + getChatRecordingService: vi.fn(() => mockChatRecordingService), + }; + + const configWithRecording = { + ...mockConfig, + getGeminiClient: vi.fn(() => mockGeminiClient), + } as unknown as Config; + + expect(() => { + render( + , + ); + }).not.toThrow(); + }); + }); + describe('Session Recording Integration', () => { + it('provides chat recording service configuration', () => { + const mockChatRecordingService = { + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + getSessionId: vi.fn(() => 'test-session-123'), + getCurrentConversation: vi.fn(), + }; + + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + resumeChat: vi.fn(), + getUserTier: vi.fn(), + getChatRecordingService: vi.fn(() => mockChatRecordingService), + setHistory: vi.fn(), + }; + + const configWithRecording = { + ...mockConfig, + getGeminiClient: vi.fn(() => mockGeminiClient), + getSessionId: vi.fn(() => 'test-session-123'), + } as unknown as Config; + + expect(() => { + render( + , + ); + }).not.toThrow(); + + // Verify the recording service structure is correct + expect(configWithRecording.getGeminiClient).toBeDefined(); + expect(mockGeminiClient.getChatRecordingService).toBeDefined(); + expect(mockChatRecordingService.initialize).toBeDefined(); + expect(mockChatRecordingService.recordMessage).toBeDefined(); + }); + + it('handles session recording when messages are added', () => { + const mockRecordMessage = vi.fn(); + const mockRecordMessageTokens = vi.fn(); + + const mockChatRecordingService = { + initialize: vi.fn(), + recordMessage: mockRecordMessage, + recordMessageTokens: mockRecordMessageTokens, + recordToolCalls: vi.fn(), + getSessionId: vi.fn(() => 'test-session-123'), + }; + + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + getChatRecordingService: vi.fn(() => mockChatRecordingService), + getUserTier: vi.fn(), + }; + + const configWithRecording = { + ...mockConfig, + getGeminiClient: vi.fn(() => mockGeminiClient), + } as unknown as Config; + + render( + , + ); + + // The actual recording happens through the useHistory hook + // which would be triggered by user interactions + expect(mockChatRecordingService.initialize).toBeDefined(); + expect(mockChatRecordingService.recordMessage).toBeDefined(); + }); + }); + + describe('Session Resume Flow', () => { + it('accepts resumed session data', () => { + const mockResumeChat = vi.fn(); + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + resumeChat: mockResumeChat, + getUserTier: vi.fn(), + getChatRecordingService: vi.fn(() => ({ + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + })), + }; + + const configWithClient = { + ...mockConfig, + getGeminiClient: vi.fn(() => mockGeminiClient), + } as unknown as Config; + + const resumedData = { + conversation: { + sessionId: 'resumed-session-456', + projectHash: 'project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-01T00:01:00Z', + messages: [ + { + id: 'msg-1', + type: 'user' as const, + content: 'Previous question', + timestamp: '2024-01-01T00:00:00Z', + }, + { + id: 'msg-2', + type: 'gemini' as const, + content: 'Previous answer', + role: 'model' as const, + parts: [{ text: 'Previous answer' }], + timestamp: '2024-01-01T00:00:30Z', + tokenCount: { input: 10, output: 20 }, + }, + ], + }, + filePath: '/tmp/resumed-session.json', + }; + + expect(() => { + render( + , + ); + }).not.toThrow(); + + // Verify the resume functionality structure is in place + expect(mockGeminiClient.resumeChat).toBeDefined(); + expect(resumedData.conversation.messages).toHaveLength(2); + }); + + it('does not attempt resume when client is not initialized', () => { + const mockResumeChat = vi.fn(); + const mockGeminiClient = { + isInitialized: vi.fn(() => false), // Not initialized + resumeChat: mockResumeChat, + getUserTier: vi.fn(), + getChatRecordingService: vi.fn(), + }; + + const configWithClient = { + ...mockConfig, + getGeminiClient: vi.fn(() => mockGeminiClient), + } as unknown as Config; + + const resumedData = { + conversation: { + sessionId: 'test-session', + projectHash: 'project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-01T00:01:00Z', + messages: [], + }, + filePath: '/tmp/session.json', + }; + + render( + , + ); + + // Should not call resumeChat when client is not initialized + expect(mockResumeChat).not.toHaveBeenCalled(); + }); + }); + + describe('Token Counting from Session Stats', () => { + it('tracks token counts from session messages', () => { + // Session stats are provided through the SessionStatsProvider context + // in the real app, not through the config directly + const mockChatRecordingService = { + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + getSessionId: vi.fn(() => 'test-session-123'), + getCurrentConversation: vi.fn(() => ({ + sessionId: 'test-session-123', + messages: [], + totalInputTokens: 150, + totalOutputTokens: 350, + })), + }; + + const mockGeminiClient = { + isInitialized: vi.fn(() => true), + getChatRecordingService: vi.fn(() => mockChatRecordingService), + getUserTier: vi.fn(), + }; + + const configWithRecording = { + ...mockConfig, + getGeminiClient: vi.fn(() => mockGeminiClient), + } as unknown as Config; + + render( + , + ); + + // In the actual app, these stats would be displayed in components + // and updated as messages are processed through the recording service + expect(mockChatRecordingService.recordMessageTokens).toBeDefined(); + expect(mockChatRecordingService.getCurrentConversation).toBeDefined(); + }); + }); + describe('Quota and Fallback Integration', () => { it('passes a null proQuotaRequest to UIStateContext by default', () => { // The default mock from beforeEach already sets proQuotaRequest to null diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index bad03fa568d..95ab1b0f9c8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -41,8 +41,10 @@ import { getAllGeminiMdFilenames, AuthType, clearCachedCredentialFile, + type ResumedSessionData, ShellExecutionService, } from '@google/gemini-cli-core'; +import type { Part } from '@google/genai'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; import process from 'node:process'; @@ -89,6 +91,7 @@ import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; +import { convertSessionToHistoryFormats } from './hooks/useSessionBrowser.js'; import { useExtensionUpdates } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; @@ -111,6 +114,7 @@ interface AppContainerProps { startupWarnings?: string[]; version: string; initializationResult: InitializationResult; + resumedSessionData?: ResumedSessionData; } /** @@ -126,7 +130,7 @@ const SHELL_WIDTH_FRACTION = 0.89; const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { - const { settings, config, initializationResult } = props; + const { settings, config, initializationResult, resumedSessionData } = props; const historyManager = useHistory(); useMemoryMonitor(historyManager); const [corgiMode, setCorgiMode] = useState(false); @@ -357,6 +361,53 @@ export const AppContainer = (props: AppContainerProps) => { const isAuthDialogOpen = authState === AuthState.Updating; const isAuthenticating = authState === AuthState.Unauthenticated; + const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized(); + + const loadHistoryForResume = useCallback( + ( + uiHistory: HistoryItemWithoutId[], + clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>, + resumedData: ResumedSessionData, + ) => { + // Wait for the client. + if (!isGeminiClientInitialized) { + return; + } + + // Now that we have the client, load the history into the UI and the client. + setQuittingMessages(null); + historyManager.clearItems(); + uiHistory.forEach((item, index) => { + historyManager.addItem(item, index); + }); + refreshStatic(); // Force Static component to re-render with the updated history. + + // Give the history to the Gemini client. + config.getGeminiClient()?.resumeChat(clientHistory, resumedData); + }, + [ + historyManager, + config, + refreshStatic, + isGeminiClientInitialized, + setQuittingMessages, + ], + ); + // Handle interactive resume from the command line (-r/--resume without -p/--prompt-interactive). + // Only if we're not authenticating, though. + useEffect(() => { + if (resumedSessionData && !isAuthenticating) { + const historyData = convertSessionToHistoryFormats( + resumedSessionData.conversation.messages, + ); + loadHistoryForResume( + historyData.uiHistory, + historyData.clientHistory, + resumedSessionData, + ); + } + }, [resumedSessionData, isAuthenticating, loadHistoryForResume]); + // Create handleAuthSelect wrapper for backward compatibility const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: SettingScope) => { From d4e0507e071d3c37836e5162a902792c1454bb8a Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:04:12 -0600 Subject: [PATCH 06/11] Extract UI resume logic to a useSessionResume hook --- packages/cli/src/ui/AppContainer.tsx | 57 +-- .../cli/src/ui/hooks/useSessionResume.test.ts | 410 ++++++++++++++++++ packages/cli/src/ui/hooks/useSessionResume.ts | 94 ++++ 3 files changed, 515 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useSessionResume.test.ts create mode 100644 packages/cli/src/ui/hooks/useSessionResume.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7f92c61db68..d139a89d484 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -44,7 +44,6 @@ import { type ResumedSessionData, ShellExecutionService, } from '@google/gemini-cli-core'; -import type { Part } from '@google/genai'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; import process from 'node:process'; @@ -90,9 +89,9 @@ import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; -import { convertSessionToHistoryFormats } from './hooks/useSessionBrowser.js'; import { useExtensionUpdates } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; +import { useSessionResume } from './hooks/useSessionResume.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; @@ -366,52 +365,18 @@ export const AppContainer = (props: AppContainerProps) => { const isAuthDialogOpen = authState === AuthState.Updating; const isAuthenticating = authState === AuthState.Unauthenticated; + // Session browser and resume functionality const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized(); - const loadHistoryForResume = useCallback( - ( - uiHistory: HistoryItemWithoutId[], - clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>, - resumedData: ResumedSessionData, - ) => { - // Wait for the client. - if (!isGeminiClientInitialized) { - return; - } - - // Now that we have the client, load the history into the UI and the client. - setQuittingMessages(null); - historyManager.clearItems(); - uiHistory.forEach((item, index) => { - historyManager.addItem(item, index); - }); - refreshStatic(); // Force Static component to re-render with the updated history. - - // Give the history to the Gemini client. - config.getGeminiClient()?.resumeChat(clientHistory, resumedData); - }, - [ - historyManager, - config, - refreshStatic, - isGeminiClientInitialized, - setQuittingMessages, - ], - ); - // Handle interactive resume from the command line (-r/--resume without -p/--prompt-interactive). - // Only if we're not authenticating, though. - useEffect(() => { - if (resumedSessionData && !isAuthenticating) { - const historyData = convertSessionToHistoryFormats( - resumedSessionData.conversation.messages, - ); - loadHistoryForResume( - historyData.uiHistory, - historyData.clientHistory, - resumedSessionData, - ); - } - }, [resumedSessionData, isAuthenticating, loadHistoryForResume]); + useSessionResume({ + config, + historyManager, + refreshStatic, + isGeminiClientInitialized, + setQuittingMessages, + resumedSessionData, + isAuthenticating, + }); // Create handleAuthSelect wrapper for backward compatibility const handleAuthSelect = useCallback( diff --git a/packages/cli/src/ui/hooks/useSessionResume.test.ts b/packages/cli/src/ui/hooks/useSessionResume.test.ts new file mode 100644 index 00000000000..33ddecbb8d3 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionResume.test.ts @@ -0,0 +1,410 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useSessionResume } from './useSessionResume.js'; +import type { + Config, + ResumedSessionData, + ConversationRecord, + MessageRecord, +} from '@google/gemini-cli-core'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import type { HistoryItemWithoutId } from '../types.js'; + +describe('useSessionResume', () => { + // Mock dependencies + const mockGeminiClient = { + resumeChat: vi.fn(), + }; + + const mockConfig = { + getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + }; + + const createMockHistoryManager = (): UseHistoryManagerReturn => ({ + history: [], + addItem: vi.fn(), + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }); + + let mockHistoryManager: UseHistoryManagerReturn; + + const mockRefreshStatic = vi.fn(); + const mockSetQuittingMessages = vi.fn(); + + const getDefaultProps = () => ({ + config: mockConfig as unknown as Config, + historyManager: mockHistoryManager, + refreshStatic: mockRefreshStatic, + isGeminiClientInitialized: true, + setQuittingMessages: mockSetQuittingMessages, + resumedSessionData: undefined, + isAuthenticating: false, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockHistoryManager = createMockHistoryManager(); + }); + + describe('loadHistoryForResume', () => { + it('should return a loadHistoryForResume callback', () => { + const { result } = renderHook(() => useSessionResume(getDefaultProps())); + + expect(result.current.loadHistoryForResume).toBeInstanceOf(Function); + }); + + it('should clear history and add items when loading history', () => { + const { result } = renderHook(() => useSessionResume(getDefaultProps())); + + const uiHistory: HistoryItemWithoutId[] = [ + { type: 'user', text: 'Hello' }, + { type: 'gemini', text: 'Hi there!' }, + ]; + + const clientHistory = [ + { role: 'user' as const, parts: [{ text: 'Hello' }] }, + { role: 'model' as const, parts: [{ text: 'Hi there!' }] }, + ]; + + const resumedData: ResumedSessionData = { + conversation: { + sessionId: 'test-123', + projectHash: 'project-123', + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T01:00:00Z', + messages: [] as MessageRecord[], + }, + filePath: '/path/to/session.json', + }; + + act(() => { + result.current.loadHistoryForResume( + uiHistory, + clientHistory, + resumedData, + ); + }); + + expect(mockSetQuittingMessages).toHaveBeenCalledWith(null); + expect(mockHistoryManager.clearItems).toHaveBeenCalled(); + expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2); + expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( + 1, + { type: 'user', text: 'Hello' }, + 0, + true, + ); + expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( + 2, + { type: 'gemini', text: 'Hi there!' }, + 1, + true, + ); + expect(mockRefreshStatic).toHaveBeenCalled(); + expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith( + clientHistory, + resumedData, + ); + }); + + it('should not load history if Gemini client is not initialized', () => { + const { result } = renderHook(() => + useSessionResume({ + ...getDefaultProps(), + isGeminiClientInitialized: false, + }), + ); + + const uiHistory: HistoryItemWithoutId[] = [ + { type: 'user', text: 'Hello' }, + ]; + const clientHistory = [ + { role: 'user' as const, parts: [{ text: 'Hello' }] }, + ]; + const resumedData: ResumedSessionData = { + conversation: { + sessionId: 'test-123', + projectHash: 'project-123', + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T01:00:00Z', + messages: [] as MessageRecord[], + }, + filePath: '/path/to/session.json', + }; + + act(() => { + result.current.loadHistoryForResume( + uiHistory, + clientHistory, + resumedData, + ); + }); + + expect(mockHistoryManager.clearItems).not.toHaveBeenCalled(); + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); + }); + + it('should handle empty history arrays', () => { + const { result } = renderHook(() => useSessionResume(getDefaultProps())); + + const resumedData: ResumedSessionData = { + conversation: { + sessionId: 'test-123', + projectHash: 'project-123', + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T01:00:00Z', + messages: [] as MessageRecord[], + }, + filePath: '/path/to/session.json', + }; + + act(() => { + result.current.loadHistoryForResume([], [], resumedData); + }); + + expect(mockHistoryManager.clearItems).toHaveBeenCalled(); + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(mockRefreshStatic).toHaveBeenCalled(); + expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData); + }); + }); + + describe('callback stability', () => { + it('should maintain stable loadHistoryForResume reference across renders', () => { + const { result, rerender } = renderHook(() => + useSessionResume(getDefaultProps()), + ); + + const initialCallback = result.current.loadHistoryForResume; + + rerender(); + + expect(result.current.loadHistoryForResume).toBe(initialCallback); + }); + + it('should update callback when config changes', () => { + const { result, rerender } = renderHook( + ({ config }) => + useSessionResume({ + ...getDefaultProps(), + config, + }), + { + initialProps: { config: mockConfig as unknown as Config }, + }, + ); + + const initialCallback = result.current.loadHistoryForResume; + + const newMockConfig = { + getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + }; + + rerender({ config: newMockConfig as unknown as Config }); + + expect(result.current.loadHistoryForResume).not.toBe(initialCallback); + }); + }); + + describe('automatic resume on mount', () => { + it('should not resume when resumedSessionData is not provided', () => { + renderHook(() => useSessionResume(getDefaultProps())); + + expect(mockHistoryManager.clearItems).not.toHaveBeenCalled(); + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); + }); + + it('should not resume when user is authenticating', () => { + const conversation: ConversationRecord = { + sessionId: 'auto-resume-123', + projectHash: 'project-123', + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T01:00:00Z', + messages: [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Test message', + type: 'user', + }, + ] as MessageRecord[], + }; + + renderHook(() => + useSessionResume({ + ...getDefaultProps(), + resumedSessionData: { + conversation, + filePath: '/path/to/session.json', + }, + isAuthenticating: true, + }), + ); + + expect(mockHistoryManager.clearItems).not.toHaveBeenCalled(); + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); + }); + + it('should automatically resume session when resumedSessionData is provided', async () => { + const conversation: ConversationRecord = { + sessionId: 'auto-resume-123', + projectHash: 'project-123', + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T01:00:00Z', + messages: [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Hello from resumed session', + type: 'user', + }, + { + id: 'msg-2', + timestamp: '2025-01-01T00:02:00Z', + content: 'Welcome back!', + type: 'gemini', + }, + ] as MessageRecord[], + }; + + renderHook(() => + useSessionResume({ + ...getDefaultProps(), + resumedSessionData: { + conversation, + filePath: '/path/to/session.json', + }, + }), + ); + + await waitFor(() => { + expect(mockHistoryManager.clearItems).toHaveBeenCalled(); + }); + + expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2); + expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( + 1, + { type: 'user', text: 'Hello from resumed session' }, + 0, + true, + ); + expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( + 2, + { type: 'gemini', text: 'Welcome back!' }, + 1, + true, + ); + expect(mockGeminiClient.resumeChat).toHaveBeenCalled(); + }); + + it('should only resume once even if props change', async () => { + const conversation: ConversationRecord = { + sessionId: 'auto-resume-123', + projectHash: 'project-123', + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T01:00:00Z', + messages: [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Test message', + type: 'user', + }, + ] as MessageRecord[], + }; + + const { rerender } = renderHook( + ({ refreshStatic }) => + useSessionResume({ + ...getDefaultProps(), + refreshStatic, + resumedSessionData: { + conversation, + filePath: '/path/to/session.json', + }, + }), + { + initialProps: { refreshStatic: mockRefreshStatic }, + }, + ); + + await waitFor(() => { + expect(mockHistoryManager.clearItems).toHaveBeenCalled(); + }); + + const clearItemsCallCount = ( + mockHistoryManager.clearItems as ReturnType + ).mock.calls.length; + + // Rerender with different refreshStatic + const newRefreshStatic = vi.fn(); + rerender({ refreshStatic: newRefreshStatic }); + + // Should not resume again + expect(mockHistoryManager.clearItems).toHaveBeenCalledTimes( + clearItemsCallCount, + ); + }); + + it('should convert session messages correctly during auto-resume', async () => { + const conversation: ConversationRecord = { + sessionId: 'auto-resume-with-tools', + projectHash: 'project-123', + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T01:00:00Z', + messages: [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: '/help', + type: 'user', + }, + { + id: 'msg-2', + timestamp: '2025-01-01T00:02:00Z', + content: 'Regular message', + type: 'user', + }, + ] as MessageRecord[], + }; + + renderHook(() => + useSessionResume({ + ...getDefaultProps(), + resumedSessionData: { + conversation, + filePath: '/path/to/session.json', + }, + }), + ); + + await waitFor(() => { + expect(mockGeminiClient.resumeChat).toHaveBeenCalled(); + }); + + // Check that the client history was called with filtered messages + // (slash commands should be filtered out) + const clientHistory = mockGeminiClient.resumeChat.mock.calls[0][0]; + + // Should only have the non-slash-command message + expect(clientHistory).toHaveLength(1); + expect(clientHistory[0]).toEqual({ + role: 'user', + parts: [{ text: 'Regular message' }], + }); + + // But UI history should have both + expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts new file mode 100644 index 00000000000..f41ff1d35ab --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useRef } from 'react'; +import type { Config, ResumedSessionData } from '@google/gemini-cli-core'; +import type { Part } from '@google/genai'; +import type { HistoryItemWithoutId } from '../types.js'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import { convertSessionToHistoryFormats } from './useSessionBrowser.js'; + +interface UseSessionResumeParams { + config: Config; + historyManager: UseHistoryManagerReturn; + refreshStatic: () => void; + isGeminiClientInitialized: boolean; + setQuittingMessages: (messages: null) => void; + resumedSessionData?: ResumedSessionData; + isAuthenticating: boolean; +} + +/** + * Hook to handle session resumption logic. + * Provides a callback to load history for resume and automatically + * handles command-line resume on mount. + */ +export function useSessionResume({ + config, + historyManager, + refreshStatic, + isGeminiClientInitialized, + setQuittingMessages, + resumedSessionData, + isAuthenticating, +}: UseSessionResumeParams) { + // Use refs to avoid dependency chain that causes infinite loop + const historyManagerRef = useRef(historyManager); + const refreshStaticRef = useRef(refreshStatic); + + useEffect(() => { + historyManagerRef.current = historyManager; + refreshStaticRef.current = refreshStatic; + }); + + const loadHistoryForResume = useCallback( + ( + uiHistory: HistoryItemWithoutId[], + clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>, + resumedData: ResumedSessionData, + ) => { + // Wait for the client. + if (!isGeminiClientInitialized) { + return; + } + + // Now that we have the client, load the history into the UI and the client. + setQuittingMessages(null); + historyManagerRef.current.clearItems(); + uiHistory.forEach((item, index) => { + historyManagerRef.current.addItem(item, index, true); + }); + refreshStaticRef.current(); // Force Static component to re-render with the updated history. + + // Give the history to the Gemini client. + config.getGeminiClient()?.resumeChat(clientHistory, resumedData); + }, + [config, isGeminiClientInitialized, setQuittingMessages], + ); + + // Handle interactive resume from the command line (-r/--resume without -p/--prompt-interactive). + // Only if we're not authenticating, though. + const hasLoadedResumedSession = useRef(false); + useEffect(() => { + if ( + resumedSessionData && + !isAuthenticating && + !hasLoadedResumedSession.current + ) { + hasLoadedResumedSession.current = true; + const historyData = convertSessionToHistoryFormats( + resumedSessionData.conversation.messages, + ); + loadHistoryForResume( + historyData.uiHistory, + historyData.clientHistory, + resumedSessionData, + ); + } + }, [resumedSessionData, isAuthenticating, loadHistoryForResume]); + + return { loadHistoryForResume }; +} From ae2bfd3d5cad85e32bba2353d35cadd159145513 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:05:15 -0600 Subject: [PATCH 07/11] Fuller deleteSession --- packages/cli/src/utils/sessions.ts | 35 ++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts index 95c97baa32b..bb583dc72f6 100644 --- a/packages/cli/src/utils/sessions.ts +++ b/packages/cli/src/utils/sessions.ts @@ -41,17 +41,38 @@ export async function deleteSession( sessionIndex: string, ): Promise { const sessionSelector = new SessionSelector(config); + const sessions = await sessionSelector.listSessions(); - let sessionToDelete: SessionInfo; - try { - sessionToDelete = await sessionSelector.findSession(sessionIndex); - } catch (error) { - console.error( - error instanceof Error ? error.message : 'Unknown error occurred.', - ); + if (sessions.length === 0) { + console.error('No sessions found for this project.'); return; } + // Sort sessions by start time to match list-sessions ordering + const sortedSessions = sessions.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + + let sessionToDelete: SessionInfo; + + // Try to find by UUID first + const sessionByUuid = sortedSessions.find( + (session) => session.id === sessionIndex, + ); + if (sessionByUuid) { + sessionToDelete = sessionByUuid; + } else { + // Parse session index + const index = parseInt(sessionIndex, 10); + if (isNaN(index) || index < 1 || index > sessions.length) { + console.error( + `Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`, + ); + return; + } + sessionToDelete = sortedSessions[index - 1]; + } + // Prevent deleting the current session if (sessionToDelete.isCurrentSession) { console.error('Cannot delete the current active session.'); From ee8bbe88bd641d846b34e9d52e5fbdbd86074d61 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Mon, 20 Oct 2025 13:49:07 -0600 Subject: [PATCH 08/11] Bug fix --- packages/cli/src/ui/hooks/useSessionResume.test.ts | 4 ---- packages/cli/src/ui/hooks/useSessionResume.ts | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/cli/src/ui/hooks/useSessionResume.test.ts b/packages/cli/src/ui/hooks/useSessionResume.test.ts index 33ddecbb8d3..6897760bbe2 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.test.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.test.ts @@ -99,13 +99,11 @@ describe('useSessionResume', () => { 1, { type: 'user', text: 'Hello' }, 0, - true, ); expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( 2, { type: 'gemini', text: 'Hi there!' }, 1, - true, ); expect(mockRefreshStatic).toHaveBeenCalled(); expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith( @@ -296,13 +294,11 @@ describe('useSessionResume', () => { 1, { type: 'user', text: 'Hello from resumed session' }, 0, - true, ); expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( 2, { type: 'gemini', text: 'Welcome back!' }, 1, - true, ); expect(mockGeminiClient.resumeChat).toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index f41ff1d35ab..68f833a3b16 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -59,7 +59,7 @@ export function useSessionResume({ setQuittingMessages(null); historyManagerRef.current.clearItems(); uiHistory.forEach((item, index) => { - historyManagerRef.current.addItem(item, index, true); + historyManagerRef.current.addItem(item, index); }); refreshStaticRef.current(); // Force Static component to re-render with the updated history. From 46a7042de8ed8fea07c8be8513e6e570b7808834 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Wed, 22 Oct 2025 18:15:03 -0600 Subject: [PATCH 09/11] Apply @jk-kashe's fix for a bug where resuming wouldn't work if authentication was in progress Thank you @jk-kashe! --- .../cli/src/ui/hooks/useSessionResume.test.ts | 32 +++++++++++++++++++ packages/cli/src/ui/hooks/useSessionResume.ts | 10 ++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/hooks/useSessionResume.test.ts b/packages/cli/src/ui/hooks/useSessionResume.test.ts index 6897760bbe2..4bbff957b1a 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.test.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.test.ts @@ -253,6 +253,38 @@ describe('useSessionResume', () => { expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); }); + it('should not resume when Gemini client is not initialized', () => { + const conversation: ConversationRecord = { + sessionId: 'auto-resume-123', + projectHash: 'project-123', + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T01:00:00Z', + messages: [ + { + id: 'msg-1', + timestamp: '2025-01-01T00:01:00Z', + content: 'Test message', + type: 'user', + }, + ] as MessageRecord[], + }; + + renderHook(() => + useSessionResume({ + ...getDefaultProps(), + resumedSessionData: { + conversation, + filePath: '/path/to/session.json', + }, + isGeminiClientInitialized: false, + }), + ); + + expect(mockHistoryManager.clearItems).not.toHaveBeenCalled(); + expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled(); + }); + it('should automatically resume session when resumedSessionData is provided', async () => { const conversation: ConversationRecord = { sessionId: 'auto-resume-123', diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index 68f833a3b16..e738b5ce56c 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -70,12 +70,13 @@ export function useSessionResume({ ); // Handle interactive resume from the command line (-r/--resume without -p/--prompt-interactive). - // Only if we're not authenticating, though. + // Only if we're not authenticating and the client is initialized, though. const hasLoadedResumedSession = useRef(false); useEffect(() => { if ( resumedSessionData && !isAuthenticating && + isGeminiClientInitialized && !hasLoadedResumedSession.current ) { hasLoadedResumedSession.current = true; @@ -88,7 +89,12 @@ export function useSessionResume({ resumedSessionData, ); } - }, [resumedSessionData, isAuthenticating, loadHistoryForResume]); + }, [ + resumedSessionData, + isAuthenticating, + isGeminiClientInitialized, + loadHistoryForResume, + ]); return { loadHistoryForResume }; } From 14200bd465bb230c60d6bbb6312d7f9da5cd5761 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Thu, 6 Nov 2025 12:37:09 -0700 Subject: [PATCH 10/11] Fix --- packages/cli/src/gemini.test.tsx | 6 +++++- packages/cli/src/ui/hooks/useSessionResume.test.ts | 8 +++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 8f4bb8af8b3..ffa59966300 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -21,7 +21,7 @@ import { } from './gemini.js'; import { type LoadedSettings } from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; -import { type Config } from '@google/gemini-cli-core'; +import { type Config, type ResumedSessionData } from '@google/gemini-cli-core'; import { act } from 'react'; import { type InitializationResult } from './core/initializer.js'; @@ -495,6 +495,7 @@ describe('startInteractiveUI', () => { settings: LoadedSettings, startupWarnings: string[], workspaceRoot: string, + resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, ) { await act(async () => { @@ -503,6 +504,7 @@ describe('startInteractiveUI', () => { settings, startupWarnings, workspaceRoot, + resumedSessionData, initializationResult, ); }); @@ -572,6 +574,7 @@ describe('startInteractiveUI', () => { mockSettings, mockStartupWarnings, mockWorkspaceRoot, + undefined, mockInitializationResult, ); @@ -588,6 +591,7 @@ describe('startInteractiveUI', () => { mockSettings, mockStartupWarnings, mockWorkspaceRoot, + undefined, mockInitializationResult, ); diff --git a/packages/cli/src/ui/hooks/useSessionResume.test.ts b/packages/cli/src/ui/hooks/useSessionResume.test.ts index 4bbff957b1a..93787b8d50f 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.test.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.test.ts @@ -4,7 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { renderHook, act, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { useSessionResume } from './useSessionResume.js'; import type { Config, @@ -190,7 +192,7 @@ describe('useSessionResume', () => { it('should update callback when config changes', () => { const { result, rerender } = renderHook( - ({ config }) => + ({ config }: { config: Config }) => useSessionResume({ ...getDefaultProps(), config, @@ -352,7 +354,7 @@ describe('useSessionResume', () => { }; const { rerender } = renderHook( - ({ refreshStatic }) => + ({ refreshStatic }: { refreshStatic: () => void }) => useSessionResume({ ...getDefaultProps(), refreshStatic, From b14040c54aff7f65a9d9e5d16cdd296ac04f528c Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Sun, 9 Nov 2025 19:49:11 -0700 Subject: [PATCH 11/11] Fix new test failures from merges --- packages/cli/src/ui/AppContainer.test.tsx | 26 +++++++++++++------ .../src/ui/hooks/atCommandProcessor.test.ts | 2 +- packages/cli/src/utils/cleanup.test.ts | 2 +- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index cc571bb74fe..f9308658567 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -596,7 +596,7 @@ describe('AppContainer State Management', () => { }); describe('Session Resumption', () => { - it('handles resumed session data correctly', () => { + it('handles resumed session data correctly', async () => { const mockResumedSessionData = { conversation: { sessionId: 'test-session-123', @@ -623,8 +623,9 @@ describe('AppContainer State Management', () => { filePath: '/tmp/test-session.json', }; - expect(() => { - render( + let unmount: () => void; + await act(async () => { + const result = render( { resumedSessionData={mockResumedSessionData} />, ); - }).not.toThrow(); + unmount = result.unmount; + }); + await act(async () => { + unmount(); + }); }); - it('renders without resumed session data', () => { - expect(() => { - render( + it('renders without resumed session data', async () => { + let unmount: () => void; + await act(async () => { + const result = render( { resumedSessionData={undefined} />, ); - }).not.toThrow(); + unmount = result.unmount; + }); + await act(async () => { + unmount(); + }); }); it('initializes chat recording service when config has it', () => { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 0828b6684ed..a2f6c35f66c 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -278,7 +278,7 @@ describe('handleAtCommand', () => { }), 125, ); - }); + }, 10000); it('should handle multiple @file references', async () => { const content1 = 'Content file1'; diff --git a/packages/cli/src/utils/cleanup.test.ts b/packages/cli/src/utils/cleanup.test.ts index 87ce5e1dbe7..80ed40f9765 100644 --- a/packages/cli/src/utils/cleanup.test.ts +++ b/packages/cli/src/utils/cleanup.test.ts @@ -16,7 +16,7 @@ describe('cleanup', () => { const cleanupModule = await import('./cleanup.js'); register = cleanupModule.registerCleanup; runExit = cleanupModule.runExitCleanup; - }); + }, 30000); it('should run a registered synchronous function', async () => { const cleanupFn = vi.fn();