mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 04:30:48 +00:00
Merge branch 'main' into fix/vscode-run
This commit is contained in:
commit
b7828ac765
120 changed files with 11934 additions and 126 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
1073
packages/cli/src/i18n/locales/de.js
Normal file
1073
packages/cli/src/i18n/locales/de.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
'Раскручиваем колесо для хомяка...',
|
||||
'Это не баг, а фича...',
|
||||
|
|
|
|||
|
|
@ -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': '选择主题',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue