Merge branch 'main' into fix/vscode-run

This commit is contained in:
yiliang114 2026-01-09 16:39:12 +08:00
commit b7828ac765
120 changed files with 11934 additions and 126 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.6.0",
"version": "0.6.1",
"description": "Qwen Code",
"repository": {
"type": "git",
@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.1"
},
"dependencies": {
"@google/genai": "1.30.0",

View file

@ -1597,6 +1597,58 @@ describe('Approval mode tool exclusion logic', () => {
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should not exclude a tool explicitly allowed in tools.allowed', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
tools: {
allowed: [ShellTool.Name],
},
};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should not exclude a tool explicitly allowed in tools.core', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {
tools: {
core: [ShellTool.Name],
},
};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should exclude only shell tools in non-interactive mode with auto-edit approval mode', async () => {
process.argv = [
'node',

View file

@ -10,22 +10,24 @@ import {
Config,
DEFAULT_QWEN_EMBEDDING_MODEL,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
EditTool,
FileDiscoveryService,
getCurrentGeminiMdFilename,
loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename,
ShellTool,
WriteFileTool,
resolveTelemetrySettings,
FatalConfigError,
Storage,
InputFormat,
OutputFormat,
isToolEnabled,
SessionService,
type ResumedSessionData,
type FileFilteringOptions,
type MCPServerConfig,
type ToolName,
EditTool,
ShellTool,
WriteFileTool,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js';
@ -111,6 +113,7 @@ export interface CliArgs {
telemetryOutfile: string | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalSkills: boolean | undefined;
extensions: string[] | undefined;
@ -314,10 +317,16 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description: 'Enables checkpointing of file edits',
default: false,
})
.option('experimental-acp', {
.option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-acp', {
type: 'boolean',
description:
'Starts the agent in ACP mode (deprecated, use --acp instead)',
hidden: true,
})
.option('experimental-skills', {
type: 'boolean',
description: 'Enable experimental Skills feature',
@ -599,8 +608,19 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
if (result['experimentalAcp'] && !result['channel']) {
// Handle deprecated --experimental-acp flag
if (result['experimentalAcp']) {
console.warn(
'\x1b[33m⚠ Warning: --experimental-acp is deprecated and will be removed in a future release. Please use --acp instead.\x1b[0m',
);
// Map experimental-acp to acp if acp is not explicitly set
if (!result['acp']) {
(result as Record<string, unknown>)['acp'] = true;
}
}
// Apply ACP fallback: if acp or experimental-acp is present but no explicit --channel, treat as ACP
if ((result['acp'] || result['experimentalAcp']) && !result['channel']) {
(result as Record<string, unknown>)['channel'] = 'ACP';
}
@ -828,6 +848,28 @@ export async function loadCliConfig(
// However, if stream-json input is used, control can be requested via JSON messages,
// so tools should not be excluded in that case.
const extraExcludes: string[] = [];
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
const resolvedAllowedTools =
argv.allowedTools || settings.tools?.allowed || [];
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
if (resolvedCoreTools.length > 0) {
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
return true;
}
}
if (resolvedAllowedTools.length > 0) {
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
return true;
}
}
return false;
};
const excludeUnlessExplicit = (toolName: ToolName): void => {
if (!isExplicitlyEnabled(toolName)) {
extraExcludes.push(toolName);
}
};
if (
!interactive &&
!argv.experimentalAcp &&
@ -836,12 +878,15 @@ export async function loadCliConfig(
switch (approvalMode) {
case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT:
// In default non-interactive mode, all tools that require approval are excluded.
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
// In default non-interactive mode, all tools that require approval are excluded,
// unless explicitly enabled via coreTools/allowedTools.
excludeUnlessExplicit(ShellTool.Name as ToolName);
excludeUnlessExplicit(EditTool.Name as ToolName);
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
break;
case ApprovalMode.AUTO_EDIT:
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
extraExcludes.push(ShellTool.Name);
excludeUnlessExplicit(ShellTool.Name as ToolName);
break;
case ApprovalMode.YOLO:
// No extra excludes for YOLO mode.
@ -991,7 +1036,7 @@ export async function loadCliConfig(
sessionTokenLimit: settings.model?.sessionTokenLimit ?? -1,
maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.experimentalAcp || false,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
experimentalSkills: argv.experimentalSkills || false,
listExtensions: argv.listExtensions || false,
extensions: allExtensions,

View file

@ -202,6 +202,7 @@ const SETTINGS_SCHEMA = {
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
{ value: 'de', label: 'Deutsch (German)' },
],
},
terminalBell: {

View file

@ -460,6 +460,7 @@ describe('gemini.tsx main function kitty protocol', () => {
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
experimentalSkills: undefined,
extensions: undefined,
@ -639,4 +640,37 @@ describe('startInteractiveUI', () => {
await new Promise((resolve) => setTimeout(resolve, 0));
expect(checkForUpdates).toHaveBeenCalledTimes(1);
});
it('should not check for updates when update nag is disabled', async () => {
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
const mockInitializationResult = {
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
};
const settingsWithUpdateNagDisabled = {
merged: {
general: {
disableUpdateNag: true,
},
ui: {
hideWindowTitle: false,
},
},
} as LoadedSettings;
await startInteractiveUI(
mockConfig,
settingsWithUpdateNagDisabled,
mockStartupWarnings,
mockWorkspaceRoot,
mockInitializationResult,
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(checkForUpdates).not.toHaveBeenCalled();
});
});

View file

@ -183,16 +183,18 @@ export async function startInteractiveUI(
},
);
checkForUpdates()
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
console.error('Update check failed:', err);
}
});
if (!settings.merged.general?.disableUpdateNag) {
checkForUpdates()
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
console.error('Update check failed:', err);
}
});
}
registerCleanup(() => instance.unmount());
}

