Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c3504db
fix(cli): prevent file paths from being treated as slash commands (#1…
yiliang114 Apr 29, 2026
776a2ab
fix(cli): allow dots in command names and fix prettier formatting
yiliang114 Apr 29, 2026
7cf3eae
fix(cli): handle slash command review edge cases
yiliang114 Apr 30, 2026
a13d5ba
docs(cli): align slash command validation comment
yiliang114 Apr 30, 2026
e0cb5d6
fix(cli): preserve slash prompt ordering
yiliang114 May 1, 2026
abc8965
fix(cli): reject shell-metacharacter slash tokens
yiliang114 May 2, 2026
579274b
test(cli): align slash command action mocks
yiliang114 May 2, 2026
08d9043
fix(cli): track model-sent user turns
yiliang114 May 2, 2026
4ee65bb
fix(cli): narrow slash path handling scope
yiliang114 May 4, 2026
aaa03e4
fix(cli): split slash routing follow-up
yiliang114 May 4, 2026
d3fe338
fix(cli): tighten slash routing follow-up scope
yiliang114 May 4, 2026
4b1f29c
fix(cli): address slash routing review gaps
yiliang114 May 4, 2026
92c6be7
fix(cli): keep slash history follow-up focused
yiliang114 May 4, 2026
b0feb68
Merge remote-tracking branch 'origin/main' into fix/slash-routing-his…
yiliang114 May 4, 2026
4d16ebe
fix(cli): align resume history item typing
yiliang114 May 4, 2026
1c84fa1
Merge latest main into slash history follow-up
yiliang114 May 4, 2026
d77af74
fix(cli): address slash history review suggestions
yiliang114 May 4, 2026
9682b92
Merge origin/main into slash history follow-up
yiliang114 May 11, 2026
1efa1c3
Merge origin/main into slash history follow-up
yiliang114 May 17, 2026
f7b3aaa
Merge remote-tracking branch 'origin/main' into fix/slash-routing-his…
yiliang114 May 17, 2026
6b71dec
test(cli): harden slash command history item ids
yiliang114 May 17, 2026
639073f
Merge remote-tracking branch 'origin/main' into fix/slash-routing-his…
yiliang114 May 17, 2026
28f4d42
chore(cli): use HistoryItemWithoutId in useEditorSettings.test
yiliang114 May 17, 2026
742e22a
Merge remote-tracking branch 'yiliang114/fix/slash-routing-history-fo…
yiliang114 May 17, 2026
b019022
Merge remote-tracking branch 'origin/main' into fix/slash-routing-his…
yiliang114 May 17, 2026
3cd426b
refactor(cli): keep setSessionName optional in useSlashCommandProcessor
yiliang114 May 17, 2026
2abaf9c
test(cli): fix slash command processor hook args
yiliang114 May 18, 2026
2e06259
fix(cli): harden slash command history metadata
yiliang114 May 18, 2026
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
1 change: 1 addition & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,7 @@ export const AppContainer = (props: AppContainerProps) => {
extensionsUpdateStateInternal,
isConfigInitialized,
logger,
historyManager.updateItem,
setSessionName,
);

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/ui/auth/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { LoadedSettings } from '../../config/settings.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import type { HistoryItemWithoutId } from '../types.js';
import { t } from '../../i18n/index.js';

import { applyProviderInstallPlan } from '../../auth/install/applyProviderInstallPlan.js';
Expand Down Expand Up @@ -110,7 +110,7 @@ export type AuthController = {
export const useAuthCommand = (
settings: LoadedSettings,
config: Config,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
addItem: (item: HistoryItemWithoutId, timestamp: number) => void,
onAuthChange?: () => void,
) => {
const unAuthenticated = config.getAuthType() === undefined;
Expand Down
147 changes: 143 additions & 4 deletions packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from './slashCommandProcessor.js';
import type {
CommandContext,
ConfirmActionReturn,
ConfirmShellCommandsActionReturn,
SlashCommand,
} from '../commands/types.js';
Expand All @@ -29,8 +30,14 @@ import {
makeFakeConfig,
} from '@qwen-code/qwen-code-core';

const { logSlashCommand } = vi.hoisted(() => ({
const { logSlashCommand, debugLoggerMock } = vi.hoisted(() => ({
logSlashCommand: vi.fn(),
debugLoggerMock: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));

vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
Expand All @@ -39,6 +46,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
return {
...original,
logSlashCommand,
createDebugLogger: () => debugLoggerMock,
getIdeInstaller: vi.fn().mockReturnValue(null),
};
});
Expand Down Expand Up @@ -110,6 +118,7 @@ function createTestCommand(

describe('useSlashCommandProcessor', () => {
const mockAddItem = vi.fn();
const mockUpdateItem = vi.fn();
const mockClearItems = vi.fn();
const mockLoadHistory = vi.fn();
const mockOpenThemeDialog = vi.fn();
Expand Down Expand Up @@ -156,6 +165,8 @@ describe('useSlashCommandProcessor', () => {

beforeEach(() => {
vi.clearAllMocks();
let nextHistoryItemId = 1;
mockAddItem.mockImplementation(() => nextHistoryItemId++);
vi.mocked(BuiltinCommandLoader).mockClear();
mockBuiltinLoadCommands.mockResolvedValue([]);
mockFileLoadCommands.mockResolvedValue([]);
Expand Down Expand Up @@ -192,6 +203,7 @@ describe('useSlashCommandProcessor', () => {
new Map(), // extensionsUpdateState
true, // isConfigInitialized
null, // logger
mockUpdateItem,
),
);

Expand Down Expand Up @@ -299,6 +311,15 @@ describe('useSlashCommandProcessor', () => {
});

expect(actionResult).toBe(false);

let absPathResult;
await act(async () => {
absPathResult = await result.current.handleSlashCommand(
'/Users/zhoushuo/Desktop/dw-operator-skill 帮我安装',
);
});

expect(absPathResult).toBe(false);
expect(mockAddItem).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -631,9 +652,21 @@ describe('useSlashCommandProcessor', () => {
});

expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.USER, text: '/filecmd' },
{ type: MessageType.USER, text: '/filecmd', sentToModel: false },
expect.any(Number),
);
expect(mockUpdateItem).toHaveBeenCalledWith(1, { sentToModel: true });
expect(debugLoggerMock.debug).toHaveBeenCalledWith(
'Marked slash command invocation as model-sent: /filecmd',
);
const recorder = mockConfig.getChatRecordingService() as unknown as {
recordSlashCommand: ReturnType<typeof vi.fn>;
};
expect(recorder.recordSlashCommand).toHaveBeenCalledWith({
phase: 'invocation',
rawCommand: '/filecmd',
sentToModel: true,
});
});

it('should handle "submit_prompt" action returned from a mcp-based command', async () => {
Expand Down Expand Up @@ -663,9 +696,10 @@ describe('useSlashCommandProcessor', () => {
});

expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.USER, text: '/mcpcmd' },
{ type: MessageType.USER, text: '/mcpcmd', sentToModel: false },
expect.any(Number),
);
expect(mockUpdateItem).toHaveBeenCalledWith(1, { sentToModel: true });
});
});

Expand Down Expand Up @@ -797,6 +831,107 @@ describe('useSlashCommandProcessor', () => {
expect(finalContext.session.sessionShellAllowlist.size).toBe(0);
});

it('should not duplicate user history when a confirmed command submits a prompt', async () => {
mockCommandAction
.mockResolvedValueOnce({
type: 'confirm_shell_commands',
commandsToConfirm: ['rm -rf /'],
originalInvocation: { raw: '/shellcmd' },
} as ConfirmShellCommandsActionReturn)
.mockResolvedValueOnce({
type: 'submit_prompt',
content: [{ text: 'run approved command' }],
});

const result = setupProcessorHook([shellCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

act(() => {
result.current.handleSlashCommand('/shellcmd');
});
await waitFor(() => {
expect(result.current.shellConfirmationRequest).not.toBeNull();
});

await act(async () => {
result.current.shellConfirmationRequest?.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
['rm -rf /'],
);
});

await waitFor(() => {
expect(mockCommandAction).toHaveBeenCalledTimes(2);
});
const userInvocationCalls = mockAddItem.mock.calls.filter(
([item]) => item.type === MessageType.USER && item.text === '/shellcmd',
);
expect(userInvocationCalls).toHaveLength(1);
expect(mockUpdateItem).toHaveBeenCalledWith(1, { sentToModel: true });

const recorder = mockConfig.getChatRecordingService() as unknown as {
recordSlashCommand: ReturnType<typeof vi.fn>;
};
expect(recorder.recordSlashCommand).toHaveBeenCalledTimes(2);
expect(recorder.recordSlashCommand).toHaveBeenCalledWith({
phase: 'invocation',
rawCommand: '/shellcmd',
sentToModel: true,
});
});

it('should not duplicate user history when a confirmed action submits a prompt', async () => {
const action = vi
.fn()
.mockResolvedValueOnce({
type: 'confirm_action',
prompt: 'Continue?',
originalInvocation: { raw: '/actioncmd' },
} as ConfirmActionReturn)
.mockResolvedValueOnce({
type: 'submit_prompt',
content: [{ text: 'run confirmed action' }],
});
const command = createTestCommand({
name: 'actioncmd',
action,
});

const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));

act(() => {
result.current.handleSlashCommand('/actioncmd');
});
await waitFor(() => {
expect(result.current.confirmationRequest).not.toBeNull();
});

await act(async () => {
result.current.confirmationRequest?.onConfirm(true);
});

await waitFor(() => {
expect(action).toHaveBeenCalledTimes(2);
});
const userInvocationCalls = mockAddItem.mock.calls.filter(
([item]) =>
item.type === MessageType.USER && item.text === '/actioncmd',
);
expect(userInvocationCalls).toHaveLength(1);
expect(mockUpdateItem).toHaveBeenCalledWith(1, { sentToModel: true });

const recorder = mockConfig.getChatRecordingService() as unknown as {
recordSlashCommand: ReturnType<typeof vi.fn>;
};
expect(recorder.recordSlashCommand).toHaveBeenCalledTimes(2);
expect(recorder.recordSlashCommand).toHaveBeenCalledWith({
phase: 'invocation',
rawCommand: '/actioncmd',
sentToModel: true,
});
});

it('should re-run command and update session allowlist on "Proceed Always"', async () => {
const result = setupProcessorHook([shellCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
Expand Down Expand Up @@ -998,7 +1133,7 @@ describe('useSlashCommandProcessor', () => {

// It should be added to the history.
expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.USER, text: '/exit' },
{ type: MessageType.USER, text: '/exit', sentToModel: false },
expect.any(Number),
);
});
Expand All @@ -1024,6 +1159,7 @@ describe('useSlashCommandProcessor', () => {
new Map(), // extensionsUpdateState
true, // isConfigInitialized
null, // logger
mockUpdateItem,
),
);

Expand Down Expand Up @@ -1066,6 +1202,7 @@ describe('useSlashCommandProcessor', () => {
new Map(),
true,
null,
mockUpdateItem,
),
);

Expand Down Expand Up @@ -1134,6 +1271,7 @@ describe('useSlashCommandProcessor', () => {
new Map(),
isConfigInitialized,
null,
mockUpdateItem,
);
},
{ initialProps: { isConfigInitialized: false } },
Expand Down Expand Up @@ -1192,6 +1330,7 @@ describe('useSlashCommandProcessor', () => {
new Map(),
isConfigInitialized,
null,
mockUpdateItem,
),
{ initialProps: { isConfigInitialized: false } },
);
Expand Down
Loading
Loading