Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { arenaCommand } from '../ui/commands/arenaCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { branchCommand } from '../ui/commands/branchCommand.js';
import { btwCommand } from '../ui/commands/btwCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
Expand Down Expand Up @@ -97,6 +98,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
arenaCommand,
approvalModeCommand,
authCommand,
branchCommand,
btwCommand,
bugCommand,
clearCommand,
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import { useModelCommand } from './hooks/useModelCommand.js';
import { useManageModelsCommand } from './hooks/useManageModelsCommand.js';
import { useArenaCommand } from './hooks/useArenaCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useBranchCommand } from './hooks/useBranchCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useDeleteCommand } from './hooks/useDeleteCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
Expand Down Expand Up @@ -683,6 +684,14 @@ export const AppContainer = (props: AppContainerProps) => {
remount: refreshStatic,
});

const { handleBranch } = useBranchCommand({
config,
historyManager,
startNewSession,
setSessionName,
remount: refreshStatic,
});

const {
isDeleteDialogOpen,
openDeleteDialog,
Expand Down Expand Up @@ -751,6 +760,7 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
openRewindSelector: () => openRewindSelectorRef.current(),
handleResume,
handleBranch,
openDeleteDialog,
}),
[
Expand All @@ -775,6 +785,7 @@ export const AppContainer = (props: AppContainerProps) => {
openHooksDialog,
openResumeDialog,
handleResume,
handleBranch,
openDeleteDialog,
],
);
Expand Down Expand Up @@ -2628,6 +2639,8 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Branch (fork) session
handleBranch,
// Delete session dialog
openDeleteDialog,
closeDeleteDialog,
Expand Down Expand Up @@ -2693,6 +2706,8 @@ export const AppContainer = (props: AppContainerProps) => {
openResumeDialog,
closeResumeDialog,
handleResume,
// Branch (fork) session
handleBranch,
// Delete session dialog
openDeleteDialog,
closeDeleteDialog,
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/ui/commands/branchCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi } from 'vitest';
import { branchCommand } from './branchCommand.js';
import type { CommandContext } from './types.js';

function makeCtx(
overrides: {
isIdle?: boolean;
sessionExists?: boolean;
noConfig?: boolean;
} = {},
): CommandContext {
const sessionService = {
sessionExists: vi.fn().mockResolvedValue(overrides.sessionExists ?? true),
};
const config = overrides.noConfig
? null
: ({
getSessionId: () => '11111111-1111-1111-1111-111111111111',
getSessionService: () => sessionService,
} as unknown as NonNullable<CommandContext['services']['config']>);
return {
services: { config, settings: {} as never, git: undefined, logger: null },
ui: {
isIdleRef: { current: overrides.isIdle ?? true },
} as unknown as CommandContext['ui'],
session: { stats: {} as never, sessionShellAllowlist: new Set() },
} as unknown as CommandContext;
}

describe('branchCommand', () => {
it('rejects when config is unavailable', async () => {
const result = await branchCommand.action!(makeCtx({ noConfig: true }), '');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
});

it('rejects when no conversation exists to branch from', async () => {
const result = await branchCommand.action!(
makeCtx({ sessionExists: false }),
'',
);
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect((result as { content: string }).content).toMatch(
/No conversation to branch/,
);
});

it('rejects while streaming or awaiting a tool confirmation', async () => {
const result = await branchCommand.action!(makeCtx({ isIdle: false }), '');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect((result as { content: string }).content).toMatch(/in progress/);
});

it('returns dialog action with no name when args are empty', async () => {
const result = await branchCommand.action!(makeCtx(), ' ');
expect(result).toEqual({ type: 'dialog', dialog: 'branch' });
});

it('returns dialog action with trimmed name when args are provided', async () => {
const result = await branchCommand.action!(makeCtx(), ' my-branch ');
expect(result).toEqual({
type: 'dialog',
dialog: 'branch',
name: 'my-branch',
});
});

it('exposes /fork as an alias', () => {
expect(branchCommand.altNames).toContain('fork');
});
});
59 changes: 59 additions & 0 deletions packages/cli/src/ui/commands/branchCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/

import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
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');
},
action: async (context, args): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config is not available.'),
};
}

// Guard: streaming or awaiting tool confirmation — forking mid-flight
// would tear the new session's parent chain.
if (context.ui.isIdleRef?.current === false) {
return {
type: 'message',
messageType: 'error',
content: t(
'Cannot branch while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.',
),
};
}

// Guard: nothing to fork from.
const sessionService = config.getSessionService();
const currentId = config.getSessionId();
const hasRecords = await sessionService.sessionExists(currentId);
if (!hasRecords) {
return {
type: 'message',
messageType: 'error',
content: t('No conversation to branch.'),
};
}

