mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
fix: explicit output if command is not supported
This commit is contained in:
parent
7f645b9726
commit
5d59ceb6f3
7 changed files with 336 additions and 34 deletions
|
|
@ -258,6 +258,8 @@ export default {
|
|||
', Tab to change focus': ', Tab to change focus',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.',
|
||||
// ============================================================================
|
||||
// Settings Labels
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -260,7 +260,8 @@ export default {
|
|||
', Tab to change focus': ', Tab для смены фокуса',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.',
|
||||
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
'Команда "/{{command}}" не поддерживается в неинтерактивном режиме.',
|
||||
// ============================================================================
|
||||
// Метки настроек
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -249,6 +249,8 @@ export default {
|
|||
', Tab to change focus': ',Tab 切换焦点',
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
|
||||
'要查看更改,必须重启 Qwen Code。按 r 退出并立即应用更改。',
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.':
|
||||
'不支持在非交互模式下使用命令 "/{{command}}"。',
|
||||
// ============================================================================
|
||||
// Settings Labels
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ describe('runNonInteractive', () => {
|
|||
let mockShutdownTelemetry: Mock;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
let processStdoutSpy: MockInstance;
|
||||
let processStderrSpy: MockInstance;
|
||||
let mockGeminiClient: {
|
||||
sendMessageStream: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
|
|
@ -86,6 +87,9 @@ describe('runNonInteractive', () => {
|
|||
processStdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
processStderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code}) called`);
|
||||
});
|
||||
|
|
@ -854,7 +858,7 @@ describe('runNonInteractive', () => {
|
|||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||
});
|
||||
|
||||
it('should throw FatalInputError if a command requires confirmation', async () => {
|
||||
it('should handle command that requires confirmation by returning early', async () => {
|
||||
const mockCommand = {
|
||||
name: 'confirm',
|
||||
description: 'a command that needs confirmation',
|
||||
|
|
@ -866,15 +870,16 @@ describe('runNonInteractive', () => {
|
|||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/confirm',
|
||||
'prompt-id-confirm',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/confirm',
|
||||
'prompt-id-confirm',
|
||||
);
|
||||
|
||||
// Should write error message to stderr
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -911,7 +916,30 @@ describe('runNonInteractive', () => {
|
|||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||
});
|
||||
|
||||
it('should throw for unhandled command result types', async () => {
|
||||
it('should handle known but unsupported slash commands like /help by returning early', async () => {
|
||||
// Mock a built-in command that exists but is not in the allowed list
|
||||
const mockHelpCommand = {
|
||||
name: 'help',
|
||||
description: 'Show help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/help',
|
||||
'prompt-id-help',
|
||||
);
|
||||
|
||||
// Should write error message to stderr
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'The command "/help" is not supported in non-interactive mode.\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unhandled command result types by returning early with error', async () => {
|
||||
const mockCommand = {
|
||||
name: 'noaction',
|
||||
description: 'unhandled type',
|
||||
|
|
@ -922,14 +950,17 @@ describe('runNonInteractive', () => {
|
|||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/noaction',
|
||||
'prompt-id-unhandled',
|
||||
),
|
||||
).rejects.toThrow('Unknown command result type: unhandled');
|
||||
await runNonInteractive(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
'/noaction',
|
||||
'prompt-id-unhandled',
|
||||
);
|
||||
|
||||
// Should write error message to stderr
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Unknown command result type: unhandled\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass arguments to the slash command action', async () => {
|
||||
|
|
|
|||
|
|
@ -205,13 +205,19 @@ export async function runNonInteractive(
|
|||
return;
|
||||
}
|
||||
case 'stream_messages':
|
||||
// ACP exclusive - should not reach here in non-interactive mode
|
||||
throw new FatalInputError(
|
||||
'Stream messages mode is not supported in non-interactive CLI',
|
||||
);
|
||||
break;
|
||||
case 'unsupported':
|
||||
throw new FatalInputError(slashCommandResult.reason);
|
||||
case 'unsupported': {
|
||||
await emitNonInteractiveFinalMessage({
|
||||
message: slashCommandResult.reason,
|
||||
isError: true,
|
||||
adapter,
|
||||
config,
|
||||
startTimeMs: startTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'no_command':
|
||||
break;
|
||||
default: {
|
||||
|
|
|
|||
242
packages/cli/src/nonInteractiveCliCommands.test.ts
Normal file
242
packages/cli/src/nonInteractiveCliCommands.test.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import { CommandKind } from './ui/commands/types.js';
|
||||
|
||||
// Mock the CommandService
|
||||
const mockGetCommands = vi.hoisted(() => vi.fn());
|
||||
const mockCommandServiceCreate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./services/CommandService.js', () => ({
|
||||
CommandService: {
|
||||
create: mockCommandServiceCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('handleSlashCommand', () => {
|
||||
let mockConfig: Config;
|
||||
let mockSettings: LoadedSettings;
|
||||
let abortController: AbortController;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandServiceCreate.mockResolvedValue({
|
||||
getCommands: mockGetCommands,
|
||||
});
|
||||
|
||||
mockConfig = {
|
||||
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||
getFolderTrustFeature: vi.fn().mockReturnValue(false),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
storage: {},
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
system: { path: '', settings: {} },
|
||||
systemDefaults: { path: '', settings: {} },
|
||||
user: { path: '', settings: {} },
|
||||
workspace: { path: '', settings: {} },
|
||||
} as LoadedSettings;
|
||||
|
||||
abortController = new AbortController();
|
||||
});
|
||||
|
||||
it('should return no_command for non-slash input', async () => {
|
||||
const result = await handleSlashCommand(
|
||||
'regular text',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('no_command');
|
||||
});
|
||||
|
||||
it('should return no_command for unknown slash commands', async () => {
|
||||
mockGetCommands.mockReturnValue([]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/unknowncommand',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('no_command');
|
||||
});
|
||||
|
||||
it('should return unsupported for known built-in commands not in allowed list', async () => {
|
||||
const mockHelpCommand = {
|
||||
name: 'help',
|
||||
description: 'Show help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/help',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
[], // Empty allowed list
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toContain('/help');
|
||||
expect(result.reason).toContain('not supported');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unsupported for /help when using default allowed list', async () => {
|
||||
const mockHelpCommand = {
|
||||
name: 'help',
|
||||
description: 'Show help',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockHelpCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/help',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
// Default allowed list: ['init', 'summary', 'compress']
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toBe(
|
||||
'The command "/help" is not supported in non-interactive mode.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute allowed built-in commands', async () => {
|
||||
const mockInitCommand = {
|
||||
name: 'init',
|
||||
description: 'Initialize project',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Project initialized',
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockInitCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/init',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
['init'], // init is in the allowed list
|
||||
);
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
if (result.type === 'message') {
|
||||
expect(result.content).toBe('Project initialized');
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute file commands regardless of allowed list', async () => {
|
||||
const mockFileCommand = {
|
||||
name: 'custom',
|
||||
description: 'Custom file command',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Custom prompt' }],
|
||||
}),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockFileCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/custom',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
[], // Empty allowed list, but FILE commands should still work
|
||||
);
|
||||
|
||||
expect(result.type).toBe('submit_prompt');
|
||||
if (result.type === 'submit_prompt') {
|
||||
expect(result.content).toEqual([{ text: 'Custom prompt' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return unsupported for other built-in commands like /quit', async () => {
|
||||
const mockQuitCommand = {
|
||||
name: 'quit',
|
||||
description: 'Quit application',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockQuitCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/quit',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('unsupported');
|
||||
if (result.type === 'unsupported') {
|
||||
expect(result.reason).toContain('/quit');
|
||||
expect(result.reason).toContain('not supported');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle command with no action', async () => {
|
||||
const mockCommand = {
|
||||
name: 'noaction',
|
||||
description: 'Command without action',
|
||||
kind: CommandKind.FILE,
|
||||
// No action property
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/noaction',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('no_command');
|
||||
});
|
||||
|
||||
it('should return message when command returns void', async () => {
|
||||
const mockCommand = {
|
||||
name: 'voidcmd',
|
||||
description: 'Command that returns void',
|
||||
kind: CommandKind.FILE,
|
||||
action: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockGetCommands.mockReturnValue([mockCommand]);
|
||||
|
||||
const result = await handleSlashCommand(
|
||||
'/voidcmd',
|
||||
abortController,
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
);
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
if (result.type === 'message') {
|
||||
expect(result.content).toBe('Command executed successfully.');
|
||||
expect(result.messageType).toBe('info');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
/**
|
||||
* Built-in commands that are allowed in non-interactive modes (CLI and ACP).
|
||||
|
|
@ -112,13 +113,11 @@ function handleCommandResult(
|
|||
messages: result.messages,
|
||||
};
|
||||
|
||||
//
|
||||
/**
|
||||
* Currently return types below are never generated due to the
|
||||
* whitelist of allowed slash commands in ACP and non-interactive mode.
|
||||
* We'll try to add more supported return types in the future.
|
||||
*/
|
||||
|
||||
case 'tool':
|
||||
return {
|
||||
type: 'unsupported',
|
||||
|
|
@ -246,28 +245,47 @@ export const handleSlashCommand = async (
|
|||
|
||||
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
|
||||
|
||||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||
const loaders =
|
||||
allowedBuiltinSet.size > 0
|
||||
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
|
||||
: [new FileCommandLoader(config)];
|
||||
// Load all commands to check if the command exists but is not allowed
|
||||
const allLoaders = [
|
||||
new BuiltinCommandLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
];
|
||||
|
||||
const commandService = await CommandService.create(
|
||||
loaders,
|
||||
allLoaders,
|
||||
abortController.signal,
|
||||
);
|
||||
const commands = commandService.getCommands();
|
||||
const allCommands = commandService.getCommands();
|
||||
const filteredCommands = filterCommandsForNonInteractive(
|
||||
commands,
|
||||
allCommands,
|
||||
allowedBuiltinSet,
|
||||
);
|
||||
|
||||
// First, try to parse with filtered commands
|
||||
const { commandToExecute, args } = parseSlashCommand(
|
||||
rawQuery,
|
||||
filteredCommands,
|
||||
);
|
||||
|
||||
if (!commandToExecute) {
|
||||
// Check if this is a known command that's just not allowed
|
||||
const { commandToExecute: knownCommand } = parseSlashCommand(
|
||||
rawQuery,
|
||||
allCommands,
|
||||
);
|
||||
|
||||
if (knownCommand) {
|
||||
// Command exists but is not allowed in non-interactive mode
|
||||
return {
|
||||
type: 'unsupported',
|
||||
reason: t(
|
||||
'The command "/{{command}}" is not supported in non-interactive mode.',
|
||||
{ command: knownCommand.name },
|
||||
),
|
||||
originalType: 'filtered_command',
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'no_command' };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue