mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -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', () => {
|
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', () => {
|
describe('worktree', () => {
|
||||||
it('should parse --worktree flag when provided with a name', async () => {
|
it('should parse --worktree flag when provided with a name', async () => {
|
||||||
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
|
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
|
||||||
@@ -255,7 +294,7 @@ describe('parseArguments', () => {
|
|||||||
const settings = createTestMergedSettings();
|
const settings = createTestMergedSettings();
|
||||||
settings.experimental.worktrees = false;
|
settings.experimental.worktrees = false;
|
||||||
|
|
||||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
throw new Error('process.exit called');
|
throw new Error('process.exit called');
|
||||||
});
|
});
|
||||||
const mockConsoleError = vi
|
const mockConsoleError = vi
|
||||||
@@ -270,9 +309,6 @@ describe('parseArguments', () => {
|
|||||||
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
|
'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 }) => {
|
async ({ argv }) => {
|
||||||
process.argv = argv;
|
process.argv = argv;
|
||||||
|
|
||||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
throw new Error('process.exit called');
|
throw new Error('process.exit called');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -321,9 +357,6 @@ describe('parseArguments', () => {
|
|||||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
mockExit.mockRestore();
|
|
||||||
mockConsoleError.mockRestore();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -560,7 +593,7 @@ describe('parseArguments', () => {
|
|||||||
async ({ argv }) => {
|
async ({ argv }) => {
|
||||||
process.argv = argv;
|
process.argv = argv;
|
||||||
|
|
||||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
throw new Error('process.exit called');
|
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.',
|
'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 () => {
|
it('should reject invalid --approval-mode values', async () => {
|
||||||
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
|
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');
|
throw new Error('process.exit called');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -623,10 +653,6 @@ describe('parseArguments', () => {
|
|||||||
expect.stringContaining('Invalid values:'),
|
expect.stringContaining('Invalid values:'),
|
||||||
);
|
);
|
||||||
expect(mockConsoleError).toHaveBeenCalled();
|
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 () => {
|
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 () => {
|
it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => {
|
||||||
const resolveToRealPathSpy = vi
|
vi.spyOn(ServerConfig, 'resolveToRealPath').mockImplementation((p) => {
|
||||||
.spyOn(ServerConfig, 'resolveToRealPath')
|
if (p.toString().includes('restricted')) {
|
||||||
.mockImplementation((p) => {
|
const err = new Error('EACCES: permission denied');
|
||||||
if (p.toString().includes('restricted')) {
|
(err as NodeJS.ErrnoException).code = 'EACCES';
|
||||||
const err = new Error('EACCES: permission denied');
|
throw err;
|
||||||
(err as NodeJS.ErrnoException).code = 'EACCES';
|
}
|
||||||
throw err;
|
return p.toString();
|
||||||
}
|
});
|
||||||
return p.toString();
|
|
||||||
});
|
|
||||||
vi.stubEnv(
|
vi.stubEnv(
|
||||||
'GEMINI_CLI_IDE_WORKSPACE_PATH',
|
'GEMINI_CLI_IDE_WORKSPACE_PATH',
|
||||||
['/project/folderA', '/nonexistent/restricted/folder'].join(
|
['/project/folderA', '/nonexistent/restricted/folder'].join(
|
||||||
@@ -893,8 +917,6 @@ describe('loadCliConfig', () => {
|
|||||||
const dirs = config.getPendingIncludeDirectories();
|
const dirs = config.getPendingIncludeDirectories();
|
||||||
expect(dirs).toContain('/project/folderA');
|
expect(dirs).toContain('/project/folderA');
|
||||||
expect(dirs).not.toContain('/nonexistent/restricted/folder');
|
expect(dirs).not.toContain('/nonexistent/restricted/folder');
|
||||||
|
|
||||||
resolveToRealPathSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default fileFilter options when unconfigured', async () => {
|
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 () => {
|
it('should error on invalid --output-format argument', async () => {
|
||||||
process.argv = ['node', 'script.js', '--output-format', 'invalid'];
|
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');
|
throw new Error('process.exit called');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3196,10 +3218,6 @@ describe('Output format', () => {
|
|||||||
expect.stringContaining('Invalid values:'),
|
expect.stringContaining('Invalid values:'),
|
||||||
);
|
);
|
||||||
expect(mockConsoleError).toHaveBeenCalled();
|
expect(mockConsoleError).toHaveBeenCalled();
|
||||||
|
|
||||||
mockExit.mockRestore();
|
|
||||||
mockConsoleError.mockRestore();
|
|
||||||
debugErrorSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3230,13 +3248,11 @@ describe('parseArguments with positional prompt', () => {
|
|||||||
'test prompt',
|
'test prompt',
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
throw new Error('process.exit called');
|
throw new Error('process.exit called');
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockConsoleError = vi
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
const debugErrorSpy = vi
|
const debugErrorSpy = vi
|
||||||
.spyOn(debugLogger, 'error')
|
.spyOn(debugLogger, 'error')
|
||||||
.mockImplementation(() => {});
|
.mockImplementation(() => {});
|
||||||
@@ -3250,10 +3266,6 @@ describe('parseArguments with positional prompt', () => {
|
|||||||
'Cannot use both a positional prompt and the --prompt (-p) flag together',
|
'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 () => {
|
it('should correctly parse a positional prompt to query field', async () => {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface CliArgs {
|
|||||||
extensions: string[] | undefined;
|
extensions: string[] | undefined;
|
||||||
listExtensions: boolean | undefined;
|
listExtensions: boolean | undefined;
|
||||||
resume: string | typeof RESUME_LATEST | undefined;
|
resume: string | typeof RESUME_LATEST | undefined;
|
||||||
|
sessionId: string | undefined;
|
||||||
listSessions: boolean | undefined;
|
listSessions: boolean | undefined;
|
||||||
deleteSession: string | undefined;
|
deleteSession: string | undefined;
|
||||||
includeDirectories: string[] | undefined;
|
includeDirectories: string[] | undefined;
|
||||||
@@ -237,6 +238,10 @@ export async function parseArguments(
|
|||||||
? query.length > 0
|
? query.length > 0
|
||||||
: !!query;
|
: !!query;
|
||||||
|
|
||||||
|
if (argv['resume'] !== undefined && argv['session-id'] !== undefined) {
|
||||||
|
return 'Cannot use both --resume (-r) and --session-id together';
|
||||||
|
}
|
||||||
|
|
||||||
if (argv['prompt'] && hasPositionalQuery) {
|
if (argv['prompt'] && hasPositionalQuery) {
|
||||||
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
|
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
|
||||||
}
|
}
|
||||||
@@ -406,6 +411,25 @@ export async function parseArguments(
|
|||||||
return trimmed;
|
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', {
|
.option('list-sessions', {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
validateDnsResolutionOrder,
|
validateDnsResolutionOrder,
|
||||||
startInteractiveUI,
|
startInteractiveUI,
|
||||||
getNodeMemoryArgs,
|
getNodeMemoryArgs,
|
||||||
|
resolveSessionId,
|
||||||
} from './gemini.js';
|
} from './gemini.js';
|
||||||
import {
|
import {
|
||||||
loadCliConfig,
|
loadCliConfig,
|
||||||
@@ -47,10 +48,13 @@ import {
|
|||||||
debugLogger,
|
debugLogger,
|
||||||
coreEvents,
|
coreEvents,
|
||||||
AuthType,
|
AuthType,
|
||||||
|
ExitCodes,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { act } from 'react';
|
import { act } from 'react';
|
||||||
import { type InitializationResult } from './core/initializer.js';
|
import { type InitializationResult } from './core/initializer.js';
|
||||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||||
|
import { SessionSelector, SessionError } from './utils/sessionUtils.js';
|
||||||
|
|
||||||
// Hoisted constants and mocks
|
// Hoisted constants and mocks
|
||||||
const performance = vi.hoisted(() => ({
|
const performance = vi.hoisted(() => ({
|
||||||
now: vi.fn(),
|
now: vi.fn(),
|
||||||
@@ -548,6 +552,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
screenReader: undefined,
|
screenReader: undefined,
|
||||||
useWriteTodos: undefined,
|
useWriteTodos: undefined,
|
||||||
resume: undefined,
|
resume: undefined,
|
||||||
|
sessionId: undefined,
|
||||||
listSessions: undefined,
|
listSessions: undefined,
|
||||||
deleteSession: undefined,
|
deleteSession: undefined,
|
||||||
outputFormat: undefined,
|
outputFormat: undefined,
|
||||||
@@ -607,6 +612,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
screenReader: undefined,
|
screenReader: undefined,
|
||||||
useWriteTodos: undefined,
|
useWriteTodos: undefined,
|
||||||
resume: undefined,
|
resume: undefined,
|
||||||
|
sessionId: undefined,
|
||||||
listSessions: undefined,
|
listSessions: undefined,
|
||||||
deleteSession: undefined,
|
deleteSession: undefined,
|
||||||
outputFormat: undefined,
|
outputFormat: undefined,
|
||||||
@@ -822,7 +828,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle session selector error', async () => {
|
it('should handle session selector error', async () => {
|
||||||
const { SessionSelector } = await import('./utils/sessionUtils.js');
|
|
||||||
vi.mocked(SessionSelector).mockImplementation(
|
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 () => {
|
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(
|
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', () => {
|
describe('gemini.tsx main function exit codes', () => {
|
||||||
let originalEnvNoRelaunch: string | undefined;
|
let originalEnvNoRelaunch: string | undefined;
|
||||||
let originalIsTTY: boolean | 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;
|
sessionId: string;
|
||||||
resumedSessionData?: ResumedSessionData;
|
resumedSessionData?: ResumedSessionData;
|
||||||
}> {
|
}> {
|
||||||
if (!resumeArg) {
|
if (!resumeArg && !sessionIdArg) {
|
||||||
return { sessionId: createSessionId() };
|
return { sessionId: createSessionId() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = new Storage(process.cwd());
|
const storage = new Storage(process.cwd());
|
||||||
await storage.initialize();
|
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 {
|
try {
|
||||||
const { sessionData, sessionPath } = await new SessionSelector(
|
const { sessionData, sessionPath } = await sessionSelector.resolveSession(
|
||||||
storage,
|
resumeArg!,
|
||||||
).resolveSession(resumeArg);
|
);
|
||||||
return {
|
return {
|
||||||
sessionId: sessionData.sessionId,
|
sessionId: sessionData.sessionId,
|
||||||
resumedSessionData: { conversation: sessionData, filePath: sessionPath },
|
resumedSessionData: { conversation: sessionData, filePath: sessionPath },
|
||||||
@@ -318,7 +335,10 @@ export async function main() {
|
|||||||
|
|
||||||
const argv = await argvPromise;
|
const argv = await argvPromise;
|
||||||
|
|
||||||
const { sessionId, resumedSessionData } = await resolveSessionId(argv.resume);
|
const { sessionId, resumedSessionData } = await resolveSessionId(
|
||||||
|
argv.resume,
|
||||||
|
argv.sessionId,
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
(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 () => {
|
it('should resolve session by UUID', async () => {
|
||||||
const sessionId1 = randomUUID();
|
const sessionId1 = randomUUID();
|
||||||
const sessionId2 = randomUUID();
|
const sessionId2 = randomUUID();
|
||||||
|
|||||||
@@ -408,6 +408,36 @@ export const getSessionFiles = async (
|
|||||||
export class SessionSelector {
|
export class SessionSelector {
|
||||||
constructor(private storage: Storage) {}
|
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.
|
* Lists all available sessions for the current project.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user