mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-01 15:34:29 -07:00
feat(cli): provide manual session UUID via command line arg (#26060)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user