const name = args.trim().replace(/[\r\n]+/g, ' ');
return (
name
? { type: 'dialog', dialog: 'branch', name }
: { type: 'dialog', dialog: 'branch' }
) as SlashCommandActionReturn;
},
};
4 changes: 4 additions & 0 deletions packages/cli/src/ui/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ export interface OpenDialogActionReturn {
/** Pre-filtered sessions for the picker (e.g., multiple title matches from /resume <title>). */
matchedSessions?: SessionListItem[];

/** Optional session name for /branch — passed through to handleBranch. */
name?: string;

dialog:
| 'help'
| 'arena_start'
Expand All @@ -181,6 +184,7 @@ export interface OpenDialogActionReturn {
| 'approval-mode'
| 'resume'
| 'delete'
| 'branch'
| 'extensions_manage'
| 'hooks'
| 'mcp'
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/contexts/UIActionsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export interface UIActions {
openResumeDialog: () => void;
closeResumeDialog: () => void;
handleResume: (sessionId: string) => void;
// Branch (fork) session
handleBranch: (name?: string) => Promise<void>;
// Delete session dialog
openDeleteDialog: () => void;
closeDeleteDialog: () => void;
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1187,4 +1187,53 @@ describe('useSlashCommandProcessor', () => {
).toBeNull();
});
});

describe('SLASH_COMMANDS_SKIP_RECORDING', () => {
// Why these live in the skip set: the fork itself is the side effect
// (new JSONL file with full parent history), so also writing a
// `/branch <name>` slash-command record into the parent session would
// bleed into the fork's tail as a trailing user input — user-visible
// noise with no semantic value. Same rationale for /new, /resume,
// /delete, /clear: session-level commands whose outcome is the new
// session state, not a conversation turn.
it('does not record /branch via the chat recorder', async () => {
const branchCmd = createTestCommand({
name: 'branch',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'branch' }),
});
const result = setupProcessorHook([branchCmd]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

const recorder = mockConfig.getChatRecordingService() as {

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] ChatRecordingService | undefined 强制转换为 { recordSlashCommand: Mock<Procedure>; } — 类型没有充分重叠(TS2352)。需要先通过 unknown 进行中间转换。

Suggested change
const recorder = mockConfig.getChatRecordingService() as {
const recorder = mockConfig.getChatRecordingService() as unknown as {
recordSlashCommand: ReturnType<typeof vi.fn>;
};

— deepseek-v4-pro via Qwen Code /review

recordSlashCommand: ReturnType<typeof vi.fn>;
};
recorder.recordSlashCommand.mockClear();

await act(async () => {
await result.current.handleSlashCommand('/branch my-branch');
});

expect(recorder.recordSlashCommand).not.toHaveBeenCalled();
});

it('still records unrelated commands via the chat recorder (control)', async () => {
const testCmd = createTestCommand({
name: 'regular',
action: vi.fn().mockResolvedValue(undefined),
});
const result = setupProcessorHook([testCmd]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

const recorder = mockConfig.getChatRecordingService() as {

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] 第二个 ChatRecordingService | undefined 强制转换存在相同问题(TS2352),与上方 1207 行模式相同。

Suggested change
const recorder = mockConfig.getChatRecordingService() as {
const recorder = mockConfig.getChatRecordingService() as unknown as {
recordSlashCommand: ReturnType<typeof vi.fn>;
};

— deepseek-v4-pro via Qwen Code /review

recordSlashCommand: ReturnType<typeof vi.fn>;
};
recorder.recordSlashCommand.mockClear();

await act(async () => {
await result.current.handleSlashCommand('/regular');
});

expect(recorder.recordSlashCommand).toHaveBeenCalled();
});
});
});
11 changes: 11 additions & 0 deletions packages/cli/src/ui/hooks/slashCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'new',
'resume',
'delete',
'branch',
'btw',
]);

Expand All @@ -95,6 +96,7 @@ export interface SlashCommandProcessorActions {
openApprovalModeDialog: () => void;
openResumeDialog: (matchedSessions?: SessionListItem[]) => void;
handleResume: (sessionId: string) => void;
handleBranch: (name?: string) => Promise<void>;
openDeleteDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
Expand Down Expand Up @@ -633,6 +635,15 @@ export const useSlashCommandProcessor = (
actions.openResumeDialog(result.matchedSessions);
}
return { type: 'handled' };
case 'branch':
// Must be awaited: `/branch` swaps core + UI session
// state asynchronously, and a non-awaited call lets
// this dispatcher return `handled` while the swap is
// still in flight. A fast follow-up prompt could then
// interleave with the swap and be recorded against
// the wrong session.
await actions.handleBranch(result.name);
return { type: 'handled' };
case 'delete':
actions.openDeleteDialog();
return { type: 'handled' };
Expand Down
Loading
Loading