diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 331ec0c018..7a94852b57 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -708,12 +708,13 @@ export async function main() { } catch (error) { if ( error instanceof SessionError && - error.code === 'NO_SESSIONS_FOUND' + (error.code === 'NO_SESSIONS_FOUND' || + error.code === 'INVALID_SESSION_IDENTIFIER') ) { - // No sessions to resume — start a fresh session with a warning + // No sessions to resume or invalid session ID — start a fresh session with a warning startupWarnings.push({ - id: 'resume-no-sessions', - message: error.message, + id: 'resume-failure', + message: 'Could not resume session. Started a new session.', priority: WarningPriority.High, }); } else { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 67f2d5dd84..b8d5f5c62a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -791,7 +791,7 @@ export const AppContainer = (props: AppContainerProps) => { Logging in with Google... Restarting Gemini CLI to continue. ---------------------------------------------------------------- `); - await relaunchApp(); + await relaunchApp(config.getSessionId()); } } setAuthState(AuthState.Authenticated); @@ -2466,7 +2466,7 @@ Logging in with Google... Restarting Gemini CLI to continue. }); } } - await relaunchApp(); + await relaunchApp(config.getSessionId()); }, handleNewAgentsSelect: async (choice: NewAgentsChoice) => { if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) { diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx index 94ca359b59..c63fc12f38 100644 --- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx @@ -35,7 +35,7 @@ export const LoginWithGoogleRestartDialog = ({ }); } } - await relaunchApp(); + await relaunchApp(config.getSessionId()); }, 100); return true; } diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 5119c1b343..11349ccdcd 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -230,7 +230,7 @@ export const DialogManager = ({ uiActions.closeSettingsDialog()} - onRestartRequest={relaunchApp} + onRestartRequest={() => relaunchApp(config.getSessionId())} availableTerminalHeight={terminalHeight - staticExtraHeight} /> diff --git a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx index 32e451a542..63b5f114b1 100644 --- a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx +++ b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx @@ -8,6 +8,7 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { relaunchApp } from '../../utils/processUtils.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import { debugLogger } from '@google/gemini-cli-core'; @@ -16,11 +17,12 @@ interface IdeTrustChangeDialogProps { } export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => { + const config = useConfig(); useKeypress( (key) => { if (key.name === 'r' || key.name === 'R') { // eslint-disable-next-line @typescript-eslint/no-floating-promises - relaunchApp(); + relaunchApp(config.getSessionId()); return true; } return false; diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx index d555ee2fed..441091d9c2 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.tsx @@ -11,6 +11,7 @@ import * as path from 'node:path'; import { TrustLevel } from '../../config/trustedFolders.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import { theme } from '../semantic-colors.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { relaunchApp } from '../../utils/processUtils.js'; @@ -33,6 +34,7 @@ export function PermissionsModifyTrustDialog({ const currentDirectory = targetDirectory ?? process.cwd(); const dirName = path.basename(currentDirectory); const parentFolder = path.basename(path.dirname(currentDirectory)); + const config = useConfig(); const TRUST_LEVEL_ITEMS = [ { @@ -72,7 +74,7 @@ export function PermissionsModifyTrustDialog({ void (async () => { const success = await commitTrustLevelChange(); if (success) { - void relaunchApp(); + void relaunchApp(config.getSessionId()); } else { onExit(); } diff --git a/packages/cli/src/utils/processUtils.test.ts b/packages/cli/src/utils/processUtils.test.ts index 3e6b7913e9..e633825d55 100644 --- a/packages/cli/src/utils/processUtils.test.ts +++ b/packages/cli/src/utils/processUtils.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { RELAUNCH_EXIT_CODE, relaunchApp, @@ -29,10 +29,35 @@ describe('processUtils', () => { afterEach(() => vi.clearAllMocks()); - it('should wait for updates, run cleanup, and exit with the relaunch code', async () => { + it('should wait for updates, run cleanup, send resume session ID, and exit with the relaunch code', async () => { + const originalSend = process.send; + process.send = vi.fn(); + await relaunchApp(); expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1); expect(runExitCleanup).toHaveBeenCalledTimes(1); + expect(process.send).toHaveBeenCalledWith({ + type: 'relaunch-resume-session', + sessionId: expect.any(String), + }); expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); + + process.send = originalSend; + }); + + it('should wait for updates, run cleanup, send override resume session ID, and exit with the relaunch code', async () => { + const originalSend = process.send; + process.send = vi.fn(); + + await relaunchApp('custom-session-id'); + expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1); + expect(runExitCleanup).toHaveBeenCalledTimes(1); + expect(process.send).toHaveBeenCalledWith({ + type: 'relaunch-resume-session', + sessionId: 'custom-session-id', + }); + expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); + + process.send = originalSend; }); }); diff --git a/packages/cli/src/utils/processUtils.ts b/packages/cli/src/utils/processUtils.ts index c43f5c54fd..fd2886c30d 100644 --- a/packages/cli/src/utils/processUtils.ts +++ b/packages/cli/src/utils/processUtils.ts @@ -6,6 +6,7 @@ import { runExitCleanup } from './cleanup.js'; import { waitForUpdateCompletion } from './handleAutoUpdate.js'; +import { sessionId } from '@google/gemini-cli-core'; /** * Exit code used to signal that the CLI should be relaunched. @@ -22,10 +23,18 @@ export function _resetRelaunchStateForTesting(): void { isRelaunching = false; } -export async function relaunchApp(): Promise { +export async function relaunchApp(sessionIdOverride?: string): Promise { if (isRelaunching) return; isRelaunching = true; await waitForUpdateCompletion(); await runExitCleanup(); + + if (process.send) { + process.send({ + type: 'relaunch-resume-session', + sessionId: sessionIdOverride ?? sessionId, + }); + } + process.exit(RELAUNCH_EXIT_CODE); } diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index 2ad5e06a73..3497ea72fb 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -315,6 +315,99 @@ describe('relaunchAppInChildProcess', () => { // Should default to exit code 1 expect(processExitSpy).toHaveBeenCalledWith(1); }); + + it('should append --resume on the next spawn if relaunch-resume-session message is received', async () => { + process.argv = ['/usr/bin/node', '/app/cli.js', '--some-flag']; + + let spawnCount = 0; + mockedSpawn.mockImplementation(() => { + spawnCount++; + const mockChild = createMockChildProcess(0, false); + + if (spawnCount === 1) { + // First run: send the resume session ID, then exit with RELAUNCH_EXIT_CODE + setImmediate(() => { + mockChild.emit('message', { + type: 'relaunch-resume-session', + sessionId: 'test-session-123', + }); + mockChild.emit('close', RELAUNCH_EXIT_CODE); + }); + } else if (spawnCount === 2) { + // Second run: exit normally + setImmediate(() => { + mockChild.emit('close', 0); + }); + } + + return mockChild; + }); + + const promise = relaunchAppInChildProcess([], []); + await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); + + expect(mockedSpawn).toHaveBeenCalledTimes(2); + + // First spawn should not have --resume + expect(mockedSpawn.mock.calls[0][1]).not.toContain('--resume'); + + // Second spawn should have --resume test-session-123 appended + const secondArgs = mockedSpawn.mock.calls[1][1]; + expect(secondArgs).toContain('--resume'); + expect(secondArgs).toContain('test-session-123'); + + // Check exact order at the end of arguments + expect(secondArgs.slice(-2)).toEqual(['--resume', 'test-session-123']); + }); + + it('should strip existing --resume flags when appending new one', async () => { + process.argv = [ + '/usr/bin/node', + '/app/cli.js', + '--resume', + 'old-session', + '--resume=other-session', + '--flag', + ]; + + let spawnCount = 0; + mockedSpawn.mockImplementation(() => { + spawnCount++; + const mockChild = createMockChildProcess(0, false); + + if (spawnCount === 1) { + setImmediate(() => { + mockChild.emit('message', { + type: 'relaunch-resume-session', + sessionId: 'new-session-456', + }); + mockChild.emit('close', RELAUNCH_EXIT_CODE); + }); + } else if (spawnCount === 2) { + setImmediate(() => { + mockChild.emit('close', 0); + }); + } + + return mockChild; + }); + + const promise = relaunchAppInChildProcess([], []); + await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); + + expect(mockedSpawn).toHaveBeenCalledTimes(2); + + const secondArgs = mockedSpawn.mock.calls[1][1] as string[]; + + // Should not contain the old resumes + expect(secondArgs).not.toContain('old-session'); + expect(secondArgs).not.toContain('--resume=other-session'); + + // Should contain the new resume at the end + expect(secondArgs.slice(-2)).toEqual(['--resume', 'new-session-456']); + // Should still contain other flags + expect(secondArgs).toContain('--flag'); + }); }); }); diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index 7e287e4565..a9801c0f10 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -41,12 +41,28 @@ export async function relaunchAppInChildProcess( } let latestAdminSettings = remoteAdminSettings; + let resumeSessionId: string | undefined = undefined; const runner = () => { // process.argv is [node, script, ...args] // We want to construct [ ...nodeArgs, script, ...scriptArgs] const script = process.argv[1]; - const scriptArgs = process.argv.slice(2); + let scriptArgs = process.argv.slice(2); + + if (resumeSessionId) { + const filteredArgs: string[] = []; + for (let i = 0; i < scriptArgs.length; i++) { + if (scriptArgs[i] === '--resume') { + i++; // Skip the next argument as well + continue; + } + if (scriptArgs[i].startsWith('--resume=')) { + continue; + } + filteredArgs.push(scriptArgs[i]); + } + scriptArgs = [...filteredArgs, '--resume', resumeSessionId]; + } const nodeArgs = [ ...process.execArgv, @@ -69,11 +85,16 @@ export async function relaunchAppInChildProcess( child.send({ type: 'admin-settings', settings: latestAdminSettings }); } - child.on('message', (msg: { type?: string; settings?: unknown }) => { - if (msg.type === 'admin-settings-update' && msg.settings) { - latestAdminSettings = msg.settings as AdminControlsSettings; - } - }); + child.on( + 'message', + (msg: { type?: string; settings?: unknown; sessionId?: string }) => { + if (msg.type === 'admin-settings-update' && msg.settings) { + latestAdminSettings = msg.settings as AdminControlsSettings; + } else if (msg.type === 'relaunch-resume-session' && msg.sessionId) { + resumeSessionId = msg.sessionId; + } + }, + ); return new Promise((resolve, reject) => { child.on('error', reject);