Skip to content

feat(cli): add fork-session resume flag#4159

Merged
wenshao merged 6 commits into
QwenLM:mainfrom
qqqys:feat/fork-session-cli
May 16, 2026
Merged

feat(cli): add fork-session resume flag#4159
wenshao merged 6 commits into
QwenLM:mainfrom
qqqys:feat/fork-session-cli

Conversation

@qqqys

@qqqys qqqys commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add a --fork-session CLI flag for resuming into a new session
  • wire --resume ... --fork-session and --continue --fork-session through SessionService.forkSession(...)
  • add parser/config tests for validation, picker-style --resume --fork-session, and forked resume loading

Closes #4158

Test plan

  • npm test -- --run src/config/config.test.ts
  • npm run lint -- src/config/config.ts src/config/config.test.ts

Notes

  • npm run typecheck currently fails on unrelated existing project-wide type errors (for example missing core exports and channel package types). This change does not add forkSession/config-test-related type errors.

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR adds a --fork-session CLI flag that allows users to resume a session while creating a new forked session ID, preserving the original session unchanged. The implementation is clean and well-tested, with proper validation and error handling throughout.

🔍 General Feedback

  • Good use of existing patterns (mirrors --continue/--resume architecture)
  • Test coverage is comprehensive for the new functionality
  • The validation logic correctly prevents invalid flag combinations
  • Error messages are clear and actionable

🎯 Specific Feedback

🟡 High

  • File: packages/cli/src/config/config.ts:1533-1536 - The error handling for --continue --fork-session when no session exists has a logic issue. The else if (argv.forkSession) only triggers when !sessionData in the argv.continue branch, but this means if --continue is used without an existing session AND --fork-session is set, it will error. However, this is actually correct behavior (can't fork nothing), but the error message "No saved session found to fork." could be clearer by mentioning that --continue requires an existing session. Consider: "Cannot use --fork-session with --continue: no saved session found to fork."

🟢 Medium

  • File: packages/cli/src/config/config.ts:164 - The JSDoc comment says "Fork the resumed session into a new session before continuing" but this is slightly misleading since it works with both --resume AND --continue. Consider: "Create a new forked session from the resumed session. Must be used with --resume or --continue."

  • File: packages/cli/src/config/config.ts:916-917 - The validation error message uses --fork-session but the actual flag in yargs is defined as 'fork-session' (kebab-case). While yargs handles both, being consistent in error messages helps users. The current message is correct, but consider adding the short form if one exists (none defined currently).

  • File: packages/cli/src/config/config.test.ts:933 - The test assertion expect(config.getSessionId()).not.toBe(sourceSessionId) is good but could be stronger. Consider also asserting that it equals the expected forked ID: expect(config.getSessionId()).toBe('forked-session-id') to verify the fork actually changed the session ID as expected.

🔵 Low

  • File: packages/cli/src/config/config.ts:809-814 - Consider adding an alias property for --fork-session if there's a natural short form (e.g., -f), though this should be checked against existing aliases to avoid conflicts.

  • File: packages/cli/src/config/config.test.ts:438-443 - The test for "picker form" (--resume --fork-session) expects argv.resume to be '' (empty string). This is correct per the existing --resume behavior, but consider adding a comment explaining why empty string is expected (the picker form) to help future maintainers.

  • File: packages/cli/src/config/config.ts:1552-1561 - The fork logic creates a new SessionService instance inside the if (argv.continue || argv.resume) block at line 1528, then uses it again here. This is fine, but consider extracting the fork logic into a helper function for clarity and testability:

    async function forkCurrentSession(
      sourceSessionId: string,
      sessionService: SessionService
    ): Promise<string> {
      const forkedSessionId = randomUUID();
      await sessionService.forkSession(sourceSessionId, forkedSessionId);
      const sessionData = await sessionService.loadSession(forkedSessionId);
      if (!sessionData) {
        writeStderrLine(`Failed to load forked session ${forkedSessionId}.`);
        process.exit(1);
      }
      return forkedSessionId;
    }

✅ Highlights

  • File: packages/cli/src/config/config.ts:916-917 - Excellent validation preventing --fork-session from being used standalone without --resume or --continue

  • File: packages/cli/src/config/config.test.ts:913-941 - Well-structured test that verifies the complete fork flow including mock setup, execution, and assertions on both the service call and resulting session ID

  • File: packages/cli/src/config/config.test.ts:446-460 - Good coverage of the error case where --fork-session is used without required flags, including verification of the error message

  • Clean integration with existing session resumption logic without disrupting the current flow

wenshao
wenshao previously approved these changes May 16, 2026

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅ — DeepSeek/deepseek-v4-pro via Qwen Code /review

Comment thread packages/cli/src/config/config.ts Outdated
if (argv.forkSession && sessionId) {
const sourceSessionId = sessionId;
const forkedSessionId = randomUUID();
await sessionService.forkSession(sourceSessionId, forkedSessionId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] forkSession() 调用缺少 try-catch 错误处理。若 fork 失败(如源会话属于不同项目),会抛出原始异常堆栈而非用户友好的错误消息,与 loadCliConfig 中其他错误处理风格不一致。

Suggested change
await sessionService.forkSession(sourceSessionId, forkedSessionId);
if (argv.forkSession && sessionId) {
const sourceSessionId = sessionId;
const forkedSessionId = randomUUID();
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) {
writeStderrLine(`Failed to load forked session ${forkedSessionId}.`);
process.exit(1);
}
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

process.exit(1);
}
}
} else if (argv.sandboxSessionId) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] --sandbox-session-id is accepted from any CLI invocation — hidden: true only hides it from --help. The branch sets sessionId = argv.sandboxSessionId without the sessionService.sessionExists() collision check that --session-id performs just below, and without requiring process.env['SANDBOX'] (the codebase already gates sandbox-child behavior on this env var at gemini.tsx:462).

