Skip to content

Commit 6cc0b1b

Browse files
authored
feat(cli): provide manual session UUID via command line arg (#26060)
1 parent 820a4e3 commit 6cc0b1b

6 files changed

Lines changed: 238 additions & 52 deletions

File tree

packages/cli/src/config/config.test.ts

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,45 @@ afterEach(() => {
231231
});
232232

233233
describe('parseArguments', () => {
234+
afterEach(() => {
235+
vi.restoreAllMocks();
236+
});
237+
it('should fail if both --resume and --session-id are provided', async () => {
238+
process.argv = [
239+
'node',
240+
'script.js',
241+
'--resume',
242+
'--session-id',
243+
'test-uuid-1234',
244+
];
245+
const mockConsoleError = vi
246+
.spyOn(console, 'error')
247+
.mockImplementation(() => {});
248+
vi.spyOn(process, 'exit').mockImplementation(() => {
249+
throw new Error('process.exit called');
250+
});
251+
252+
await expect(parseArguments(createTestMergedSettings())).rejects.toThrow(
253+
'process.exit called',
254+
);
255+
256+
expect(mockConsoleError).toHaveBeenCalledWith(
257+
expect.stringContaining(
258+
'Cannot use both --resume (-r) and --session-id together',
259+
),
260+
);
261+
});
262+
263+
it('should parse --session-id option correctly', async () => {
264+
process.argv = ['node', 'script.js', '--session-id', 'test-uuid-1234'];
265+
vi.spyOn(process, 'exit').mockImplementation(() => {
266+
throw new Error('process.exit called');
267+
});
268+
269+
const parsedArgs = await parseArguments(createTestMergedSettings());
270+
expect(parsedArgs.sessionId).toBe('test-uuid-1234');
271+
});
272+
234273
describe('worktree', () => {
235274
it('should parse --worktree flag when provided with a name', async () => {
236275
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
@@ -255,7 +294,7 @@ describe('parseArguments', () => {
255294
const settings = createTestMergedSettings();
256295
settings.experimental.worktrees = false;
257296

258-
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
297+
vi.spyOn(process, 'exit').mockImplementation(() => {
259298
throw new Error('process.exit called');
260299
});
261300
const mockConsoleError = vi
@@ -270,9 +309,6 @@ describe('parseArguments', () => {
270309
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
271310
),
272311
);
273-
274-
mockExit.mockRestore();
275-
mockConsoleError.mockRestore();
276312
});
277313
});
278314

@@ -304,7 +340,7 @@ describe('parseArguments', () => {
304340
async ({ argv }) => {
305341
process.argv = argv;
306342

307-
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
343+
vi.spyOn(process, 'exit').mockImplementation(() => {
308344
throw new Error('process.exit called');
309345
});
310346

@@ -321,9 +357,6 @@ describe('parseArguments', () => {
321357
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
322358
),
323359
);
324-
325-
mockExit.mockRestore();
326-
mockConsoleError.mockRestore();
327360
},
328361
);
329362

@@ -560,7 +593,7 @@ describe('parseArguments', () => {
560593
async ({ argv }) => {
561594
process.argv = argv;
562595

563-
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
596+
vi.spyOn(process, 'exit').mockImplementation(() => {
564597
throw new Error('process.exit called');
565598
});
566599

@@ -577,9 +610,6 @@ describe('parseArguments', () => {
577610
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
578611
),
579612
);
580-
581-
mockExit.mockRestore();
582-
mockConsoleError.mockRestore();
583613
},
584614
);
585615

@@ -604,7 +634,7 @@ describe('parseArguments', () => {
604634
it('should reject invalid --approval-mode values', async () => {
605635
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
606636

607-
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
637+
vi.spyOn(process, 'exit').mockImplementation(() => {
608638
throw new Error('process.exit called');
609639
});
610640

@@ -623,10 +653,6 @@ describe('parseArguments', () => {
623653
expect.stringContaining('Invalid values:'),
624654
);
625655
expect(mockConsoleError).toHaveBeenCalled();
626-
627-
mockExit.mockRestore();
628-
mockConsoleError.mockRestore();
629-
debugErrorSpy.mockRestore();
630656
});
631657

632658
it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => {
@@ -870,16 +896,14 @@ describe('loadCliConfig', () => {
870896
});
871897

872898
it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => {
873-
const resolveToRealPathSpy = vi
874-
.spyOn(ServerConfig, 'resolveToRealPath')
875-
.mockImplementation((p) => {
876-
if (p.toString().includes('restricted')) {
877-
const err = new Error('EACCES: permission denied');
878-
(err as NodeJS.ErrnoException).code = 'EACCES';
879-
throw err;
880-
}
881-
return p.toString();
882-
});
899+
vi.spyOn(ServerConfig, 'resolveToRealPath').mockImplementation((p) => {
900+
if (p.toString().includes('restricted')) {
901+
const err = new Error('EACCES: permission denied');
902+
(err as NodeJS.ErrnoException).code = 'EACCES';
903+
throw err;
904+
}
905+
return p.toString();
906+
});
883907
vi.stubEnv(
884908
'GEMINI_CLI_IDE_WORKSPACE_PATH',
885909
['/project/folderA', '/nonexistent/restricted/folder'].join(
@@ -893,8 +917,6 @@ describe('loadCliConfig', () => {
893917
const dirs = config.getPendingIncludeDirectories();
894918
expect(dirs).toContain('/project/folderA');
895919
expect(dirs).not.toContain('/nonexistent/restricted/folder');
896-
897-
resolveToRealPathSpy.mockRestore();
898920
});
899921

900922
it('should use default fileFilter options when unconfigured', async () => {
@@ -3178,7 +3200,7 @@ describe('Output format', () => {
31783200
it('should error on invalid --output-format argument', async () => {
31793201
process.argv = ['node', 'script.js', '--output-format', 'invalid'];
31803202

3181-
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
3203+
vi.spyOn(process, 'exit').mockImplementation(() => {
31823204
throw new Error('process.exit called');
31833205
});
31843206

@@ -3196,10 +3218,6 @@ describe('Output format', () => {
31963218
expect.stringContaining('Invalid values:'),
31973219
);
31983220
expect(mockConsoleError).toHaveBeenCalled();
3199-
3200-
mockExit.mockRestore();
3201-
mockConsoleError.mockRestore();
3202-
debugErrorSpy.mockRestore();
32033221
});
32043222
});
32053223

@@ -3230,13 +3248,11 @@ describe('parseArguments with positional prompt', () => {
32303248
'test prompt',
32313249
];
32323250

3233-
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
3251+
vi.spyOn(process, 'exit').mockImplementation(() => {
32343252
throw new Error('process.exit called');
32353253
});
32363254

3237-
const mockConsoleError = vi
3238-
.spyOn(console, 'error')
3239-
.mockImplementation(() => {});
3255+
vi.spyOn(console, 'error').mockImplementation(() => {});
32403256
const debugErrorSpy = vi
32413257
.spyOn(debugLogger, 'error')
32423258
.mockImplementation(() => {});
@@ -3250,10 +3266,6 @@ describe('parseArguments with positional prompt', () => {
32503266
'Cannot use both a positional prompt and the --prompt (-p) flag together',
32513267
),
32523268
);
3253-
3254-
mockExit.mockRestore();
3255-
mockConsoleError.mockRestore();
3256-
debugErrorSpy.mockRestore();
32573269
});
32583270

32593271
it('should correctly parse a positional prompt to query field', async () => {

packages/cli/src/config/config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface CliArgs {
9696
extensions: string[] | undefined;
9797
listExtensions: boolean | undefined;
9898
resume: string | typeof RESUME_LATEST | undefined;
99+
sessionId: string | undefined;
99100
listSessions: boolean | undefined;
100101
deleteSession: string | undefined;
101102
includeDirectories: string[] | undefined;
@@ -237,6 +238,10 @@ export async function parseArguments(
237238
? query.length > 0
238239
: !!query;
239240

241+
if (argv['resume'] !== undefined && argv['session-id'] !== undefined) {
242+
return 'Cannot use both --resume (-r) and --session-id together';
243+
}
244+
240245
if (argv['prompt'] && hasPositionalQuery) {
241246
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
242247
}
@@ -406,6 +411,25 @@ export async function parseArguments(
406411
return trimmed;
407412
},
408413
})
414+
.option('session-id', {
415+
type: 'string',
416+
nargs: 1,
417+
description: 'Start a new session with a manually provided UUID.',
418+
coerce: (value: string): string => {
419+
const trimmed = value.trim();
420+
if (!trimmed) {
421+
throw new Error('The --session-id option cannot be empty.');
422+
}
423+
if (!/^[a-zA-Z0-9-_]+$/.test(trimmed)) {
424+
throw new Error(
425+
'Invalid session ID "' +
426+
trimmed +
427+
'": Only alphanumeric characters, dashes, and underscores are allowed.',
428+
);
429+
}
430+
return trimmed;
431+
},
432+
})
409433
.option('list-sessions', {
410434
type: 'boolean',
411435
description:

packages/cli/src/gemini.test.tsx

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
validateDnsResolutionOrder,
2121
startInteractiveUI,
2222
getNodeMemoryArgs,
23+
resolveSessionId,
2324
} from './gemini.js';
2425
import {
2526
loadCliConfig,
@@ -47,10 +48,13 @@ import {
4748
debugLogger,
4849
coreEvents,
4950
AuthType,
51+
ExitCodes,
5052
} from '@google/gemini-cli-core';
5153
import { act } from 'react';
5254
import { type InitializationResult } from './core/initializer.js';
5355
import { runNonInteractive } from './nonInteractiveCli.js';
56+
import { SessionSelector, SessionError } from './utils/sessionUtils.js';
57+
5458
// Hoisted constants and mocks
5559
const performance = vi.hoisted(() => ({
5660
now: vi.fn(),
@@ -548,6 +552,7 @@ describe('gemini.tsx main function kitty protocol', () => {
548552
screenReader: undefined,
549553
useWriteTodos: undefined,
550554
resume: undefined,
555+
sessionId: undefined,
551556
listSessions: undefined,
552557
deleteSession: undefined,
553558
outputFormat: undefined,
@@ -607,6 +612,7 @@ describe('gemini.tsx main function kitty protocol', () => {
607612
screenReader: undefined,
608613
useWriteTodos: undefined,
609614
resume: undefined,
615+
sessionId: undefined,
610616
listSessions: undefined,
611617
deleteSession: undefined,
612618
outputFormat: undefined,
@@ -822,7 +828,6 @@ describe('gemini.tsx main function kitty protocol', () => {
822828
});
823829

824830
it('should handle session selector error', async () => {
825-
const { SessionSelector } = await import('./utils/sessionUtils.js');
826831
vi.mocked(SessionSelector).mockImplementation(
827832
() =>
828833
({
@@ -879,9 +884,6 @@ describe('gemini.tsx main function kitty protocol', () => {
879884
});
880885

881886
it('should start normally with a warning when no sessions found for resume', async () => {
882-
const { SessionSelector, SessionError } = await import(
883-
'./utils/sessionUtils.js'
884-
);
885887
vi.mocked(SessionSelector).mockImplementation(
886888
() =>
887889
({
@@ -1056,6 +1058,63 @@ describe('gemini.tsx main function kitty protocol', () => {
10561058
});
10571059
});
10581060

1061+
describe('resolveSessionId', () => {
1062+
it('should return a new session ID when neither resume nor sessionId is provided', async () => {
1063+
const { sessionId, resumedSessionData } = await resolveSessionId(
1064+
undefined,
1065+
undefined,
1066+
);
1067+
expect(sessionId).toBeDefined();
1068+
expect(resumedSessionData).toBeUndefined();
1069+
});
1070+
1071+
it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => {
1072+
vi.mocked(SessionSelector).mockImplementation(
1073+
() =>
1074+
({
1075+
sessionExists: vi.fn().mockResolvedValue(true),
1076+
}) as unknown as InstanceType<typeof SessionSelector>,
1077+
);
1078+
1079+
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
1080+
const processExitSpy = vi
1081+
.spyOn(process, 'exit')
1082+
.mockImplementation((code) => {
1083+
throw new MockProcessExitError(code);
1084+
});
1085+
1086+
try {
1087+
await resolveSessionId(undefined, 'existing-id');
1088+
} catch (e) {
1089+
if (!(e instanceof MockProcessExitError)) throw e;
1090+
}
1091+
1092+
expect(emitFeedbackSpy).toHaveBeenCalledWith(
1093+
'error',
1094+
expect.stringContaining('Session ID "existing-id" already exists'),
1095+
);
1096+
expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR);
1097+
1098+
emitFeedbackSpy.mockRestore();
1099+
processExitSpy.mockRestore();
1100+
});
1101+
1102+
it('should return provided sessionId when it does not exist', async () => {
1103+
vi.mocked(SessionSelector).mockImplementation(
1104+
() =>
1105+
({
1106+
sessionExists: vi.fn().mockResolvedValue(false),
1107+
}) as unknown as InstanceType<typeof SessionSelector>,
1108+
);
1109+
const { sessionId, resumedSessionData } = await resolveSessionId(
1110+
undefined,
1111+
'new-id',
1112+
);
1113+
expect(sessionId).toBe('new-id');
1114+
expect(resumedSessionData).toBeUndefined();
1115+
});
1116+
});
1117+
10591118
describe('gemini.tsx main function exit codes', () => {
10601119
let originalEnvNoRelaunch: string | undefined;
10611120
let originalIsTTY: boolean | undefined;

0 commit comments

Comments
 (0)