mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 09:01:17 -07:00
feat(cli): resume session after CLI restart/relaunch
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -35,7 +35,7 @@ export const LoginWithGoogleRestartDialog = ({
|
||||
});
|
||||
}
|
||||
}
|
||||
await relaunchApp();
|
||||
await relaunchApp(config.getSessionId());
|
||||
}, 100);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ export const DialogManager = ({
|
||||
<Box flexDirection="column">
|
||||
<SettingsDialog
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onRestartRequest={relaunchApp}
|
||||
onRestartRequest={() => relaunchApp(config.getSessionId())}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
export async function relaunchApp(sessionIdOverride?: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -315,6 +315,99 @@ describe('relaunchAppInChildProcess', () => {
|
||||
// Should default to exit code 1
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should append --resume <sessionId> 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<number>((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
|
||||
Reference in New Issue
Block a user