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: