Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -102,6 +103,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
authCommand,
branchCommand,
btwCommand,
forkCommand,
bugCommand,
clearCommand,
compressCommand,
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/ui/commands/branchCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
1 change: 0 additions & 1 deletion packages/cli/src/ui/commands/branchCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
185 changes: 185 additions & 0 deletions packages/cli/src/ui/commands/forkCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) => {
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<typeof vi.fn>;
let mockBuild: ReturnType<typeof vi.fn>;
let mockGetTool: ReturnType<typeof vi.fn>;

const historyWithTurn = [
{ role: 'user' as const, parts: [{ text: 'hello' }] },
{ role: 'model' as const, parts: [{ text: 'hi there' }] },
];

const createConfig = (overrides: Record<string, unknown> = {}) => ({
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 <directive>',
});
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' });
});
});
156 changes: 156 additions & 0 deletions packages/cli/src/ui/commands/forkCommand.ts
Original file line number Diff line number Diff line change
@@ -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<void | SlashCommandActionReturn> => {
const directive = args.trim();
if (!directive) {
return {
type: 'message',
messageType: 'error',
content: t('Please provide a directive. Usage: /fork <directive>'),
};
}

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.',
),
};
},
};
Loading
Loading