From 6893d274415a85f9f1b167cf3214cd9b396b29de Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:31:00 -0700 Subject: [PATCH] feat(sessions): add resuming to geminiChat and add CLI flags for session management (#10719) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/cli/src/config/config.test.ts | 30 + packages/cli/src/config/config.ts | 39 ++ packages/cli/src/gemini.test.tsx | 16 +- packages/cli/src/gemini.tsx | 41 +- packages/cli/src/nonInteractiveCli.test.ts | 2 + packages/cli/src/nonInteractiveCli.ts | 14 + packages/cli/src/ui/AppContainer.test.tsx | 328 ++++++++++ packages/cli/src/ui/AppContainer.tsx | 18 +- .../src/ui/hooks/atCommandProcessor.test.ts | 2 +- .../src/ui/hooks/useSessionBrowser.test.ts | 591 ++++++++++++++++++ .../cli/src/ui/hooks/useSessionBrowser.ts | 178 ++++++ .../cli/src/ui/hooks/useSessionResume.test.ts | 440 +++++++++++++ packages/cli/src/ui/hooks/useSessionResume.ts | 100 +++ packages/cli/src/utils/cleanup.test.ts | 2 +- packages/cli/src/utils/sessionCleanup.test.ts | 76 +++ packages/cli/src/utils/sessionUtils.test.ts | 363 +++++++++++ packages/cli/src/utils/sessionUtils.ts | 207 +++++- 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 +- 21 files changed, 2578 insertions(+), 11 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/ui/hooks/useSessionResume.test.ts create mode 100644 packages/cli/src/ui/hooks/useSessionResume.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.test.ts b/packages/cli/src/config/config.test.ts index 9a76646dbe..6f9a66b8b3 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -451,6 +451,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', diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 73c2398d6b..7396728d3c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -61,6 +61,9 @@ export interface CliArgs { experimentalAcp: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; + resume: string | 'latest' | undefined; + listSessions: boolean | undefined; + deleteSession: string | undefined; includeDirectories: string[] | undefined; screenReader: boolean | undefined; useSmartEdit: boolean | undefined; @@ -172,6 +175,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, @@ -227,6 +259,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.'; } @@ -585,6 +622,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, enabledExtensions: argv.extensions, extensionLoader: extensionManager, enableExtensionReloading: settings.experimental?.extensionReloading, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 3b88d6bb31..ffa5996630 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'; @@ -189,6 +189,8 @@ describe('gemini.tsx main function', () => { getSandbox: () => false, getDebugMode: () => false, getListExtensions: () => false, + getListSessions: () => false, + getDeleteSession: () => undefined, getMcpServers: () => ({}), getMcpClientManager: vi.fn(), initialize: vi.fn(), @@ -339,6 +341,8 @@ describe('gemini.tsx main function kitty protocol', () => { getSandbox: () => false, getDebugMode: () => false, getListExtensions: () => false, + getListSessions: () => false, + getDeleteSession: () => undefined, getMcpServers: () => ({}), getMcpClientManager: vi.fn(), initialize: vi.fn(), @@ -391,6 +395,9 @@ describe('gemini.tsx main function kitty protocol', () => { screenReader: undefined, useSmartEdit: undefined, useWriteTodos: undefined, + resume: undefined, + listSessions: undefined, + deleteSession: undefined, outputFormat: undefined, fakeResponses: undefined, recordResponses: undefined, @@ -488,6 +495,7 @@ describe('startInteractiveUI', () => { settings: LoadedSettings, startupWarnings: string[], workspaceRoot: string, + resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, ) { await act(async () => { @@ -496,6 +504,7 @@ describe('startInteractiveUI', () => { settings, startupWarnings, workspaceRoot, + resumedSessionData, initializationResult, ); }); @@ -510,6 +519,7 @@ describe('startInteractiveUI', () => { mockSettings, mockStartupWarnings, mockWorkspaceRoot, + undefined, mockInitializationResult, ); @@ -538,6 +548,7 @@ describe('startInteractiveUI', () => { mockSettings, mockStartupWarnings, mockWorkspaceRoot, + undefined, mockInitializationResult, ); @@ -563,6 +574,7 @@ describe('startInteractiveUI', () => { mockSettings, mockStartupWarnings, mockWorkspaceRoot, + undefined, mockInitializationResult, ); @@ -579,6 +591,7 @@ describe('startInteractiveUI', () => { mockSettings, mockStartupWarnings, mockWorkspaceRoot, + undefined, mockInitializationResult, ); @@ -610,6 +623,7 @@ describe('startInteractiveUI', () => { mockSettings, mockStartupWarnings, mockWorkspaceRoot, + undefined, mockInitializationResult, ); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index aa9b7e3588..330de4beab 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -32,7 +32,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, @@ -55,6 +55,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'; import { MouseProvider } from './ui/contexts/MouseContext.js'; @@ -68,6 +69,7 @@ import { relaunchOnExitCode, } from './utils/relaunch.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { deleteSession, listSessions } from './utils/sessions.js'; import { ExtensionManager } from './config/extension-manager.js'; import { createPolicyUpdater } from './config/policy.js'; import { requestConsentNonInteractive } from './config/extensions/consent.js'; @@ -152,6 +154,7 @@ export async function startInteractiveUI( settings: LoadedSettings, startupWarnings: string[], workspaceRoot: string = process.cwd(), + resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, ) { // When not in screen reader mode, disable line wrapping. @@ -206,6 +209,7 @@ export async function startInteractiveUI( settings={settings} startupWarnings={startupWarnings} version={version} + resumedSessionData={resumedSessionData} initializationResult={initializationResult} /> @@ -415,6 +419,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; if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { // Set this as early as possible to avoid spurious characters from @@ -461,6 +478,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()) { await startInteractiveUI( @@ -468,6 +505,7 @@ export async function main() { settings, startupWarnings, process.cwd(), + resumedSessionData, initializationResult, ); return; @@ -521,6 +559,7 @@ export async function main() { input, prompt_id, hasDeprecatedPromptArg, + resumedSessionData, }); // Call cleanup before process.exit, which causes cleanup to not run await runExitCleanup(); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index eea524256c..d9d8101ac2 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -93,6 +93,7 @@ describe('runNonInteractive', () => { let processStderrSpy: MockInstance; let mockGeminiClient: { sendMessageStream: Mock; + resumeChat: Mock; getChatRecordingService: Mock; }; const MOCK_SESSION_METRICS: SessionMetrics = { @@ -142,6 +143,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 a6b5477567..fca533c868 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -7,6 +7,7 @@ import type { Config, ToolCallRequestInfo, + ResumedSessionData, CompletedToolCall, UserFeedbackPayload, } from '@google/gemini-cli-core'; @@ -32,6 +33,7 @@ import { import type { Content, Part } from '@google/genai'; import readline from 'node:readline'; +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'; @@ -49,6 +51,7 @@ interface RunNonInteractiveParams { input: string; prompt_id: string; hasDeprecatedPromptArg?: boolean; + resumedSessionData?: ResumedSessionData; } export async function runNonInteractive({ @@ -57,6 +60,7 @@ export async function runNonInteractive({ input, prompt_id, hasDeprecatedPromptArg, + resumedSessionData, }: RunNonInteractiveParams): Promise { return promptIdContext.run(prompt_id, async () => { const consolePatcher = new ConsolePatcher({ @@ -185,6 +189,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, + ); + } + // Emit init event for streaming JSON if (streamFormatter) { streamFormatter.emitEvent({ diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index cee6e918d7..df63e54483 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -595,6 +595,334 @@ describe('AppContainer State Management', () => { }); }); + describe('Session Resumption', () => { + it('handles resumed session data correctly', async () => { + 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', + }; + + let unmount: () => void; + await act(async () => { + const result = render( + , + ); + unmount = result.unmount; + }); + await act(async () => { + unmount(); + }); + }); + + it('renders without resumed session data', async () => { + let unmount: () => void; + await act(async () => { + const result = render( + , + ); + unmount = result.unmount; + }); + await act(async () => { + unmount(); + }); + }); + + 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', async () => { // 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 0eee49d44e..6271141fbc 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -42,6 +42,7 @@ import { getAllGeminiMdFilenames, AuthType, clearCachedCredentialFile, + type ResumedSessionData, recordExitFail, ShellExecutionService, saveApiKey, @@ -105,6 +106,7 @@ import { useExtensionUpdates, } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; +import { useSessionResume } from './hooks/useSessionResume.js'; import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js'; @@ -129,6 +131,7 @@ interface AppContainerProps { startupWarnings?: string[]; version: string; initializationResult: InitializationResult; + resumedSessionData?: ResumedSessionData; } /** @@ -144,7 +147,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); @@ -395,6 +398,19 @@ 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(); + + useSessionResume({ + config, + historyManager, + refreshStatic, + isGeminiClientInitialized, + setQuittingMessages, + resumedSessionData, + isAuthenticating, + }); + // Create handleAuthSelect wrapper for backward compatibility const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 0828b6684e..a2f6c35f66 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/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts new file mode 100644 index 0000000000..6cf878dcd1 --- /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 0000000000..9f159aac4d --- /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/ui/hooks/useSessionResume.test.ts b/packages/cli/src/ui/hooks/useSessionResume.test.ts new file mode 100644 index 0000000000..93787b8d50 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionResume.test.ts @@ -0,0 +1,440 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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, + 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, + ); + expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( + 2, + { type: 'gemini', text: 'Hi there!' }, + 1, + ); + 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 }: { config: 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 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', + 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, + ); + expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith( + 2, + { type: 'gemini', text: 'Welcome back!' }, + 1, + ); + 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 }: { refreshStatic: () => void }) => + 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 0000000000..e738b5ce56 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -0,0 +1,100 @@ +/** + * @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); + }); + 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 and the client is initialized, though. + const hasLoadedResumedSession = useRef(false); + useEffect(() => { + if ( + resumedSessionData && + !isAuthenticating && + isGeminiClientInitialized && + !hasLoadedResumedSession.current + ) { + hasLoadedResumedSession.current = true; + const historyData = convertSessionToHistoryFormats( + resumedSessionData.conversation.messages, + ); + loadHistoryForResume( + historyData.uiHistory, + historyData.clientHistory, + resumedSessionData, + ); + } + }, [ + resumedSessionData, + isAuthenticating, + isGeminiClientInitialized, + loadHistoryForResume, + ]); + + return { loadHistoryForResume }; +} diff --git a/packages/cli/src/utils/cleanup.test.ts b/packages/cli/src/utils/cleanup.test.ts index 87ce5e1dbe..80ed40f976 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(); diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts index 01939df5ea..34aff03d63 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 0000000000..5c53e8bd54 --- /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 44843ae323..3be8ca7a9f 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,142 @@ 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()); + } + + /** + * Finds a session by identifier (UUID or numeric index). + * + * @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 findSession(identifier: 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) + 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 { + 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}`, + ); + } + } + + 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 0000000000..bb583dc72f --- /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 6a115f0385..c2196f53c3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -255,6 +255,8 @@ export interface ConfigParameters { model: string; maxSessionTurns?: number; experimentalZedIntegration?: boolean; + listSessions?: boolean; + deleteSession?: string; listExtensions?: boolean; extensionLoader?: ExtensionLoader; enabledExtensions?: string[]; @@ -311,7 +313,7 @@ export class Config { private blockedMcpServers: string[]; private promptRegistry!: PromptRegistry; private agentRegistry!: AgentRegistry; - private readonly sessionId: string; + private sessionId: string; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGenerator!: ContentGenerator; @@ -360,6 +362,8 @@ export class Config { private inFallbackMode = false; private readonly maxSessionTurns: number; + private readonly listSessions: boolean; + private readonly deleteSession: string | undefined; private readonly listExtensions: boolean; private readonly _extensionLoader: ExtensionLoader; private readonly _enabledExtensions: string[]; @@ -473,6 +477,8 @@ export class Config { this.maxSessionTurns = params.maxSessionTurns ?? -1; this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; + this.listSessions = params.listSessions ?? false; + this.deleteSession = params.deleteSession; this.listExtensions = params.listExtensions ?? false; this._extensionLoader = params.extensionLoader ?? new SimpleExtensionLoader([]); @@ -709,6 +715,10 @@ export class Config { return this.sessionId; } + setSessionId(sessionId: string): void { + this.sessionId = sessionId; + } + shouldLoadMemoryFromIncludeDirectories(): boolean { return this.loadMemoryFromIncludeDirectories; } @@ -1033,6 +1043,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 f190fb5e6a..0400c7f835 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -26,7 +26,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, @@ -152,6 +155,13 @@ export class GeminiClient { this.updateTelemetryTokenCount(); } + async resumeChat( + history: Content[], + resumedSessionData?: ResumedSessionData, + ): Promise { + this.chat = await this.startChat(history, resumedSessionData); + } + getChatRecordingService(): ChatRecordingService | undefined { return this.chat?.getChatRecordingService(); } @@ -175,7 +185,10 @@ export class GeminiClient { }); } - async startChat(extraHistory?: Content[]): Promise { + async startChat( + extraHistory?: Content[], + resumedSessionData?: ResumedSessionData, + ): Promise { this.forceFullIdeContext = true; this.hasFailedCompressionAttempt = false; @@ -207,6 +220,7 @@ export class GeminiClient { tools, }, history, + resumedSessionData, ); } catch (error) { await reportError( diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 54553bc4e4..86dd4e718b 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -30,7 +30,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, @@ -191,10 +194,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); this.lastPromptTokenCount = Math.ceil( JSON.stringify(this.history).length / 4, );