diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index b596c78989c..631d55cbab9 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -211,6 +211,14 @@ describe('BuiltinCommandLoader', () => { expect(modelCmd?.name).toBe('model'); }); + it('should always register the /fork command', async () => { + const loader = new BuiltinCommandLoader(mockConfig); + const commands = await loader.loadCommands(new AbortController().signal); + const forkCmd = commands.find((c) => c.name === 'fork'); + expect(forkCmd).toBeDefined(); + expect(forkCmd?.kind).toBe(CommandKind.BUILT_IN); + }); + it('should include lsp command only when LSP is enabled', async () => { const disabledLoader = new BuiltinCommandLoader(mockConfig); const disabledCommands = await disabledLoader.loadCommands( diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c7fc5592abc..047f6b01f82 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -27,6 +27,7 @@ import { diffCommand } from '../ui/commands/diffCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { exportCommand } from '../ui/commands/exportCommand.js'; +import { forkCommand } from '../ui/commands/forkCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { goalCommand } from '../ui/commands/goalCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; @@ -102,6 +103,7 @@ export class BuiltinCommandLoader implements ICommandLoader { authCommand, branchCommand, btwCommand, + forkCommand, bugCommand, clearCommand, compressCommand, diff --git a/packages/cli/src/ui/commands/branchCommand.test.ts b/packages/cli/src/ui/commands/branchCommand.test.ts index df81527389f..abb2236c00a 100644 --- a/packages/cli/src/ui/commands/branchCommand.test.ts +++ b/packages/cli/src/ui/commands/branchCommand.test.ts @@ -70,7 +70,7 @@ describe('branchCommand', () => { }); }); - it('exposes /fork as an alias', () => { - expect(branchCommand.altNames).toContain('fork'); + it('no longer aliases /fork (now a separate background-fork command)', () => { + expect(branchCommand.altNames ?? []).not.toContain('fork'); }); }); diff --git a/packages/cli/src/ui/commands/branchCommand.ts b/packages/cli/src/ui/commands/branchCommand.ts index e5ca498814f..3f3b345fb49 100644 --- a/packages/cli/src/ui/commands/branchCommand.ts +++ b/packages/cli/src/ui/commands/branchCommand.ts @@ -10,7 +10,6 @@ import { t } from '../../i18n/index.js'; export const branchCommand: SlashCommand = { name: 'branch', - altNames: ['fork'], kind: CommandKind.BUILT_IN, get description() { return t('Fork the current conversation into a new session'); diff --git a/packages/cli/src/ui/commands/forkCommand.test.ts b/packages/cli/src/ui/commands/forkCommand.test.ts new file mode 100644 index 00000000000..08c13a8080a --- /dev/null +++ b/packages/cli/src/ui/commands/forkCommand.test.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { forkCommand } from './forkCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { CommandKind } from './types.js'; + +vi.mock('../../i18n/index.js', () => ({ + t: (key: string, params?: Record) => { + if (params) { + return Object.entries(params).reduce( + (str, [k, v]) => str.replace(`{{${k}}}`, v), + key, + ); + } + return key; + }, +})); + +vi.mock('@qwen-code/qwen-code-core', () => ({ + ToolNames: { AGENT: 'agent' }, +})); + +describe('forkCommand', () => { + let mockContext: CommandContext; + let mockExecute: ReturnType; + let mockBuild: ReturnType; + let mockGetTool: ReturnType; + + const historyWithTurn = [ + { role: 'user' as const, parts: [{ text: 'hello' }] }, + { role: 'model' as const, parts: [{ text: 'hi there' }] }, + ]; + + const createConfig = (overrides: Record = {}) => ({ + getGeminiClient: () => ({ getHistory: () => historyWithTurn }), + getModel: () => 'test-model', + getToolRegistry: () => ({ getTool: mockGetTool }), + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockExecute = vi.fn().mockResolvedValue({ llmContent: 'launched' }); + mockBuild = vi.fn().mockReturnValue({ execute: mockExecute }); + mockGetTool = vi.fn().mockReturnValue({ build: mockBuild }); + mockContext = createMockCommandContext({ + services: { config: createConfig() }, + }); + }); + + it('has correct metadata', () => { + expect(forkCommand.name).toBe('fork'); + expect(forkCommand.kind).toBe(CommandKind.BUILT_IN); + expect(forkCommand.description).toBeTruthy(); + }); + + it('returns usage error when no directive is provided', async () => { + const result = await forkCommand.action!(mockContext, ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Please provide a directive. Usage: /fork ', + }); + expect(mockBuild).not.toHaveBeenCalled(); + }); + + it('returns error when config is not available', async () => { + const noConfig = createMockCommandContext({ + services: { config: null }, + }); + const result = await forkCommand.action!(noConfig, 'do something'); + expect(result).toMatchObject({ + messageType: 'error', + content: 'Config is not available.', + }); + }); + + it('refuses to fork while a response/tool call is in progress', async () => { + const busy = createMockCommandContext({ + services: { config: createConfig() }, + ui: { isIdleRef: { current: false } }, + }); + const result = await forkCommand.action!(busy, 'do something'); + expect(result).toMatchObject({ messageType: 'error' }); + expect(String((result as { content: string }).content)).toContain( + 'in progress', + ); + expect(mockBuild).not.toHaveBeenCalled(); + }); + + it('refuses to fork before the first conversation turn', async () => { + const fresh = createMockCommandContext({ + services: { + config: createConfig({ + getGeminiClient: () => ({ getHistory: () => [] }), + }), + }, + }); + const result = await forkCommand.action!(fresh, 'do something'); + expect(result).toMatchObject({ + messageType: 'error', + content: 'Cannot fork before the first conversation turn.', + }); + expect(mockBuild).not.toHaveBeenCalled(); + }); + + it('errors when the agent tool is unavailable', async () => { + mockGetTool.mockReturnValue(undefined); + const result = await forkCommand.action!(mockContext, 'do something'); + expect(result).toMatchObject({ messageType: 'error' }); + expect(String((result as { content: string }).content)).toContain( + 'agent tool', + ); + }); + + it('launches a background fork via the Agent tool and returns immediately', async () => { + const result = await forkCommand.action!( + mockContext, + 'review the current code', + ); + + // Fetches the Agent tool by its registered name. + expect(mockGetTool).toHaveBeenCalledWith('agent'); + + // Builds a background fork: full directive as prompt, run_in_background, + // no subagent_type (→ implicit FORK_AGENT). + expect(mockBuild).toHaveBeenCalledTimes(1); + const builtParams = mockBuild.mock.calls[0][0]; + expect(builtParams.prompt).toBe('review the current code'); + expect(builtParams.run_in_background).toBe(true); + expect(builtParams.subagent_type).toBeUndefined(); + expect(builtParams.description).toBeTruthy(); + + expect(mockExecute).toHaveBeenCalledTimes(1); + + // Immediate, non-blocking confirmation. + expect(result).toMatchObject({ type: 'message', messageType: 'info' }); + }); + + it('truncates an overlong directive for the panel label', async () => { + const long = 'x'.repeat(200); + await forkCommand.action!(mockContext, long); + const builtParams = mockBuild.mock.calls[0][0]; + expect(builtParams.prompt).toBe(long); // full directive preserved + expect(builtParams.description.length).toBeLessThanOrEqual(60); // label truncated + }); + + it('surfaces an error when the launch throws', async () => { + mockExecute.mockRejectedValue(new Error('concurrency cap reached')); + const result = await forkCommand.action!(mockContext, 'do something'); + expect(result).toMatchObject({ messageType: 'error' }); + expect(String((result as { content: string }).content)).toContain( + 'concurrency cap reached', + ); + }); + + it('surfaces an error when the launch fails without throwing (e.g. concurrency cap)', async () => { + // The Agent tool does not reject on a failed background launch — it + // resolves with a result whose display status is 'failed'. + mockExecute.mockResolvedValue({ + llmContent: 'Cannot start background agent: maximum (10) reached.', + returnDisplay: { status: 'failed' }, + }); + const result = await forkCommand.action!(mockContext, 'do something'); + expect(result).toMatchObject({ messageType: 'error' }); + expect(String((result as { content: string }).content)).toContain( + 'maximum (10) reached', + ); + }); + + it('treats a non-failed result as a successful launch', async () => { + mockExecute.mockResolvedValue({ + llmContent: 'Background agent launched successfully.', + returnDisplay: { status: 'background' }, + }); + const result = await forkCommand.action!(mockContext, 'do something'); + expect(result).toMatchObject({ messageType: 'info' }); + }); +}); diff --git a/packages/cli/src/ui/commands/forkCommand.ts b/packages/cli/src/ui/commands/forkCommand.ts new file mode 100644 index 00000000000..f4051c1a088 --- /dev/null +++ b/packages/cli/src/ui/commands/forkCommand.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ToolNames } from '@qwen-code/qwen-code-core'; +import type { AgentParams } from '@qwen-code/qwen-code-core'; +import type { + CommandContext, + SlashCommand, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +/** Short, human-readable label for the background-tasks panel. */ +function deriveForkDescription(directive: string): string { + const oneLine = directive.replace(/\s+/g, ' ').trim(); + return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine; +} + +export const forkCommand: SlashCommand = { + name: 'fork', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + get description() { + return t('Spawn a background agent that inherits the full conversation'); + }, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const directive = args.trim(); + if (!directive) { + return { + type: 'message', + messageType: 'error', + content: t('Please provide a directive. Usage: /fork '), + }; + } + + const { config } = context.services; + const { ui } = context; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config is not available.'), + }; + } + + // Guard: streaming or awaiting tool confirmation — forking mid-flight + // would snapshot an inconsistent conversation state. + if (ui.isIdleRef?.current === false) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.', + ), + }; + } + + if (!config.getModel()) { + return { + type: 'message', + messageType: 'error', + content: t('No model configured.'), + }; + } + + // Guard: a fork inherits the conversation history; there must be one. + let hasHistory = false; + try { + hasHistory = (config.getGeminiClient().getHistory(true) ?? []).length > 0; + } catch { + hasHistory = false; + } + if (!hasHistory) { + return { + type: 'message', + messageType: 'error', + content: t('Cannot fork before the first conversation turn.'), + }; + } + + // Route through the Agent tool's background path (omitting subagent_type + // selects the implicit FORK_AGENT). This reuses the full background + // machinery: registration in the BackgroundTaskRegistry, live activity + // streaming, a JSONL transcript, completion stats, and a terminal + // task-notification — all surfaced by the existing background-tasks + // pill/dialog (↑/↓ to select, view details, `x` to stop). The fork + // inherits the parent system prompt, history, tools, and model. + const agentTool = config.getToolRegistry().getTool(ToolNames.AGENT); + if (!agentTool) { + return { + type: 'message', + messageType: 'error', + content: t('The agent tool is unavailable; cannot fork.'), + }; + } + + const params: AgentParams = { + description: deriveForkDescription(directive), + prompt: directive, + run_in_background: true, + }; + + let result; + try { + // The background path registers the agent and starts it detached, then + // resolves promptly — it does not block on the fork finishing. + result = await agentTool.build(params).execute(); + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to launch fork: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + }; + } + + // A failed launch (e.g. the background-agent concurrency cap is reached, or + // registration throws) does NOT reject — the Agent tool returns a result + // whose display status is 'failed'. Surface that instead of a misleading + // success message. + const display = result?.returnDisplay; + if ( + display && + typeof display === 'object' && + 'status' in display && + (display as { status?: string }).status === 'failed' + ) { + const reason = + typeof result.llmContent === 'string' && result.llmContent.trim() + ? result.llmContent.trim() + : t('the background agent could not be started.'); + return { + type: 'message', + messageType: 'error', + content: t('Failed to launch fork: {{error}}', { error: reason }), + }; + } + + return { + type: 'message', + messageType: 'info', + content: t( + 'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.', + ), + }; + }, +}; diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx index 69bc50f681a..4006d257855 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx @@ -368,6 +368,26 @@ describe('BackgroundTasksDialog', () => { expect(h.cancel).not.toHaveBeenCalled(); }); + it('sanitizes ANSI/control sequences in an entry label (terminal-injection guard)', () => { + // A /fork directive is user-controlled and flows verbatim into the entry + // description; a raw escape sequence must not reach the terminal when the + // dialog renders the row. + const ESC = ''; + const malicious = entry({ + agentId: 'fork-evil', + subagentType: 'fork', + description: `r${ESC}[2Jx`, + }); + const h = setup([malicious]); + h.call(() => h.probe.current!.actions.openDialog()); + + const frame = h.lastFrame() ?? ''; + // The raw clear-screen escape (ESC + "[2J") never reaches the frame... + expect(frame).not.toContain(`${ESC}[2J`); + // ...it survives only as inert, escaped text. + expect(frame).toContain('[2J'); + }); + it('detail-mode left clears any armed foreground cancel before exiting', () => { // Detail-mode `x` arms the foreground confirm step on the focused // entry. If the user presses `left` to back out without confirming, diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx index b240f037d27..8e53e734b01 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -31,6 +31,7 @@ import { type MonitorTask, } from '@qwen-code/qwen-code-core'; import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; +import { escapeAnsiCtrlCodes } from '../../utils/textUtils.js'; import { type AgentDialogEntry, type DialogEntry, @@ -301,7 +302,9 @@ const ListBody: React.FC<{ {isSelected ? '> ' : ' '} - {rowLabel(entry)} + + {escapeAnsiCtrlCodes(rowLabel(entry))} + ); })} @@ -556,7 +559,9 @@ const AgentDetailBody: React.FC<{ maxHeight: number; maxWidth: number; }> = ({ entry, maxHeight, maxWidth }) => { - const title = `${entry.subagentType ?? 'Agent'} \u203A ${buildBackgroundEntryLabel(entry, { includePrefix: false })}`; + const title = escapeAnsiCtrlCodes( + `${entry.subagentType ?? 'Agent'} \u203A ${buildBackgroundEntryLabel(entry, { includePrefix: false })}`, + ); const terminal = terminalStatusPresentation(entry.status); const dimSubtitleParts: string[] = [elapsedFor(entry)]; @@ -628,7 +633,7 @@ const AgentDetailBody: React.FC<{ // broke alignment in some fonts. const prefix = isLast ? '> ' : ' '; const label = truncateToWidth( - formatActivityLabel(a.name, a.description), + escapeAnsiCtrlCodes(formatActivityLabel(a.name, a.description)), Math.max(0, maxWidth - stringWidth(prefix)), ); return ( @@ -655,7 +660,9 @@ const AgentDetailBody: React.FC<{ {visiblePromptLines.map((line, i) => ( - {line || ' '} + + {escapeAnsiCtrlCodes(line) || ' '} + ))}