From 6cc0b1b136450f529647144380a8107d59fdafdf Mon Sep 17 00:00:00 2001 From: Coco Sheng Date: Mon, 27 Apr 2026 17:05:27 -0400 Subject: [PATCH] feat(cli): provide manual session UUID via command line arg (#26060) --- packages/cli/src/config/config.test.ts | 96 ++++++++++++--------- packages/cli/src/config/config.ts | 24 ++++++ packages/cli/src/gemini.test.tsx | 67 +++++++++++++- packages/cli/src/gemini.tsx | 32 +++++-- packages/cli/src/utils/sessionUtils.test.ts | 41 +++++++++ packages/cli/src/utils/sessionUtils.ts | 30 +++++++ 6 files changed, 238 insertions(+), 52 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0e132f316d..f7d3bbbcd3 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -231,6 +231,45 @@ afterEach(() => { }); describe('parseArguments', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should fail if both --resume and --session-id are provided', async () => { + process.argv = [ + 'node', + 'script.js', + '--resume', + '--session-id', + 'test-uuid-1234', + ]; + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot use both --resume (-r) and --session-id together', + ), + ); + }); + + it('should parse --session-id option correctly', async () => { + process.argv = ['node', 'script.js', '--session-id', 'test-uuid-1234']; + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const parsedArgs = await parseArguments(createTestMergedSettings()); + expect(parsedArgs.sessionId).toBe('test-uuid-1234'); + }); + describe('worktree', () => { it('should parse --worktree flag when provided with a name', async () => { process.argv = ['node', 'script.js', '--worktree', 'my-feature']; @@ -255,7 +294,7 @@ describe('parseArguments', () => { const settings = createTestMergedSettings(); settings.experimental.worktrees = false; - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); const mockConsoleError = vi @@ -270,9 +309,6 @@ describe('parseArguments', () => { 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.', ), ); - - mockExit.mockRestore(); - mockConsoleError.mockRestore(); }); }); @@ -304,7 +340,7 @@ describe('parseArguments', () => { async ({ argv }) => { process.argv = argv; - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); @@ -321,9 +357,6 @@ describe('parseArguments', () => { 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', ), ); - - mockExit.mockRestore(); - mockConsoleError.mockRestore(); }, ); @@ -560,7 +593,7 @@ describe('parseArguments', () => { async ({ argv }) => { process.argv = argv; - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); @@ -577,9 +610,6 @@ describe('parseArguments', () => { 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.', ), ); - - mockExit.mockRestore(); - mockConsoleError.mockRestore(); }, ); @@ -604,7 +634,7 @@ describe('parseArguments', () => { it('should reject invalid --approval-mode values', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'invalid']; - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); @@ -623,10 +653,6 @@ describe('parseArguments', () => { expect.stringContaining('Invalid values:'), ); expect(mockConsoleError).toHaveBeenCalled(); - - mockExit.mockRestore(); - mockConsoleError.mockRestore(); - debugErrorSpy.mockRestore(); }); it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => { @@ -870,16 +896,14 @@ describe('loadCliConfig', () => { }); it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => { - const resolveToRealPathSpy = vi - .spyOn(ServerConfig, 'resolveToRealPath') - .mockImplementation((p) => { - if (p.toString().includes('restricted')) { - const err = new Error('EACCES: permission denied'); - (err as NodeJS.ErrnoException).code = 'EACCES'; - throw err; - } - return p.toString(); - }); + vi.spyOn(ServerConfig, 'resolveToRealPath').mockImplementation((p) => { + if (p.toString().includes('restricted')) { + const err = new Error('EACCES: permission denied'); + (err as NodeJS.ErrnoException).code = 'EACCES'; + throw err; + } + return p.toString(); + }); vi.stubEnv( 'GEMINI_CLI_IDE_WORKSPACE_PATH', ['/project/folderA', '/nonexistent/restricted/folder'].join( @@ -893,8 +917,6 @@ describe('loadCliConfig', () => { const dirs = config.getPendingIncludeDirectories(); expect(dirs).toContain('/project/folderA'); expect(dirs).not.toContain('/nonexistent/restricted/folder'); - - resolveToRealPathSpy.mockRestore(); }); it('should use default fileFilter options when unconfigured', async () => { @@ -3178,7 +3200,7 @@ describe('Output format', () => { it('should error on invalid --output-format argument', async () => { process.argv = ['node', 'script.js', '--output-format', 'invalid']; - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); @@ -3196,10 +3218,6 @@ describe('Output format', () => { expect.stringContaining('Invalid values:'), ); expect(mockConsoleError).toHaveBeenCalled(); - - mockExit.mockRestore(); - mockConsoleError.mockRestore(); - debugErrorSpy.mockRestore(); }); }); @@ -3230,13 +3248,11 @@ describe('parseArguments with positional prompt', () => { 'test prompt', ]; - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); }); - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); const debugErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); @@ -3250,10 +3266,6 @@ describe('parseArguments with positional prompt', () => { 'Cannot use both a positional prompt and the --prompt (-p) flag together', ), ); - - mockExit.mockRestore(); - mockConsoleError.mockRestore(); - debugErrorSpy.mockRestore(); }); it('should correctly parse a positional prompt to query field', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c0da3ae1b8..6147ba0b40 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -96,6 +96,7 @@ export interface CliArgs { extensions: string[] | undefined; listExtensions: boolean | undefined; resume: string | typeof RESUME_LATEST | undefined; + sessionId: string | undefined; listSessions: boolean | undefined; deleteSession: string | undefined; includeDirectories: string[] | undefined; @@ -237,6 +238,10 @@ export async function parseArguments( ? query.length > 0 : !!query; + if (argv['resume'] !== undefined && argv['session-id'] !== undefined) { + return 'Cannot use both --resume (-r) and --session-id together'; + } + if (argv['prompt'] && hasPositionalQuery) { return 'Cannot use both a positional prompt and the --prompt (-p) flag together'; } @@ -406,6 +411,25 @@ export async function parseArguments( return trimmed; }, }) + .option('session-id', { + type: 'string', + nargs: 1, + description: 'Start a new session with a manually provided UUID.', + coerce: (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error('The --session-id option cannot be empty.'); + } + if (!/^[a-zA-Z0-9-_]+$/.test(trimmed)) { + throw new Error( + 'Invalid session ID "' + + trimmed + + '": Only alphanumeric characters, dashes, and underscores are allowed.', + ); + } + return trimmed; + }, + }) .option('list-sessions', { type: 'boolean', description: diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 20fc80d190..ca990420d1 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -20,6 +20,7 @@ import { validateDnsResolutionOrder, startInteractiveUI, getNodeMemoryArgs, + resolveSessionId, } from './gemini.js'; import { loadCliConfig, @@ -47,10 +48,13 @@ import { debugLogger, coreEvents, AuthType, + ExitCodes, } from '@google/gemini-cli-core'; import { act } from 'react'; import { type InitializationResult } from './core/initializer.js'; import { runNonInteractive } from './nonInteractiveCli.js'; +import { SessionSelector, SessionError } from './utils/sessionUtils.js'; + // Hoisted constants and mocks const performance = vi.hoisted(() => ({ now: vi.fn(), @@ -548,6 +552,7 @@ describe('gemini.tsx main function kitty protocol', () => { screenReader: undefined, useWriteTodos: undefined, resume: undefined, + sessionId: undefined, listSessions: undefined, deleteSession: undefined, outputFormat: undefined, @@ -607,6 +612,7 @@ describe('gemini.tsx main function kitty protocol', () => { screenReader: undefined, useWriteTodos: undefined, resume: undefined, + sessionId: undefined, listSessions: undefined, deleteSession: undefined, outputFormat: undefined, @@ -822,7 +828,6 @@ describe('gemini.tsx main function kitty protocol', () => { }); it('should handle session selector error', async () => { - const { SessionSelector } = await import('./utils/sessionUtils.js'); vi.mocked(SessionSelector).mockImplementation( () => ({ @@ -879,9 +884,6 @@ describe('gemini.tsx main function kitty protocol', () => { }); it('should start normally with a warning when no sessions found for resume', async () => { - const { SessionSelector, SessionError } = await import( - './utils/sessionUtils.js' - ); vi.mocked(SessionSelector).mockImplementation( () => ({ @@ -1056,6 +1058,63 @@ describe('gemini.tsx main function kitty protocol', () => { }); }); +describe('resolveSessionId', () => { + it('should return a new session ID when neither resume nor sessionId is provided', async () => { + const { sessionId, resumedSessionData } = await resolveSessionId( + undefined, + undefined, + ); + expect(sessionId).toBeDefined(); + expect(resumedSessionData).toBeUndefined(); + }); + + it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => { + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + sessionExists: vi.fn().mockResolvedValue(true), + }) as unknown as InstanceType, + ); + + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + try { + await resolveSessionId(undefined, 'existing-id'); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } + + expect(emitFeedbackSpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Session ID "existing-id" already exists'), + ); + expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR); + + emitFeedbackSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + it('should return provided sessionId when it does not exist', async () => { + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + sessionExists: vi.fn().mockResolvedValue(false), + }) as unknown as InstanceType, + ); + const { sessionId, resumedSessionData } = await resolveSessionId( + undefined, + 'new-id', + ); + expect(sessionId).toBe('new-id'); + expect(resumedSessionData).toBeUndefined(); + }); +}); + describe('gemini.tsx main function exit codes', () => { let originalEnvNoRelaunch: string | undefined; let originalIsTTY: boolean | undefined; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index cb1c987584..e7ec0b1a66 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -190,21 +190,38 @@ ${reason.stack}` }); } -export async function resolveSessionId(resumeArg: string | undefined): Promise<{ +export async function resolveSessionId( + resumeArg: string | undefined, + sessionIdArg?: string | undefined, +): Promise<{ sessionId: string; resumedSessionData?: ResumedSessionData; }> { - if (!resumeArg) { + if (!resumeArg && !sessionIdArg) { return { sessionId: createSessionId() }; } const storage = new Storage(process.cwd()); await storage.initialize(); + const sessionSelector = new SessionSelector(storage); + + if (sessionIdArg) { + if (await sessionSelector.sessionExists(sessionIdArg)) { + coreEvents.emitFeedback( + 'error', + `Error starting session: Session ID "${sessionIdArg}" already exists. Use --resume to resume it, or provide a different ID.`, + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } + return { sessionId: sessionIdArg }; + } + try { - const { sessionData, sessionPath } = await new SessionSelector( - storage, - ).resolveSession(resumeArg); + const { sessionData, sessionPath } = await sessionSelector.resolveSession( + resumeArg!, + ); return { sessionId: sessionData.sessionId, resumedSessionData: { conversation: sessionData, filePath: sessionPath }, @@ -318,7 +335,10 @@ export async function main() { const argv = await argvPromise; - const { sessionId, resumedSessionData } = await resolveSessionId(argv.resume); + const { sessionId, resumedSessionData } = await resolveSessionId( + argv.resume, + argv.sessionId, + ); if ( (argv.allowedTools && argv.allowedTools.length > 0) || diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 0495bf5588..0bc1183e71 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -47,6 +47,47 @@ describe('SessionSelector', () => { } }); + describe('sessionExists', () => { + it('should return true if a session file with the exact UUID exists', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.writeFile( + path.join( + chatsDir, + `session-20240101T000000-${sessionId.slice(0, 8)}.jsonl`, + ), + JSON.stringify({ sessionId }), + ); + + const selector = new SessionSelector(storage); + const exists = await selector.sessionExists(sessionId); + expect(exists).toBe(true); + }); + + it('should return false if no session file matches the UUID', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.writeFile( + path.join(chatsDir, `session-different-uuid-20240101.jsonl`), + '{}', + ); + + const selector = new SessionSelector(storage); + const exists = await selector.sessionExists(sessionId); + expect(exists).toBe(false); + }); + + it('should return false if the chats directory does not exist', async () => { + const sessionId = randomUUID(); + // Notice we do NOT create chatsDir here. + const selector = new SessionSelector(storage); + const exists = await selector.sessionExists(sessionId); + expect(exists).toBe(false); + }); + }); + it('should resolve session by UUID', async () => { const sessionId1 = randomUUID(); const sessionId2 = randomUUID(); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 647ed77727..437e32c465 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -408,6 +408,36 @@ export const getSessionFiles = async ( export class SessionSelector { constructor(private storage: Storage) {} + /** + * Checks if a session with the given ID already exists on disk. + */ + async sessionExists(id: string): Promise { + const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats'); + const files = await fs.readdir(chatsDir).catch(() => []); + + // The filename format is `session--.jsonl` + const shortId = id.slice(0, 8); + const candidateFiles = files.filter( + (f) => + f.startsWith(SESSION_FILE_PREFIX) && + (f.endsWith(`-${shortId}.json`) || f.endsWith(`-${shortId}.jsonl`)), + ); + + for (const fileName of candidateFiles) { + try { + const sessionPath = path.join(chatsDir, fileName); + const sessionData = await loadConversationRecord(sessionPath); + if (sessionData && sessionData.sessionId === id) { + return true; + } + } catch { + // Ignore unparseable files + } + } + + return false; + } + /** * Lists all available sessions for the current project. */