feat(cli): provide manual session UUID via command line arg (#26060)

This commit is contained in:
Coco Sheng
2026-04-27 17:05:27 -04:00
committed by GitHub
parent 820a4e3c92
commit 6cc0b1b136
6 changed files with 238 additions and 52 deletions
+54 -42
View File
@@ -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 () => {
+24
View File
@@ -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:
+63 -4
View File
@@ -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<typeof SessionSelector>,
);
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<typeof SessionSelector>,
);
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;
+26 -6
View File
@@ -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) ||
@@ -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();
+30
View File
@@ -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<boolean> {
const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats');
const files = await fs.readdir(chatsDir).catch(() => []);
// The filename format is `session-<TIMESTAMP>-<ID_SLICE(0,8)>.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.
*/