File diff suppressed because it is too large Load diff

View file

@ -89,6 +89,9 @@ export default {
'No tools available': 'No tools available',
'View or change the approval mode for tool usage':
'View or change the approval mode for tool usage',
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}',
'Approval mode set to "{{mode}}"': 'Approval mode set to "{{mode}}"',
'View or change the language setting': 'View or change the language setting',
'change the theme': 'change the theme',
'Select Theme': 'Select Theme',
@ -1037,7 +1040,6 @@ export default {
'Applying percussive maintenance...',
'Searching for the correct USB orientation...',
'Ensuring the magic smoke stays inside the wires...',
'Rewriting in Rust for no particular reason...',
'Trying to exit Vim...',
'Spinning up the hamster wheel...',
"That's not a bug, it's an undocumented feature...",

View file

@ -89,6 +89,10 @@ export default {
'No tools available': 'Нет доступных инструментов',
'View or change the approval mode for tool usage':
'Просмотр или изменение режима подтверждения для использования инструментов',
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
'Недопустимый режим подтверждения "{{arg}}". Допустимые режимы: {{modes}}',
'Approval mode set to "{{mode}}"':
'Режим подтверждения установлен на "{{mode}}"',
'View or change the language setting':
'Просмотр или изменение настроек языка',
'change the theme': 'Изменение темы',
@ -1056,7 +1060,6 @@ export default {
'Провожу настройку методом тыка...',
'Ищем, какой стороной вставлять флешку...',
'Следим, чтобы волшебный дым не вышел из проводов...',
'Переписываем всё на Rust без особой причины...',
'Пытаемся выйти из Vim...',
'Раскручиваем колесо для хомяка...',
'Это не баг, а фича...',

View file

@ -88,6 +88,9 @@ export default {
'No tools available': '没有可用工具',
'View or change the approval mode for tool usage':
'查看或更改工具使用的审批模式',
'Invalid approval mode "{{arg}}". Valid modes: {{modes}}':
'无效的审批模式 "{{arg}}"。有效模式:{{modes}}',
'Approval mode set to "{{mode}}"': '审批模式已设置为 "{{mode}}"',
'View or change the language setting': '查看或更改语言设置',
'change the theme': '更改主题',
'Select Theme': '选择主题',

View file

@ -630,6 +630,67 @@ describe('BaseJsonOutputAdapter', () => {
expect(state.blocks).toHaveLength(0);
});
it('should preserve whitespace in thinking content', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
adapter.exposeAppendThinking(
state,
'',
'The user just said "Hello"',
null,
);
expect(state.blocks).toHaveLength(1);
expect(state.blocks[0]).toMatchObject({
type: 'thinking',
thinking: 'The user just said "Hello"',
});
// Verify spaces are preserved
const block = state.blocks[0] as { thinking: string };
expect(block.thinking).toContain('user just');
expect(block.thinking).not.toContain('userjust');
});
it('should preserve whitespace when appending multiple thinking fragments', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
// Simulate streaming thinking content in fragments
adapter.exposeAppendThinking(state, '', 'The user just', null);
adapter.exposeAppendThinking(state, '', ' said "Hello"', null);
adapter.exposeAppendThinking(
state,
'',
'. This is a simple greeting',
null,
);
expect(state.blocks).toHaveLength(1);
const block = state.blocks[0] as { thinking: string };
// Verify the complete text with all spaces preserved
expect(block.thinking).toBe(
'The user just said "Hello". This is a simple greeting',
);
// Verify specific space preservation
expect(block.thinking).toContain('user just ');
expect(block.thinking).toContain(' said');
expect(block.thinking).toContain('". This');
expect(block.thinking).not.toContain('userjust');
expect(block.thinking).not.toContain('justsaid');
});
it('should preserve leading and trailing whitespace in description', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
adapter.exposeAppendThinking(state, '', ' content with spaces ', null);
expect(state.blocks).toHaveLength(1);
const block = state.blocks[0] as { thinking: string };
expect(block.thinking).toBe(' content with spaces ');
});
});
describe('appendToolUse', () => {

View file

@ -816,9 +816,18 @@ export abstract class BaseJsonOutputAdapter {
parentToolUseId?: string | null,
): void {
const actualParentToolUseId = parentToolUseId ?? null;
const fragment = [subject?.trim(), description?.trim()]
.filter((value) => value && value.length > 0)
.join(': ');
// Build fragment without trimming to preserve whitespace in streaming content
// Only filter out null/undefined/empty values
const parts: string[] = [];
if (subject && subject.length > 0) {
parts.push(subject);
}
if (description && description.length > 0) {
parts.push(description);
}
const fragment = parts.join(': ');
if (!fragment) {
return;
}

View file

@ -323,6 +323,68 @@ describe('StreamJsonOutputAdapter', () => {
});
});
it('should preserve whitespace in thinking content (issue #1356)', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: 'The user just said "Hello"',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
const block = message.message.content[0] as {
type: string;
thinking: string;
};
expect(block.type).toBe('thinking');
expect(block.thinking).toBe('The user just said "Hello"');
// Verify spaces are preserved
expect(block.thinking).toContain('user just');
expect(block.thinking).not.toContain('userjust');
});
it('should preserve whitespace when streaming multiple thinking fragments (issue #1356)', () => {
// Simulate streaming thinking content in multiple events
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: 'The user just',
},
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: ' said "Hello"',
},
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: '',
description: '. This is a simple greeting',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
const block = message.message.content[0] as {
type: string;
thinking: string;
};
expect(block.thinking).toBe(
'The user just said "Hello". This is a simple greeting',
);
// Verify specific spaces are preserved
expect(block.thinking).toContain('user just ');
expect(block.thinking).toContain(' said');
expect(block.thinking).not.toContain('userjust');
expect(block.thinking).not.toContain('justsaid');
});
it('should append tool use from ToolCallRequest events', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,

View file

@ -298,7 +298,9 @@ describe('runNonInteractive', () => {
mockConfig,
expect.objectContaining({ name: 'testTool' }),
expect.any(AbortSignal),
undefined,
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
);
// Verify first call has isContinuation: false
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
@ -771,6 +773,52 @@ describe('runNonInteractive', () => {
);
});
it('should handle API errors in text mode and exit with error code', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.TEXT);
setupMetricsMock();
// Simulate an API error event (like 401 unauthorized)
const apiErrorEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.Error,
value: {
error: {
message: '401 Incorrect API key provided',
status: 401,
},
},
};
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([apiErrorEvent]),
);
let thrownError: Error | null = null;
try {
await runNonInteractive(
mockConfig,
mockSettings,
'Test input',
'prompt-id-api-error',
);
// Should not reach here
expect.fail('Expected error to be thrown');
} catch (error) {
thrownError = error as Error;
}
// Should throw with the API error message
expect(thrownError).toBeTruthy();
expect(thrownError?.message).toContain('401');
expect(thrownError?.message).toContain('Incorrect API key provided');
// Verify error was written to stderr
expect(processStderrSpy).toHaveBeenCalled();
const stderrCalls = processStderrSpy.mock.calls;
const errorOutput = stderrCalls.map((call) => call[0]).join('');
expect(errorOutput).toContain('401');
expect(errorOutput).toContain('Incorrect API key provided');
});
it('should handle FatalInputError with custom exit code in JSON format', async () => {
(mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON);
setupMetricsMock();
@ -1777,4 +1825,84 @@ describe('runNonInteractive', () => {
{ isContinuation: false },
);
});
it('should print tool output to console in text mode (non-Task tools)', async () => {
// Test that tool output is printed to stdout in text mode
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'run_in_terminal',
args: { command: 'npm outdated' },
isClientInitiated: false,
prompt_id: 'prompt-id-tool-output',
},
};
// Mock tool execution with outputUpdateHandler being called
mockCoreExecuteToolCall.mockImplementation(
async (_config, _request, _signal, options) => {
// Simulate tool calling outputUpdateHandler with output chunks
if (options?.outputUpdateHandler) {
options.outputUpdateHandler('tool-1', 'Package outdated\n');
options.outputUpdateHandler('tool-1', 'npm@1.0.0 -> npm@2.0.0\n');
}
return {
responseParts: [
{
functionResponse: {
id: 'tool-1',
name: 'run_in_terminal',
response: {
output: 'Package outdated\nnpm@1.0.0 -> npm@2.0.0',
},
},
},
],
};
},
);
const firstCallEvents: ServerGeminiStreamEvent[] = [
toolCallEvent,
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
},
];
const secondCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Dependencies checked' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
},
];
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
await runNonInteractive(
mockConfig,
mockSettings,
'Check dependencies',
'prompt-id-tool-output',
);
// Verify that executeToolCall was called with outputUpdateHandler
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({ name: 'run_in_terminal' }),
expect.any(AbortSignal),
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
);
// Verify tool output was written to stdout
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
});
});

