diff --git a/.gitignore b/.gitignore index af3591bda23..32be8052a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ packages/*/coverage/ packages/cli/src/generated/ .integration-tests/ packages/vscode-ide-companion/*.vsix + +tmp_run_preflight_without_proxy.sh \ No newline at end of file diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 55780320828..6b458f878a0 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -156,6 +156,10 @@ describe('loadCliConfig', () => { vi.resetAllMocks(); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); process.env.GEMINI_API_KEY = 'test-api-key'; // Ensure API key is set for tests + delete process.env.https_proxy; + delete process.env.http_proxy; + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; }); afterEach(() => { diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index c353d0c16bb..47c6185192f 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -101,6 +101,7 @@ export interface Settings { // Add other settings here. ideMode?: boolean; + 'session.persistence'?: boolean; memoryDiscoveryMaxDirs?: number; } diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 7b5aa8d0bed..130c3515f4a 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -48,7 +48,9 @@ import { HistoryItemDisplay } from './components/HistoryItemDisplay.js'; import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js'; import { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js'; import { useHistory } from './hooks/useHistoryManager.js'; +import { useSessionPersistence } from './hooks/useSessionPersistence.js'; import process from 'node:process'; + import { getErrorMessage, type Config, @@ -118,6 +120,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { }, []); const { history, addItem, clearItems, loadHistory } = useHistory(); + const sessionPersistence = settings.merged['session.persistence']; + const [sessionLoaded, setSessionLoaded] = useState(false); + const onLoadComplete = useCallback(() => setSessionLoaded(true), []); + useSessionPersistence({ + sessionPersistence, + history, + loadHistory, + onLoadComplete, + }); const { consoleMessages, handleNewMessage, @@ -698,6 +709,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if ( initialPrompt && !initialPromptSubmitted.current && + sessionLoaded && !isAuthenticating && !isAuthDialogOpen && !isThemeDialogOpen && @@ -711,6 +723,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { }, [ initialPrompt, submitQuery, + sessionLoaded, isAuthenticating, isAuthDialogOpen, isThemeDialogOpen, @@ -722,7 +735,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if (quittingMessages) { return ( - {quittingMessages.map((item) => ( + {quittingMessages.map((item: HistoryItem) => ( { )} {!settings.merged.hideTips && } , - ...history.map((h) => ( + ...history.map((h: HistoryItem) => ( { )), ]} > - {(item) => item} + {(item: React.ReactNode) => item} diff --git a/packages/cli/src/ui/hooks/useSessionPersistence.test.ts b/packages/cli/src/ui/hooks/useSessionPersistence.test.ts new file mode 100644 index 00000000000..4eaa7235bd2 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionPersistence.test.ts @@ -0,0 +1,314 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useSessionPersistence } from './useSessionPersistence.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import process from 'node:process'; +import { MessageType, HistoryItem } from '../types.js'; + +// Spy on process.on/off to capture the exit handler +let mockProcessOn: Mock; +let mockProcessOff: Mock; + +describe('useSessionPersistence - Integration Test', () => { + let mockHistory: HistoryItem[]; + let mockLoadHistory: Mock; + let mockOnLoadComplete: Mock; + let originalCwd = ''; + + vi.mock('os', async (importOriginal) => { + const actualOs = await importOriginal(); + return { + ...actualOs, + homedir: vi.fn(() => 'home-user'), + }; + }); + + beforeEach(() => { + // Store original CWD and create a temp directory + originalCwd = process.cwd(); + fs.mkdirSync(os.homedir()); + process.chdir(os.homedir()); + + mockHistory = []; + mockLoadHistory = vi.fn(); + mockOnLoadComplete = vi.fn(); + mockOnLoadComplete = vi.fn(); + mockOnLoadComplete = vi.fn(); + mockOnLoadComplete = vi.fn(); + mockOnLoadComplete = vi.fn(); + + // Spy on process.on/off + mockProcessOn = vi.spyOn(process, 'on'); + mockProcessOff = vi.spyOn(process, 'off'); + }); + + afterEach(() => { + // Restore CWD and clean up the temp directory + process.chdir(originalCwd); + fs.rmSync(os.homedir(), { recursive: true, force: true }); + + // Restore mocks + vi.restoreAllMocks(); + }); + + it('should not create any files or register handlers if persistence is disabled', () => { + renderHook(() => + useSessionPersistence({ + sessionPersistence: false, + history: mockHistory, + loadHistory: mockLoadHistory, + onLoadComplete: mockOnLoadComplete, + }), + ); + + const geminiDir = path.join(os.homedir(), '.gemini'); + expect(fs.existsSync(geminiDir)).toBe(false); + expect(mockProcessOn).not.toHaveBeenCalled(); + }); + + it('should save session history on exit when persistence is enabled', () => { + // Add items to history before rendering the hook + mockHistory.push( + { id: 1, type: MessageType.USER, text: 'User message 1' }, + { id: 2, type: MessageType.GEMINI, text: 'Gemini response 1' }, + { id: 3, type: MessageType.INFO, text: 'Info message' }, // Should be filtered + ); + + const { unmount } = renderHook(() => + useSessionPersistence({ + sessionPersistence: true, + history: mockHistory, + loadHistory: mockLoadHistory, + onLoadComplete: mockOnLoadComplete, + }), + ); + + // Find the registered exit handler + const exitHandler = mockProcessOn.mock.calls.find( + (call) => call[0] === 'exit', + )?.[1]; + expect(exitHandler).toBeDefined(); + + // Manually trigger the exit handler to save the session + exitHandler(); + + const sessionPath = path.join(os.homedir(), '.gemini', 'session.json'); + expect(fs.existsSync(sessionPath)).toBe(true); + + const savedData = JSON.parse(fs.readFileSync(sessionPath, 'utf-8')); + const expectedData = [ + { type: MessageType.USER, text: 'User message 1' }, + { type: MessageType.GEMINI, text: 'Gemini response 1' }, + ]; + + expect(savedData).toEqual(expectedData); + + // Ensure the handler is unregistered on unmount + unmount(); + expect(mockProcessOff).toHaveBeenCalledWith('exit', exitHandler); + }); + + it('should load session history on startup if file exists', async () => { + const geminiDir = path.join(os.homedir(), '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + + const sessionPath = path.join(geminiDir, 'session.json'); + const savedHistory = [ + { type: MessageType.USER, text: 'Loaded user message' }, + { type: MessageType.GEMINI, text: 'Loaded gemini response' }, + ]; + fs.writeFileSync(sessionPath, JSON.stringify(savedHistory)); + + const initialHistory = [ + { id: 100, type: MessageType.USER, text: 'Initial message' }, + ]; + mockHistory.push(...initialHistory); + + renderHook(() => + useSessionPersistence({ + sessionPersistence: true, + history: mockHistory, + loadHistory: mockLoadHistory, + onLoadComplete: mockOnLoadComplete, + }), + ); + + // The hook loads asynchronously, so we wait for the loadHistory mock to be called + await waitFor(() => { + expect(mockLoadHistory).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + type: MessageType.USER, + text: 'Loaded user message', + id: expect.any(Number), + }), + expect.objectContaining({ + type: MessageType.GEMINI, + text: 'Loaded gemini response', + id: expect.any(Number), + }), + expect.objectContaining({ + type: MessageType.USER, + text: 'Initial message', + id: 100, + }), + ]), + ); + + const loadedItems = mockLoadHistory.mock.calls[0][0]; + loadedItems.forEach((item: HistoryItem) => { + if (item.id !== 100) { + // Only check loaded items for negative IDs + expect(item.id).toBeLessThan(0); + } + }); + }); + }); + + it('should not throw or call loadHistory if session file is empty or corrupt', async () => { + const geminiDir = path.join(os.homedir(), '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + const sessionPath = path.join(geminiDir, 'session.json'); + fs.writeFileSync(sessionPath, 'corrupt data'); + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + renderHook(() => + useSessionPersistence({ + sessionPersistence: true, + history: mockHistory, + loadHistory: mockLoadHistory, + onLoadComplete: mockOnLoadComplete, + }), + ); + + // Wait for the async load to finish + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error loading session history:', + expect.any(Error), + ); + }); + + expect(mockLoadHistory).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('should not call loadHistory if session file contains valid JSON but not an array', async () => { + const geminiDir = path.join(os.homedir(), '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + const sessionPath = path.join(geminiDir, 'session.json'); + fs.writeFileSync(sessionPath, '{"key": "value"}'); // Valid JSON, but not an array + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + renderHook(() => + useSessionPersistence({ + sessionPersistence: true, + history: mockHistory, + loadHistory: mockLoadHistory, + onLoadComplete: mockOnLoadComplete, + }), + ); + + // Give async operations a chance to run + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockLoadHistory).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); // No error should be logged for this case + consoleErrorSpy.mockRestore(); + }); + + it('should not call loadHistory if session file contains malformed history items', async () => { + const geminiDir = path.join(os.homedir(), '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + const sessionPath = path.join(geminiDir, 'session.json'); + fs.writeFileSync(sessionPath, '[null, {"foo": "bar"}]'); // Malformed history items + + renderHook(() => + useSessionPersistence({ + sessionPersistence: true, + history: mockHistory, + loadHistory: mockLoadHistory, + onLoadComplete: mockOnLoadComplete, + }), + ); + + // Give async operations a chance to run + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockLoadHistory).not.toHaveBeenCalled(); + }); + + it('should not throw or call loadHistory if session file does not exist', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + renderHook(() => + useSessionPersistence({ + sessionPersistence: true, + history: mockHistory, + loadHistory: mockLoadHistory, + onLoadComplete: mockOnLoadComplete, + }), + ); + + // Give async operations a chance to run, though none should happen + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockLoadHistory).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); + + it('should log error if saving fails due to file system permissions', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Create a read-only directory to simulate permission error + const geminiDir = path.join(os.homedir(), '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + fs.chmodSync(geminiDir, 0o444); // Set read-only permissions + + // Add items to history before rendering the hook + mockHistory.push({ id: 1, type: MessageType.USER, text: 'User message' }); + + const { unmount } = renderHook(() => + useSessionPersistence({ + sessionPersistence: true, + history: mockHistory, + loadHistory: mockLoadHistory, + onLoadComplete: mockOnLoadComplete, + }), + ); + + const exitHandler = mockProcessOn.mock.calls.find( + (call) => call[0] === 'exit', + )?.[1]; + expect(exitHandler).toBeDefined(); + + exitHandler(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error saving session history:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSessionPersistence.ts b/packages/cli/src/ui/hooks/useSessionPersistence.ts new file mode 100644 index 00000000000..5fd18c19b9c --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionPersistence.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef } from 'react'; +import * as fs from 'fs'; +import { promises as fsp } from 'fs'; +import * as path from 'path'; +import process from 'node:process'; +import { USER_SETTINGS_DIR } from '../../config/settings.js'; +import { + HistoryItem, + MessageType, + HistoryItemUser, + HistoryItemGemini, +} from '../types.js'; + +interface UseSessionPersistenceProps { + sessionPersistence: boolean | undefined; + history: HistoryItem[]; + loadHistory: (history: HistoryItem[]) => void; + onLoadComplete: () => void; +} + +export const useSessionPersistence = ({ + sessionPersistence, + history, + loadHistory, + onLoadComplete, +}: UseSessionPersistenceProps) => { + const historyRef = useRef(history); + + useEffect(() => { + historyRef.current = history; + }, [history]); + useEffect(() => { + let isMounted = true; + + const loadSession = async () => { + try { + if (sessionPersistence) { + const sessionPath = path.join(USER_SETTINGS_DIR, 'session.json'); + try { + const sessionData = await fsp.readFile(sessionPath, 'utf-8'); + const parsedHistory = JSON.parse(sessionData); + + if (!isMounted) return; + + if (Array.isArray(parsedHistory)) { + const historyWithIds: HistoryItem[] = parsedHistory + .filter( + ( + item: unknown, + ): item is HistoryItemUser | HistoryItemGemini => + item !== null && + typeof item === 'object' && + 'type' in item && + (item.type === MessageType.USER || + item.type === MessageType.GEMINI) && + 'text' in item && + typeof (item as { text: unknown }).text === 'string', + ) + .map((item, index) => ({ + type: item.type, + text: item.text, + id: -(index + 1), + })); + if (historyWithIds.length > 0) { + loadHistory([...historyWithIds, ...historyRef.current]); + } + } + } catch (error) { + if (!isMounted) return; + + // Silently ignore if file doesn't exist. + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + console.error('Error loading session history:', error); + } + } + } finally { + onLoadComplete(); + } + }; + + void loadSession(); + + return () => { + isMounted = false; + }; + }, [sessionPersistence, loadHistory, onLoadComplete]); + + useEffect(() => { + if (!sessionPersistence) { + return; + } + + const saveSession = () => { + // The surrounding useEffect ensures this only runs when persistence is enabled. + try { + const geminiDir = USER_SETTINGS_DIR; + if (!fs.existsSync(geminiDir)) { + fs.mkdirSync(geminiDir, { recursive: true }); + } + const sessionPath = path.join(geminiDir, 'session.json'); + + // Create a serializable version of the history + const MAX_PERSISTED_HISTORY = 200; // A reasonable default, could be made configurable. + const serializableHistory = historyRef.current + .filter( + (item) => + item.type === MessageType.USER || + item.type === MessageType.GEMINI, + ) + .slice(-MAX_PERSISTED_HISTORY) + .map((item) => ({ type: item.type, text: item.text })); + + fs.writeFileSync( + sessionPath, + JSON.stringify(serializableHistory, null, 2), + ); + } catch (error) { + console.error('Error saving session history:', error); + } + }; + + process.on('exit', saveSession); + + return () => { + process.off('exit', saveSession); + }; + }, [sessionPersistence]); +}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index da95d6ec35d..114e463d85c 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -163,6 +163,7 @@ export enum MessageType { QUIT = 'quit', GEMINI = 'gemini', COMPRESSION = 'compression', + TOOL_GROUP = 'tool_group', } // Simplified message structure for internal feedback