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