From bf51af0337f498ab5a8e43d5b839391a89eb55d3 Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 15 May 2026 00:10:44 +0800 Subject: [PATCH 1/4] feat(cli): add fork-session resume flag --- packages/cli/src/config/config.test.ts | 88 ++++++++++++++++++++++++++ packages/cli/src/config/config.ts | 30 ++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index e7ff46b461a..d572aae2a02 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -21,6 +21,15 @@ import { isWorkspaceTrusted } from './trustedFolders.js'; const mockWriteStderrLine = vi.hoisted(() => vi.fn()); const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockSessionServiceInstance = vi.hoisted(() => ({ + loadLastSession: vi.fn(), + loadSession: vi.fn(), + forkSession: vi.fn(), + sessionExists: vi.fn(), +})); +const mockSessionServiceCtor = vi.hoisted(() => + vi.fn(() => mockSessionServiceInstance), +); vi.mock('../utils/stdioHelpers.js', () => ({ writeStderrLine: mockWriteStderrLine, @@ -139,6 +148,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { NativeLspService: vi .fn() .mockImplementation(() => createNativeLspServiceInstance()), + SessionService: mockSessionServiceCtor, SkillManager: SkillManagerMock, IdeClient: { getInstance: vi.fn().mockResolvedValue({ @@ -412,6 +422,44 @@ describe('parseArguments', () => { expect(argv.continue).toBe(true); }); + it('should parse --fork-session with --resume', async () => { + process.argv = [ + 'node', + 'script.js', + '--resume', + '123e4567-e89b-12d3-a456-426614174000', + '--fork-session', + ]; + const argv = await parseArguments(); + expect(argv.resume).toBe('123e4567-e89b-12d3-a456-426614174000'); + expect(argv.forkSession).toBe(true); + }); + + it('should parse --fork-session with the --resume picker form', async () => { + process.argv = ['node', 'script.js', '--resume', '--fork-session']; + const argv = await parseArguments(); + expect(argv.resume).toBe(''); + expect(argv.forkSession).toBe(true); + }); + + it('should reject --fork-session without --resume or --continue', async () => { + process.argv = ['node', 'script.js', '--fork-session']; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockWriteStderrLine.mockClear(); + + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining( + '--fork-session must be used with --resume or --continue', + ), + ); + + mockExit.mockRestore(); + }); + it('should convert positional query argument to prompt by default', async () => { process.argv = ['node', 'script.js', 'Hi Gemini']; const argv = await parseArguments(); @@ -787,6 +835,14 @@ describe('loadCliConfig', () => { nativeLspServiceMock.mockImplementation( () => createNativeLspServiceInstance() as unknown as NativeLspService, ); + mockSessionServiceCtor.mockImplementation(() => mockSessionServiceInstance); + mockSessionServiceInstance.loadLastSession.mockResolvedValue(undefined); + mockSessionServiceInstance.loadSession.mockResolvedValue(undefined); + mockSessionServiceInstance.forkSession.mockResolvedValue({ + filePath: '/mock/fork.jsonl', + copiedCount: 1, + }); + mockSessionServiceInstance.sessionExists.mockResolvedValue(false); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); }); @@ -853,6 +909,38 @@ describe('loadCliConfig', () => { expect(config.getIncludePartialMessages()).toBe(true); }); + it('should fork and load a new session when --resume is combined with --fork-session', async () => { + const sourceSessionId = '123e4567-e89b-42d3-a456-426614174000'; + const sourceData = { + conversation: { sessionId: sourceSessionId, messages: [] }, + uiHistory: [], + }; + const forkedData = { + conversation: { sessionId: 'forked-session-id', messages: [] }, + uiHistory: [], + }; + mockSessionServiceInstance.loadSession.mockImplementation( + async (sessionId: string) => { + if (sessionId === sourceSessionId) return sourceData; + return forkedData; + }, + ); + + const config = await loadCliConfig({}, { + resume: sourceSessionId, + forkSession: true, + } as CliArgs); + + expect(mockSessionServiceInstance.forkSession).toHaveBeenCalledWith( + sourceSessionId, + config.getSessionId(), + ); + expect(config.getSessionId()).not.toBe(sourceSessionId); + expect(mockSessionServiceInstance.loadSession).toHaveBeenCalledWith( + config.getSessionId(), + ); + }); + it('should reset context filenames to defaults when context.fileName is not configured', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index cc691650f57..4446b9a0c0d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -47,6 +47,7 @@ import { hideBin } from 'yargs/helpers'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { homedir } from 'node:os'; +import { randomUUID } from 'node:crypto'; import stripJsonComments from 'strip-json-comments'; import { resolvePath } from '../utils/resolvePath.js'; @@ -159,6 +160,8 @@ export interface CliArgs { resume: string | undefined; /** Specify a session ID without session resumption */ sessionId: string | undefined; + /** Fork the resumed session into a new session before continuing */ + forkSession?: boolean | undefined; maxSessionTurns: number | undefined; coreTools: string[] | undefined; excludeTools: string[] | undefined; @@ -803,6 +806,12 @@ export async function parseArguments(): Promise { type: 'string', description: 'Specify a session ID for this run.', }) + .option('fork-session', { + type: 'boolean', + description: + 'Fork the resumed session into a new session before continuing. Must be used with --resume or --continue.', + default: false, + }) .option('max-session-turns', { type: 'number', description: 'Maximum number of session turns', @@ -900,9 +909,13 @@ export async function parseArguments(): Promise { if (argv['continue'] && argv['resume']) { return 'Cannot use both --continue and --resume together. Use --continue to resume the latest session, or --resume to resume a specific session.'; } - if (argv['sessionId'] && (argv['continue'] || argv['resume'])) { + const hasResume = argv['resume'] !== undefined; + if (argv['sessionId'] && (argv['continue'] || hasResume)) { return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.'; } + if (argv['forkSession'] && !(argv['continue'] || hasResume)) { + return '--fork-session must be used with --resume or --continue.'; + } if ( argv['sessionId'] && !isValidSessionId(argv['sessionId'] as string) @@ -1517,6 +1530,9 @@ export async function loadCliConfig( sessionData = await sessionService.loadLastSession(); if (sessionData) { sessionId = sessionData.conversation.sessionId; + } else if (argv.forkSession) { + writeStderrLine('No saved session found to fork.'); + process.exit(1); } } @@ -1532,6 +1548,18 @@ export async function loadCliConfig( process.exit(1); } } + + if (argv.forkSession && sessionId) { + const sourceSessionId = sessionId; + const forkedSessionId = randomUUID(); + await sessionService.forkSession(sourceSessionId, forkedSessionId); + sessionId = forkedSessionId; + sessionData = await sessionService.loadSession(forkedSessionId); + if (!sessionData) { + writeStderrLine(`Failed to load forked session ${forkedSessionId}.`); + process.exit(1); + } + } } else if (argv['sessionId']) { // Use provided session ID without session resumption // Check if session ID is already in use From e00b46fa414d33bd55fece027079f2435fa099eb Mon Sep 17 00:00:00 2001 From: qqqys Date: Fri, 15 May 2026 10:59:41 +0800 Subject: [PATCH 2/4] fix(cli): address fork-session review feedback --- packages/cli/src/config/config.test.ts | 24 +++++++++++++++++++++++- packages/cli/src/config/config.ts | 11 ++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index d572aae2a02..8203b36d1ab 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -438,6 +438,8 @@ describe('parseArguments', () => { it('should parse --fork-session with the --resume picker form', async () => { process.argv = ['node', 'script.js', '--resume', '--fork-session']; const argv = await parseArguments(); + // Empty string is the existing yargs shape for picker form: --resume + // without an explicit session ID. expect(argv.resume).toBe(''); expect(argv.forkSession).toBe(true); }); @@ -935,12 +937,32 @@ describe('loadCliConfig', () => { sourceSessionId, config.getSessionId(), ); - expect(config.getSessionId()).not.toBe(sourceSessionId); + expect(config.getSessionId()).toBe( + mockSessionServiceInstance.forkSession.mock.calls[0]?.[1], + ); expect(mockSessionServiceInstance.loadSession).toHaveBeenCalledWith( config.getSessionId(), ); }); + it('should explain when --continue --fork-session has no saved session to fork', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect( + loadCliConfig({}, { + continue: true, + forkSession: true, + } as CliArgs), + ).rejects.toThrow('process.exit called'); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Cannot use --fork-session with --continue: no saved session found to fork.', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + it('should reset context filenames to defaults when context.fileName is not configured', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4446b9a0c0d..f3df331cee3 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -160,7 +160,10 @@ export interface CliArgs { resume: string | undefined; /** Specify a session ID without session resumption */ sessionId: string | undefined; - /** Fork the resumed session into a new session before continuing */ + /** + * Create a new forked session from the resumed session. Must be used with + * --resume or --continue. + */ forkSession?: boolean | undefined; maxSessionTurns: number | undefined; coreTools: string[] | undefined; @@ -809,7 +812,7 @@ export async function parseArguments(): Promise { .option('fork-session', { type: 'boolean', description: - 'Fork the resumed session into a new session before continuing. Must be used with --resume or --continue.', + 'Create a new forked session from the resumed session. Must be used with --resume or --continue.', default: false, }) .option('max-session-turns', { @@ -1531,7 +1534,9 @@ export async function loadCliConfig( if (sessionData) { sessionId = sessionData.conversation.sessionId; } else if (argv.forkSession) { - writeStderrLine('No saved session found to fork.'); + writeStderrLine( + 'Cannot use --fork-session with --continue: no saved session found to fork.', + ); process.exit(1); } } From 5cd1793eabf5fd690cd1b9bd0d07951de39e83a1 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 16 May 2026 18:14:31 +0800 Subject: [PATCH 3/4] fix(cli): handle fork session copy failures --- packages/cli/src/config/config.test.ts | 27 ++++++++++++++++++++++++++ packages/cli/src/config/config.ts | 9 ++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 8203b36d1ab..a82e6b4cfc6 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -945,6 +945,33 @@ describe('loadCliConfig', () => { ); }); + it('should explain when --fork-session fails to copy the source session', async () => { + const sourceSessionId = '123e4567-e89b-42d3-a456-426614174000'; + const sourceData = { + conversation: { sessionId: sourceSessionId, messages: [] }, + uiHistory: [], + }; + mockSessionServiceInstance.loadSession.mockResolvedValue(sourceData); + mockSessionServiceInstance.forkSession.mockRejectedValue( + new Error('source session belongs to another project'), + ); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect( + loadCliConfig({}, { + resume: sourceSessionId, + forkSession: true, + } as CliArgs), + ).rejects.toThrow('process.exit called'); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + `Failed to fork session ${sourceSessionId}: source session belongs to another project`, + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + it('should explain when --continue --fork-session has no saved session to fork', async () => { const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f3df331cee3..7e64bccafbb 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1557,7 +1557,14 @@ export async function loadCliConfig( if (argv.forkSession && sessionId) { const sourceSessionId = sessionId; const forkedSessionId = randomUUID(); - await sessionService.forkSession(sourceSessionId, forkedSessionId); + try { + await sessionService.forkSession(sourceSessionId, forkedSessionId); + } catch (err) { + writeStderrLine( + `Failed to fork session ${sourceSessionId}: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } sessionId = forkedSessionId; sessionData = await sessionService.loadSession(forkedSessionId); if (!sessionData) { From bf80a88e073cf81809d643b7f1a80424a25b0d39 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 16 May 2026 20:10:51 +0800 Subject: [PATCH 4/4] fix(cli): guard sandbox session handoff flag --- packages/cli/src/config/config.test.ts | 21 +++++++++++++++++++++ packages/cli/src/config/config.ts | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c3d3950dab8..42ea0632e6c 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -992,6 +992,7 @@ describe('loadCliConfig', () => { it('should use internal sandbox session ID without treating it as a new session', async () => { const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + vi.stubEnv('SANDBOX', 'sandbox-exec'); process.argv = ['node', 'script.js', '--sandbox-session-id', sessionId]; const argv = await parseArguments(); const settings: Settings = {}; @@ -1001,6 +1002,26 @@ describe('loadCliConfig', () => { expect(mockSessionServiceInstance.sessionExists).not.toHaveBeenCalled(); }); + it('should reject direct use of the internal sandbox session ID flag', async () => { + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + process.argv = ['node', 'script.js', '--sandbox-session-id', sessionId]; + const argv = await parseArguments(); + + await expect(loadCliConfig({}, argv)).rejects.toThrow( + 'process.exit called', + ); + + expect(mockWriteStderrLine).toHaveBeenCalledWith( + '--sandbox-session-id is for internal sandbox use only.', + ); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockSessionServiceInstance.sessionExists).not.toHaveBeenCalled(); + }); + it('should reset context filenames to defaults when context.fileName is not configured', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 45064b6f4b0..17e7a8beeff 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1591,6 +1591,10 @@ export async function loadCliConfig( } } } else if (argv.sandboxSessionId) { + if (!process.env['SANDBOX']) { + writeStderrLine('--sandbox-session-id is for internal sandbox use only.'); + process.exit(1); + } sessionId = argv.sandboxSessionId; } else if (argv['sessionId']) { // Use provided session ID without session resumption