Consider adding a guard: reject the flag when process.env['SANDBOX'] is not set, or at minimum document why the collision guard is intentionally skipped here (the parent process already owns this UUID, but that invariant is not enforced in code).

Suggested change
} else if (argv.sandboxSessionId) {
} else if (argv.sandboxSessionId) {
// Sandbox relaunch handoff: the parent process already owns this UUID.
// Reject direct invocations (not inside the sandbox) to match the
// documented "internal" semantics.
if (!process.env['SANDBOX']) {
writeStderrLine(
'--sandbox-session-id is for internal sandbox use only.',
);
process.exit(1);
}
sessionId = argv.sandboxSessionId;

— claude-opus-4-7 via Qwen Code /review

@wenshao

wenshao commented May 16, 2026

Copy link
Copy Markdown
Collaborator

本地真实场景测试报告(tmux)

Head: bf80a88e (12:10Z) "guard sandbox session handoff flag"

Fork 链验证

3 个连续会话操作,构成完整 fork 链:

2d7fd674 (root, 初次 --prompt 创建)
   ↑
c5b911ef ← forked from 2d7fd674 (非交互式 --continue --fork-session)
   ↑
4f1faa62 ← forked from c5b911ef (tmux 交互式 --continue --fork-session)

每个 forked jsonl 的第一条记录都含 forkedFrom: { sessionId: <parent>, messageUuid: <…> },parent 链完整可追溯。

tmux 交互 TUI 验证

启动:

cd /tmp/pr4159-tui
node dist/cli.js --continue --fork-session
  1. ✅ 新建 forked session 4f1faa62,加载原 session 的历史(Remember: my favorite color is purple + What's my favorite color?
  2. ✅ 输入新问题 What was my favorite color again? → 模型回 紫色 (purple)。你之前告诉过我。 —— 证明 forked context 真的承袭了原 session 的对话历史,不是空白会话

两条 Suggestion 都修了

Suggestion 1 (09:22Z) — forkSession() 加 try-catch 友好错误

packages/cli/src/config/config.ts:1575-1586

if (argv.forkSession && sessionId) {
  const sourceSessionId = sessionId;
  const forkedSessionId = randomUUID();
  try {
    await sessionService.forkSession(sourceSessionId, forkedSessionId);
  } catch (err) {
    writeStderrLine(
      `Failed to fork session ${sourceSessionId}: ${err instanceof Error ? err.message : String(err)}`,
    );
    process.exit(1);
  }
}

Suggestion 2 (11:09Z) — --sandbox-session-id gate on SANDBOX env

packages/cli/src/config/config.ts:1593-1597

} else if (argv.sandboxSessionId) {
  if (!process.env['SANDBOX']) {
    writeStderrLine('--sandbox-session-id is for internal sandbox use only.');
    process.exit(1);
  }
  sessionId = argv.sandboxSessionId;
}

实测:

# 无 SANDBOX
$ node dist/cli.js --sandbox-session-id <uuid> --prompt "hi"
--sandbox-session-id is for internal sandbox use only.

# 有 SANDBOX=1
$ SANDBOX=1 node dist/cli.js --sandbox-session-id <uuid> --prompt "reply 'ok'"
ok

测试 & CI

结果
npx tsc --noEmit -p packages/cli 0 错
npx tsc --noEmit -p packages/core 0 错
config.test.ts 213/213 pass + 2 skipped
GitHub CI 全绿(Lint, Test ubuntu/macos/windows, CodeQL)
--help 显示新 flag ✅ "Create a new forked session from the resumed session. Must be used with --resume or --continue."

LGTM ✅

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已发布详细测试报告(见 issue comment)。2 个 Suggestion 都修了,tmux 真测 fork 链 + forkedFrom 元数据 + 模型从原 context 答出 purple;CI 全绿,tsc 0 错,213/213 单测过。LGTM ✅

@wenshao wenshao merged commit daaa85e into QwenLM:main May 16, 2026
9 checks passed

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] --continue --fork-session 成功路径缺少测试。当前仅覆盖了 loadLastSession() 返回空时的错误分支,未测试存在已保存会话时 fork 成功的正常路径。建议添加与 should fork and load a new session when --resume is combined with --fork-session 对等的 --continue 版本。

[Suggestion] forkSession 成功后 loadSession(forkedSessionId) 返回 undefined 的错误处理分支无测试覆盖。

[Suggestion] fork 成功测试仅验证了函数调用关系,未断言 Config 对象实际填充了 fork 后的会话数据,建议增加对 config 返回值的断言。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

}
if (
argv['sandboxSessionId'] &&
(argv['sessionId'] || argv['continue'] || argv['resume'])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] hasResume 变量在第 921 行引入用于正确识别 picker 模式(--resume 无参产生 ''),但此处 sandbox-session-id 互斥检查仍直接使用 argv['resume'](falsy 值),与上方 L922/925 的 hasResume 用法不一致。

Suggested change
(argv['sessionId'] || argv['continue'] || argv['resume'])
if (
argv['sandboxSessionId'] &&
(argv['sessionId'] || argv['continue'] || hasResume)
) {

— DeepSeek/deepseek-v4-pro via Qwen Code /review

try {
await sessionService.forkSession(sourceSessionId, forkedSessionId);
} catch (err) {
writeStderrLine(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] forkSession 失败时 err.message 可能包含原生 Node.js 错误中的绝对文件系统路径(如 ENOENT: no such file or directory, open '/Users/.../session.jsonl'),直接写入 stderr 会泄露内部目录结构。建议对错误消息做路径脱敏,或仅输出不含路径的通用失败提示。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

expect(mockSessionServiceInstance.forkSession).toHaveBeenCalledWith(
sourceSessionId,
config.getSessionId(),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] 该断言读取 mock 内部数组 mock.calls[0]?.[1] 来验证 config.getSessionId(),与上方 L936 已断言 forkSession(sourceSessionId, config.getSessionId()) 调用构成同义反复。建议改为更直接、有意义的断言:

Suggested change
);
expect(config.getSessionId()).not.toBe(sourceSessionId);

— DeepSeek/deepseek-v4-pro via Qwen Code /review

);
});

it('should explain when --fork-session fails to copy the source session', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] 此测试(以及下方 L976、L1007 两个测试)创建了 vi.spyOn(process, 'exit') 但未调用 mockRestore(),与文件中另外 16 处使用 mockRestore() 的模式不一致。虽然 afterEachvi.restoreAllMocks() 会兜底,建议保持一致:在每个测试的 expect(mockExit).toHaveBeenCalledWith(1) 之后添加 mockExit.mockRestore();

— DeepSeek/deepseek-v4-pro via Qwen Code /review

process.exit(1);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] sessionData 在 L1568 经 loadSession 加载源会话数据,在 fork block 中又被 loadSession(forkedSessionId) 覆盖。无注释说明源 sessionData 在 fork 路径下会被丢弃。未来若有人在 resume 加载和 fork 之间插入对 sessionData 的修改,该修改将在 fork 路径下静默丢失。建议添加注释或使用独立变量名(如 forkedSessionData)。

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a user-facing way to fork from an existing session

3 participants