feat(cli): add /fork background-agent command#4780
Conversation
📋 Review SummaryThis PR introduces a 🔍 General Feedback
🎯 Specific Feedback🟡 High
🟢 Medium
🔵 Low
✅ Highlights
|
Add a `/fork <directive>` slash command that spawns a background agent inheriting the full conversation (system prompt, history, tools, model, prompt-cache parity) to work the directive without blocking the main conversation. It routes through the Agent tool's background path (omitting subagent_type selects the implicit FORK_AGENT), so it reuses the existing BackgroundTaskRegistry, live-activity tracking, JSONL transcript, stop, and completion task-notification. `/branch` drops its `fork` alias so `/fork` unambiguously means the background-agent command; `/branch` keeps the copy-to-new-session behaviour. Guards: empty directive, missing config/model, mid-stream/in-flight, no conversation history yet, and a non-throwing failed launch (concurrency cap) is surfaced instead of a false success. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The background-tasks dialog rendered agent entry labels, titles, activity lines, and prompt text without sanitization, while the sibling LiveAgentPanel and /tasks already strip control codes. With user-driven `/fork` directives flowing into an entry's description/prompt, a raw escape sequence (e.g. clear-screen) could reach the terminal when the dialog is opened. Wrap the description/title/activity/prompt renders in `escapeAnsiCtrlCodes`, matching LiveAgentPanel. Benefits all background agents, not just forks. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
| const agentTool = config.getToolRegistry().getTool(ToolNames.AGENT); | ||
| if (!agentTool) { | ||
| return { | ||
| type: 'message', |
There was a problem hiding this comment.
[Critical] Missing feature-gate check. The fork path in the Agent tool is gated on isForkSubagentEnabled(config) (agent.ts:1431-1432), which defaults to off (config.ts:1078). When the gate is off, omitting subagent_type silently falls back to DEFAULT_BUILTIN_SUBAGENT_TYPE — a general-purpose subagent that starts with zero conversation history. The success message at line 158 ("It inherits this conversation") is then factually false.
| type: 'message', | |
| if (!config.isForkSubagentEnabled?.()) { | |
| return { | |
| type: 'message', | |
| messageType: 'error', | |
| content: t( | |
| 'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.', | |
| ), | |
| }; | |
| } | |
| const agentTool = config.getToolRegistry().getTool(ToolNames.AGENT); |
— qwen3.7-max via Qwen Code /review
| // Guard: a fork inherits the conversation history; there must be one. | ||
| let hasHistory = false; | ||
| try { | ||
| hasHistory = (config.getGeminiClient().getHistory(true) ?? []).length > 0; |
There was a problem hiding this comment.
[Suggestion] getHistory(true) triggers structuredClone of the entire curated conversation (see geminiChat.ts:2377-2383) just to check .length > 0. The codebase explicitly warns against this pattern at chatCompressionService.ts:358: "Avoid getHistory(true) here: long tool-heavy sessions can make a defensive deep clone larger than the remaining V8 heap headroom." For long sessions this can cause noticeable latency.
| hasHistory = (config.getGeminiClient().getHistory(true) ?? []).length > 0; | |
| hasHistory = (config.getGeminiClient().getHistoryShallow() ?? []).length > 0; |
— qwen3.7-max via Qwen Code /review
| }; | ||
| } | ||
|
|
||
| if (!config.getModel()) { |
There was a problem hiding this comment.
[Suggestion] Missing test for the !config.getModel() guard. All tests provide getModel: () => 'test-model', so this branch ("No model configured.") is never exercised. Consider adding a test with getModel: () => '' to cover this path.
— qwen3.7-max via Qwen Code /review
| } | ||
|
|
||
| // Guard: a fork inherits the conversation history; there must be one. | ||
| let hasHistory = false; |
There was a problem hiding this comment.
[Suggestion] Missing test for the catch block around getHistory(). No test makes getHistory() throw to exercise this error-recovery path. Consider adding a test where getHistory throws and assert the result is the "Cannot fork before the first conversation turn" error (not an unhandled rejection).
— qwen3.7-max via Qwen Code /review
| maxWidth: number; | ||
| }> = ({ entry, maxHeight, maxWidth }) => { | ||
| const title = `${entry.subagentType ?? 'Agent'} \u203A ${buildBackgroundEntryLabel(entry, { includePrefix: false })}`; | ||
| const title = escapeAnsiCtrlCodes( |
There was a problem hiding this comment.
[Suggestion] The new ANSI sanitization test only covers list-view rendering. Three detail-view sanitization sites added in this PR (title here, activity label at line 636, prompt lines at line 664) have no test coverage. A malicious /fork directive containing ANSI escape sequences could still inject raw terminal escapes when the user opens the detail view. Consider extending the test to call enterDetail() and assert the detail-mode frame does not contain the raw escape sequence.
— qwen3.7-max via Qwen Code /review
| name: 'fork', | ||
| kind: CommandKind.BUILT_IN, | ||
| supportedModes: ['interactive'] as const, | ||
| get description() { |
There was a problem hiding this comment.
[Suggestion] Missing argumentHint. The command requires a <directive> argument (errors on empty input at line 53), but does not set argumentHint. Other commands that accept arguments consistently set this field — e.g., bugCommand has argumentHint: '<description>', approvalModeCommand has '<mode>', goalCommand has '[<condition> | clear]'. The completion menu reads this field for display.
| get description() { | |
| supportedModes: ['interactive'] as const, | |
| argumentHint: '<directive>', |
— qwen3.7-max via Qwen Code /review
| // the synchronous launch/registration, so a fresh one is sufficient. | ||
| result = await agentTool | ||
| .build(params) | ||
| .execute(new AbortController().signal); |
There was a problem hiding this comment.
[Suggestion] The fork launch calls agentTool.build(params).execute(...) directly, bypassing the tool scheduler. This means no functionCall/functionResponse entries are added to the Gemini client's conversation history. When the fork completes, the <task-notification> XML arrives as a user-role message, but the model has zero prior context — it never issued the tool_use or saw the launch confirmation. Every other notification path starts with a model-issued tool_use, so the model can correlate the completion.
Consider injecting a sentToModel: true history item after a successful launch (e.g., a user-role message like "User launched a background fork via /fork: ") so the model can contextualize the eventual notification.
— qwen3.7-max via Qwen Code /review
| // ============================================================================ | ||
| 'Resume a previous session': '恢复先前会话', | ||
| 'Fork the current conversation into a new session': '将当前对话分支到新会话', | ||
| 'Spawn a background agent that inherits the full conversation': |
There was a problem hiding this comment.
[Suggestion] The description key is translated here, but the other 9 t() strings in forkCommand.ts are missing from both zh.js and zh-TW.js:
'Please provide a directive. Usage: /fork <directive>''Cannot fork while a response or tool call is in progress. …''Cannot fork before the first conversation turn.''The /fork command requires the fork feature gate. …''The agent tool is unavailable; cannot fork.''Failed to launch fork: {{error}}''the background agent could not be started.''User launched a background fork via /fork: {{directive}}''Forked into a background agent. It inherits this conversation …'
The sibling branchCommand has all of its error keys translated in both locale files. Chinese-locale users will currently see these 9 messages in English (via the t() fallback).
— qwen3.7-max via Qwen Code /review
✅ Local Verification Report — PR #4780feat(cli): add /fork background-agent command Environment
Build Verification
Test Results
Note: initial run had 3 failures in PR-scoped tests due to stale Pre-existing Failures (NOT introduced by this PR)
None of these files are modified by this PR. Review Issues — All Addressed
Code Review Summary
Verdict✅ PASS — All 62 PR-scoped tests pass, no regressions, all 8 review issues addressed, clean architecture separating Verified by wenshao |
wenshao
left a comment
There was a problem hiding this comment.
Test coverage gap: hasFailedDisplayStatus fallback branch (forkCommand.ts:160-161) — when returnDisplay.status === 'failed' but llmContent is empty/non-string — is untested. The existing test ('surfaces an error when the launch fails without throwing') only covers the case where llmContent is a non-empty string. Consider adding a test with mockExecute.mockResolvedValue({ returnDisplay: { status: 'failed' } }) (no llmContent) and asserting the error content contains 'the background agent could not be started.'. (Posted as body because the line is not in the PR diff.)
| 'Resume a previous session': '恢复先前会话', | ||
| 'Fork the current conversation into a new session': '将当前对话分支到新会话', | ||
| 'Spawn a background agent that inherits the full conversation': | ||
| '启动继承完整对话的后台代理', |
There was a problem hiding this comment.
[Suggestion] Translation inconsistency: "agent" is translated as "代理" in the new fork strings, but the established codebase convention for AI agents is "智能体" (40+ entries: 'Built-in Agents': '内置智能体', 'subagent': '子智能体', 'No agent selected': '未选择智能体', etc.). "代理" is used elsewhere only for network proxy ('Proxy': '代理').
Additionally, "fork" is left untranslated in several strings (e.g., "无法 fork", "启动 fork 失败", "已 fork 到后台代理"), while the existing entry 'Fork the current conversation into a new session' translates it as "分支" ('将当前对话分支到新会话').
Suggested fixes for zh.js:
'启动继承完整对话的后台代理'→'启动继承完整对话的后台智能体''后台代理无法启动。'→'后台智能体无法启动。''已 fork 到后台代理。...'→'已 fork 到后台智能体。...'- Consider translating "fork" consistently as "分支" (matching the existing convention) or leaving it as-is throughout (and updating the existing description to match).
Apply analogous changes in zh-TW.js using "智能體".
— qwen3.7-max via Qwen Code /review
| parts: [ | ||
| { | ||
| text: t('User launched a background fork via /fork: {{directive}}', { | ||
| directive, |
There was a problem hiding this comment.
[Suggestion] addHistory() is called outside the try/catch that wraps execute(). If addHistory() throws (e.g., Gemini client disconnected), the user gets an unhandled rejection even though the fork is already running in the background. The fork continues working, but the user sees a red error wall and may retry — creating a duplicate fork.
| directive, | |
| try { | |
| config.getGeminiClient().addHistory({ | |
| role: 'user', | |
| parts: [ | |
| { | |
| text: t('User launched a background fork via /fork: {{directive}}', { | |
| directive, | |
| }), | |
| }, | |
| ], | |
| }); | |
| } catch (error) { | |
| debugLogger.debug('Failed to record fork event in history:', error); | |
| } |
— qwen3.7-max via Qwen Code /review
| import { CommandKind } from './types.js'; | ||
| import { t } from '../../i18n/index.js'; | ||
|
|
||
| const debugLogger = createDebugLogger('FORK_COMMAND'); |
There was a problem hiding this comment.
[Suggestion] debugLogger('FORK_COMMAND') is declared here but called exactly once in the entire file (line 94, in the getHistoryShallow catch). There are no debug logs at fork launch, success, failure, or guard returns. Compare with exportCommand (~20+ debug calls) or arenaCommand (5+ calls).
If /fork fails silently, there are no debug logs to trace what went wrong. Consider adding debugLogger.debug at key points: directive received (truncated), guard hit, launch result, returnDisplay shape, history recorded.
— qwen3.7-max via Qwen Code /review
| 'Resume a previous session': 'Resume a previous session', | ||
| 'Fork the current conversation into a new session': | ||
| 'Fork the current conversation into a new session', | ||
| 'Spawn a background agent that inherits the full conversation': |
There was a problem hiding this comment.
[Suggestion] The 10 new fork-specific i18n keys added here (and to zh.js/zh-TW.js) are not in the MUST_TRANSLATE_KEYS array in packages/cli/src/i18n/mustTranslateKeys.ts. Without that, no automated test will fail if a future change accidentally removes a fork translation from a non-English locale — users would silently fall back to the English key string.
Consider adding all 10 new fork keys to MUST_TRANSLATE_KEYS.
— qwen3.7-max via Qwen Code /review
| parts: [ | ||
| { | ||
| text: t('User launched a background fork via /fork: {{directive}}', { | ||
| directive, |
There was a problem hiding this comment.
[Suggestion] addHistory() is called outside the try/catch that wraps execute(). If addHistory() throws (e.g., Gemini client disconnected), the user gets an unhandled rejection even though the fork is already running in the background. The fork continues working, but the user sees a red error wall and may retry — creating a duplicate fork.
| directive, | |
| try { | |
| config.getGeminiClient().addHistory({ | |
| role: 'user', | |
| parts: [ | |
| { | |
| text: t('User launched a background fork via /fork: {{directive}}', { | |
| directive, | |
| }), | |
| }, | |
| ], | |
| }); | |
| } catch (error) { | |
| debugLogger.debug('Failed to record fork event in history:', error); | |
| } |
— qwen3.7-max via Qwen Code /review
| import { CommandKind } from './types.js'; | ||
| import { t } from '../../i18n/index.js'; | ||
|
|
||
| const debugLogger = createDebugLogger('FORK_COMMAND'); |
There was a problem hiding this comment.
[Suggestion] debugLogger('FORK_COMMAND') is declared here but called exactly once in the entire file (line 94, in the getHistoryShallow catch). There are no debug logs at fork launch, success, failure, or guard returns. Compare with exportCommand (~20+ debug calls) or arenaCommand (5+ calls).
If /fork fails silently, there are no debug logs to trace what went wrong. Consider adding debugLogger.debug at key points: directive received (truncated), guard hit, launch result, returnDisplay shape, history recorded.
— qwen3.7-max via Qwen Code /review
| 'Resume a previous session': 'Resume a previous session', | ||
| 'Fork the current conversation into a new session': | ||
| 'Fork the current conversation into a new session', | ||
| 'Spawn a background agent that inherits the full conversation': |
There was a problem hiding this comment.
[Suggestion] The 10 new fork-specific i18n keys added here (and to zh.js/zh-TW.js) are not in the MUST_TRANSLATE_KEYS array in packages/cli/src/i18n/mustTranslateKeys.ts. Without that, no automated test will fail if a future change accidentally removes a fork translation from a non-English locale — users would silently fall back to the English key string.
Consider adding all 10 new fork keys to MUST_TRANSLATE_KEYS.
— qwen3.7-max via Qwen Code /review
wenshao
left a comment
There was a problem hiding this comment.
R7 incremental review — 2 new commits since R6 (5f0cf139 → eb3ff829):
fix(cli): tolerate fork history recording failures— wrapsaddHistory()in try/catch withdebugLoggerfallback. New test verifies success despite recording failure. ✅ Addresses R6 suggestion.test(cli): require fork command translations— adds 9 fork-specific keys toMUST_TRANSLATE_KEYS, a dedicated test, and translations for 6 locales (ca, de, fr, ja, pt, ru). ✅ Addresses R6 suggestion.
No new findings in this round. Deterministic analysis clean (tsc 0 errors, eslint 0 errors). All 84 related tests pass. The remaining R6 suggestion (debugLogger usage breadth) is partially addressed — now called in 2 error-path locations instead of 1.
Downgraded from Approve to Comment: CI still running. — qwen3.7-max via Qwen Code /review
tanzhenxin
left a comment
There was a problem hiding this comment.
Review
The fork lifecycle and guard handling are correct and well-tested, and routing through the existing background-agent machinery instead of reimplementing session-copy is the right design call.
A few low-severity polish items remain, none blocking — good as follow-ups: the inherited-conversation promise drops the trailing user turn in the unusual case where history ends on a user message; the failed-launch fallback message ("the background agent could not be started.") is still unlocalized in ca/de/fr/ja/pt/ru and isn't on the required-translations list; and the background-task label truncates by code unit, so a multi-unit character on the cutoff can render as a broken glyph.
Verdict
APPROVE — solid, well-tested feature with the right architecture; remaining items are low-severity polish that can land as follow-ups.
tanzhenxin
left a comment
There was a problem hiding this comment.
Review
Solid feature — the fork lifecycle and guards are correct and well-tested, and routing through the existing background-agent machinery instead of reimplementing session-copy is the right design. Approving.
A few low-severity, non-blocking items worth a follow-up:
- Trailing user turn dropped on inherit — when the parent history ends on a user message (e.g. after a
!shell command, or an interrupted turn), that last turn is dropped even though the success message says the conversation was inherited. - Failed-launch fallback unlocalized — the "the background agent could not be started." message still renders in English for ca/de/fr/ja/pt/ru and isn't on the required-translations list, so the guard meant to catch this doesn't flag it.
- Label truncates mid-character — the background-task label is cut by code unit rather than character, so a multi-unit character (e.g. an emoji) landing on the cutoff renders as a broken glyph.
Verdict
APPROVE — none of the above block; they can land as follow-ups.
Duplicate of the review immediately following — dismissing to avoid a double approval.
Summary
Adds a
/fork <directive>slash command that spawns a background agent inheriting the full conversation (system prompt, history, tools, model, prompt-cache parity) and works the directive without blocking the main conversation. The fork reports back through the existing background-tasks panel and a terminaltask-notification.This realizes the
/fork⇄/branchsplit:/fork= background agent,/branch= copy-to-new-session.Closes #4757.
What's included
Commit 1 —
feat(cli): add /fork background-agent commandforkCommand.ts: routes through the Agent tool's background path (omittingsubagent_typeselects the implicitFORK_AGENT), reusingBackgroundTaskRegistry, live-activity tracking, JSONL transcript, stop, and the completiontask-notification. Verified behaviorally identical to a model-launched fork./branchdrops itsaltNames: ['fork']so/forkis unambiguous;/branchkeeps the copy-to-new-session behaviour.Commit 2 —
fix(cli): sanitize ANSI/control sequences in background-tasks dialogLiveAgentPaneland/tasksalready strip control codes. Since/forkdirectives flow into an entry's description/prompt, a raw escape sequence could reach the terminal when the dialog opens. Wrapped those renders inescapeAnsiCtrlCodes. Benefits all background agents, not just forks.Testing
forkCommand.test.ts(11) — guards, full-tool launch, inherited history, failed-launch detection.BuiltinCommandLoader.test.ts—/forkis always registered.BackgroundTasksDialog.test.tsx— ANSI/control-sequence injection guard, plus existing render suite.npm run dev:/forklaunches a background agent that inherits the conversation, appears in the inline tracker (main/○ fork: …), runs non-blocking, and reports its result back into the session.Notes / follow-ups
@mention-ing an agent. These are tracked as follow-up UX work.