View file

@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
import type {
Config,
ToolCallRequestInfo,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js';
import {
@ -308,6 +312,8 @@ export async function runNonInteractive(
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
}
}
}
@ -333,7 +339,7 @@ export async function runNonInteractive(
? options.controlService.permission.getToolCallUpdateCallback()
: undefined;
// Only pass outputUpdateHandler for Task tool
// Create output handler for Task tool (for subagent execution)
const isTaskTool = finalRequestInfo.name === 'task';
const taskToolProgress = isTaskTool
? createTaskToolProgressHandler(
@ -343,20 +349,41 @@ export async function runNonInteractive(
)
: undefined;
const taskToolProgressHandler = taskToolProgress?.handler;
// Create output handler for non-Task tools in text mode (for console output)
const nonTaskOutputHandler =
!isTaskTool && !adapter
? (callId: string, outputChunk: ToolResultDisplay) => {
// Print tool output to console in text mode
if (typeof outputChunk === 'string') {
process.stdout.write(outputChunk);
} else if (
outputChunk &&
typeof outputChunk === 'object' &&
'ansiOutput' in outputChunk
) {
// Handle ANSI output - just print as string for now
process.stdout.write(String(outputChunk.ansiOutput));
}
}
: undefined;
// Combine output handlers
const outputUpdateHandler =
taskToolProgressHandler || nonTaskOutputHandler;
const toolResponse = await executeToolCall(
config,
finalRequestInfo,
abortController.signal,
isTaskTool && taskToolProgressHandler
outputUpdateHandler || toolCallUpdateCallback
? {
outputUpdateHandler: taskToolProgressHandler,
onToolCallsUpdate: toolCallUpdateCallback,
}
: toolCallUpdateCallback
? {
...(outputUpdateHandler && { outputUpdateHandler }),
...(toolCallUpdateCallback && {
onToolCallsUpdate: toolCallUpdateCallback,
}
: undefined,
}),
}
: undefined,
);
// Note: In JSON mode, subagent messages are automatically added to the main

View file

@ -72,6 +72,7 @@ describe('ShellProcessor', () => {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
getAllowedTools: vi.fn().mockReturnValue([]),
};
context = createMockCommandContext({
@ -196,6 +197,35 @@ describe('ShellProcessor', () => {
);
});
it('should NOT throw ConfirmationRequiredError when a command matches allowedTools', async () => {
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent = createPromptPipelineContent(
'Do something dangerous: !{rm -rf /}',
);
mockCheckCommandPermissions.mockReturnValue({
allAllowed: false,
disallowedCommands: ['rm -rf /'],
});
(mockConfig.getAllowedTools as Mock).mockReturnValue([
'ShellTool(rm -rf /)',
]);
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
});
const result = await processor.process(prompt, context);
expect(mockShellExecute).toHaveBeenCalledWith(
'rm -rf /',
expect.any(String),
expect.any(Function),
expect.any(Object),
false,
expect.any(Object),
);
expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]);
});
it('should NOT throw ConfirmationRequiredError if a command is not allowed but approval mode is YOLO', async () => {
const processor = new ShellProcessor('test-command');
const prompt: PromptPipelineContent = createPromptPipelineContent(

View file

@ -7,11 +7,13 @@
import {
ApprovalMode,
checkCommandPermissions,
doesToolInvocationMatch,
escapeShellArg,
getShellConfiguration,
ShellExecutionService,
flatMapTextParts,
} from '@qwen-code/qwen-code-core';
import type { AnyToolInvocation } from '@qwen-code/qwen-code-core';
import type { CommandContext } from '../../ui/commands/types.js';
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
@ -124,6 +126,15 @@ export class ShellProcessor implements IPromptProcessor {
// Security check on the final, escaped command string.
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
checkCommandPermissions(command, config, sessionShellAllowlist);
const allowedTools = config.getAllowedTools() || [];
const invocation = {
params: { command },
} as AnyToolInvocation;
const isAllowedBySettings = doesToolInvocationMatch(
'run_shell_command',
invocation,
allowedTools,
);
if (!allAllowed) {
if (isHardDenial) {
@ -132,10 +143,17 @@ export class ShellProcessor implements IPromptProcessor {
);
}
// If not a hard denial, respect YOLO mode and auto-approve.
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
// If the command is allowed by settings, skip confirmation.
if (isAllowedBySettings) {
continue;
}
// If not a hard denial, respect YOLO mode and auto-approve.
if (config.getApprovalMode() === ApprovalMode.YOLO) {
continue;
}
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
}
}

View file

@ -925,7 +925,12 @@ export const AppContainer = (props: AppContainerProps) => {
const handleIdePromptComplete = useCallback(
(result: IdeIntegrationNudgeResult) => {
if (result.userSelection === 'yes') {
handleSlashCommand('/ide install');
// Check whether the extension has been pre-installed
if (result.isExtensionPreInstalled) {
handleSlashCommand('/ide enable');
} else {
handleSlashCommand('/ide install');
}
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
} else if (result.userSelection === 'dismiss') {
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);

View file

@ -38,6 +38,7 @@ export function IdeIntegrationNudge({
);
const { displayName: ideName } = ide;
const isInSandbox = !!process.env['SANDBOX'];
// Assume extension is already installed if the env variables are set.
const isExtensionPreInstalled =
!!process.env['QWEN_CODE_IDE_SERVER_PORT'] &&
@ -70,13 +71,15 @@ export function IdeIntegrationNudge({
},
];
const installText = isExtensionPreInstalled
? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`;
const installText = isInSandbox
? `Note: In sandbox environments, IDE integration requires manual setup on the host system. If you select Yes, you'll receive instructions on how to set this up.`
: isExtensionPreInstalled
? `If you select Yes, the CLI will connect to your ${
ideName ?? 'editor'
} and have access to your open files and display diffs directly.`
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`;
return (
<Box

View file

@ -4,31 +4,28 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { approvalModeCommand } from './approvalModeCommand.js';
import {
type CommandContext,
CommandKind,
type OpenDialogActionReturn,
type MessageActionReturn,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { LoadedSettings } from '../../config/settings.js';
describe('approvalModeCommand', () => {
let mockContext: CommandContext;
let mockSetApprovalMode: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockSetApprovalMode = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
getApprovalMode: () => 'default',
setApprovalMode: () => {},
setApprovalMode: mockSetApprovalMode,
},
settings: {
merged: {},
setValue: () => {},
forScope: () => ({}),
} as unknown as LoadedSettings,
},
});
});
@ -41,7 +38,7 @@ describe('approvalModeCommand', () => {
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should open approval mode dialog when invoked', async () => {
it('should open approval mode dialog when invoked without arguments', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'',
@ -51,16 +48,123 @@ describe('approvalModeCommand', () => {
expect(result.dialog).toBe('approval-mode');
});
it('should open approval mode dialog with arguments (ignored)', async () => {
it('should open approval mode dialog when invoked with whitespace only', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'some arguments',
' ',
)) as OpenDialogActionReturn;
expect(result.type).toBe('dialog');
expect(result.dialog).toBe('approval-mode');
});
describe('direct mode setting (session-only)', () => {
it('should set approval mode to "plan" when argument is "plan"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('plan');
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
});
it('should set approval mode to "yolo" when argument is "yolo"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'yolo',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('yolo');
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
});
it('should set approval mode to "auto-edit" when argument is "auto-edit"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('auto-edit');
expect(mockSetApprovalMode).toHaveBeenCalledWith('auto-edit');
});
it('should set approval mode to "default" when argument is "default"', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'default',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toContain('default');
expect(mockSetApprovalMode).toHaveBeenCalledWith('default');
});
it('should be case-insensitive for mode argument', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'YOLO',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(mockSetApprovalMode).toHaveBeenCalledWith('yolo');
});
it('should handle argument with leading/trailing whitespace', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
' plan ',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(mockSetApprovalMode).toHaveBeenCalledWith('plan');
});
});
describe('invalid mode argument', () => {
it('should return error for invalid mode', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'invalid-mode',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('invalid-mode');
expect(result.content).toContain('plan');
expect(result.content).toContain('yolo');
expect(mockSetApprovalMode).not.toHaveBeenCalled();
});
});
describe('untrusted folder handling', () => {
it('should return error when setApprovalMode throws (e.g., untrusted folder)', async () => {
const errorMessage =
'Cannot enable privileged approval modes in an untrusted folder.';
mockSetApprovalMode.mockImplementation(() => {
throw new Error(errorMessage);
});
const result = (await approvalModeCommand.action?.(
mockContext,
'yolo',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(errorMessage);
});
});
it('should not have subcommands', () => {
expect(approvalModeCommand.subCommands).toBeUndefined();
});

View file

@ -8,9 +8,25 @@ import type {
SlashCommand,
CommandContext,
OpenDialogActionReturn,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import type { ApprovalMode } from '@qwen-code/qwen-code-core';
import { APPROVAL_MODES } from '@qwen-code/qwen-code-core';
/**
* Parses the argument string and returns the corresponding ApprovalMode if valid.
* Returns undefined if the argument is empty or not a valid mode.
*/
function parseApprovalModeArg(arg: string): ApprovalMode | undefined {
const trimmed = arg.trim().toLowerCase();
if (!trimmed) {
return undefined;
}
// Match against valid approval modes (case-insensitive)
return APPROVAL_MODES.find((mode) => mode.toLowerCase() === trimmed);
}
export const approvalModeCommand: SlashCommand = {
name: 'approval-mode',
@ -19,10 +35,49 @@ export const approvalModeCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
action: async (
_context: CommandContext,
_args: string,
): Promise<OpenDialogActionReturn> => ({
type: 'dialog',
dialog: 'approval-mode',
}),
context: CommandContext,
args: string,
): Promise<OpenDialogActionReturn | MessageActionReturn> => {
const mode = parseApprovalModeArg(args);
// If no argument provided, open the dialog
if (!args.trim()) {
return {
type: 'dialog',
dialog: 'approval-mode',
};
}
// If invalid argument, return error message with valid options
if (!mode) {
return {
type: 'message',
messageType: 'error',
content: t('Invalid approval mode "{{arg}}". Valid modes: {{modes}}', {
arg: args.trim(),
modes: APPROVAL_MODES.join(', '),
}),
};
}
// Set the mode for current session only (not persisted)
const { config } = context.services;
if (config) {
try {
config.setApprovalMode(mode);
} catch (e) {
return {
type: 'message',
messageType: 'error',
content: (e as Error).message,
};
}
}
return {
type: 'message',
messageType: 'info',
content: t('Approval mode set to "{{mode}}"', { mode }),
};
},
};

View file

@ -191,11 +191,23 @@ export const ideCommand = async (): Promise<SlashCommand> => {
kind: CommandKind.BUILT_IN,
action: async (context) => {
const installer = getIdeInstaller(currentIDE);
const isSandBox = !!process.env['SANDBOX'];
if (isSandBox) {
context.ui.addItem(
{
type: 'info',
text: `IDE integration needs to be installed on the host. If you have already installed it, you can directly connect the ide`,
},
Date.now(),
);
return;
}
if (!installer) {
const ideName = ideClient.getDetectedIdeDisplayName();
context.ui.addItem(
{
type: 'error',
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
text: `Automatic installation is not supported for ${ideName}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
},
Date.now(),
);

View file

@ -87,7 +87,13 @@ export async function showResumeSessionPicker(
let selectedId: string | undefined;
const { unmount, waitUntilExit } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<KeypressProvider
kittyProtocolEnabled={false}
pasteWorkaround={
process.platform === 'win32' ||
parseInt(process.versions.node.split('.')[0], 10) < 20
}
>
<StandalonePickerScreen
sessionService={sessionService}
onSelect={(id) => {

View file

@ -6,7 +6,11 @@
import { vi, type Mock, type MockInstance } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
import {
OutputFormat,
FatalInputError,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
import {
getErrorMessage,
handleError,
@ -65,6 +69,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
describe('errors', () => {
let mockConfig: Config;
let processExitSpy: MockInstance;
let processStderrWriteSpy: MockInstance;
let consoleErrorSpy: MockInstance;
beforeEach(() => {
@ -74,6 +79,11 @@ describe('errors', () => {
// Mock console.error
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock process.stderr.write
processStderrWriteSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
// Mock process.exit to throw instead of actually exiting
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit called with code: ${code}`);
@ -84,11 +94,13 @@ describe('errors', () => {
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
getDebugMode: vi.fn().mockReturnValue(true),
isInteractive: vi.fn().mockReturnValue(false),
} as unknown as Config;
});
afterEach(() => {
consoleErrorSpy.mockRestore();
processStderrWriteSpy.mockRestore();
processExitSpy.mockRestore();
});
@ -432,6 +444,87 @@ describe('errors', () => {
expect(processExitSpy).not.toHaveBeenCalled();
});
});
describe('permission denied warnings', () => {
it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.EXECUTION_DENIED,
);
expect(processStderrWriteSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Warning: Tool "test-tool" requires user approval',
),
);
expect(processStderrWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('use the -y flag (YOLO mode)'),
);
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not show warning when EXECUTION_DENIED in interactive mode', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(true);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.EXECUTION_DENIED,
);
expect(processStderrWriteSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not show warning when EXECUTION_DENIED in JSON mode', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.JSON);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.EXECUTION_DENIED,
);
expect(processStderrWriteSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not show warning for non-EXECUTION_DENIED errors', () => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(
toolName,
toolError,
mockConfig,
ToolErrorType.FILE_NOT_FOUND,
);
expect(processStderrWriteSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
});
});
describe('handleCancellationError', () => {

View file

@ -11,6 +11,7 @@ import {
parseAndFormatApiError,
FatalTurnLimitedError,
FatalCancellationError,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
export function getErrorMessage(error: unknown): string {
@ -102,10 +103,24 @@ export function handleToolError(
toolName: string,
toolError: Error,
config: Config,
_errorCode?: string | number,
errorCode?: string | number,
resultDisplay?: string,
): void {
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
// Check if this is a permission denied error in non-interactive mode
const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED;
const isNonInteractive = !config.isInteractive();
const isTextMode = config.getOutputFormat() === OutputFormat.TEXT;
// Show warning for permission denied errors in non-interactive text mode
if (isExecutionDenied && isNonInteractive && isTextMode) {
const warningMessage =
`Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` +
`To enable automatic tool execution, use the -y flag (YOLO mode):\n` +
`Example: qwen -p 'your prompt' -y\n\n`;
process.stderr.write(warningMessage);
}
// Always log detailed error in debug mode
if (config.getDebugMode()) {
console.error(
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,