diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 1ce425f42..54b4be287 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -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 // ============================================================================ diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 5978eef3a..0bc89142f 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -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}}" не поддерживается в неинтерактивном режиме.', // ============================================================================ // Метки настроек // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 8798079c9..2cae1bab0 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -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 // ============================================================================ diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 07c806ffe..07fd168fc 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -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 () => { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index f2366c767..067f190b9 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -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: { diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts new file mode 100644 index 000000000..76b29f3e0 --- /dev/null +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -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'); + } + }); +}); diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 410abaa48..2c9a51072 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -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' }; }