Skip to content

Commit 7a82306

Browse files
authored
Merge pull request #1383 from QwenLM/fix/tool-result-text-mode
fix: improve tool execution feedback in non-interactive mode
2 parents 49892a8 + aaa66b3 commit 7a82306

File tree

4 files changed

+229
-14
lines changed

4 files changed

+229
-14
lines changed

packages/cli/src/nonInteractiveCli.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,9 @@ describe('runNonInteractive', () => {
298298
mockConfig,
299299
expect.objectContaining({ name: 'testTool' }),
300300
expect.any(AbortSignal),
301-
undefined,
301+
expect.objectContaining({
302+
outputUpdateHandler: expect.any(Function),
303+
}),
302304
);
303305
// Verify first call has isContinuation: false
304306
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
@@ -1777,4 +1779,84 @@ describe('runNonInteractive', () => {
17771779
{ isContinuation: false },
17781780
);
17791781
});
1782+
1783+
it('should print tool output to console in text mode (non-Task tools)', async () => {
1784+
// Test that tool output is printed to stdout in text mode
1785+
const toolCallEvent: ServerGeminiStreamEvent = {
1786+
type: GeminiEventType.ToolCallRequest,
1787+
value: {
1788+
callId: 'tool-1',
1789+
name: 'run_in_terminal',
1790+
args: { command: 'npm outdated' },
1791+
isClientInitiated: false,
1792+
prompt_id: 'prompt-id-tool-output',
1793+
},
1794+
};
1795+
1796+
// Mock tool execution with outputUpdateHandler being called
1797+
mockCoreExecuteToolCall.mockImplementation(
1798+
async (_config, _request, _signal, options) => {
1799+
// Simulate tool calling outputUpdateHandler with output chunks
1800+
if (options?.outputUpdateHandler) {
1801+
options.outputUpdateHandler('tool-1', 'Package outdated\n');
1802+
options.outputUpdateHandler('tool-1', '[email protected] -> [email protected]\n');
1803+
}
1804+
return {
1805+
responseParts: [
1806+
{
1807+
functionResponse: {
1808+
id: 'tool-1',
1809+
name: 'run_in_terminal',
1810+
response: {
1811+
output: 'Package outdated\[email protected] -> [email protected]',
1812+
},
1813+
},
1814+
},
1815+
],
1816+
};
1817+
},
1818+
);
1819+
1820+
const firstCallEvents: ServerGeminiStreamEvent[] = [
1821+
toolCallEvent,
1822+
{
1823+
type: GeminiEventType.Finished,
1824+
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
1825+
},
1826+
];
1827+
1828+
const secondCallEvents: ServerGeminiStreamEvent[] = [
1829+
{ type: GeminiEventType.Content, value: 'Dependencies checked' },
1830+
{
1831+
type: GeminiEventType.Finished,
1832+
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
1833+
},
1834+
];
1835+
1836+
mockGeminiClient.sendMessageStream
1837+
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
1838+
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
1839+
1840+
await runNonInteractive(
1841+
mockConfig,
1842+
mockSettings,
1843+
'Check dependencies',
1844+
'prompt-id-tool-output',
1845+
);
1846+
1847+
// Verify that executeToolCall was called with outputUpdateHandler
1848+
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
1849+
mockConfig,
1850+
expect.objectContaining({ name: 'run_in_terminal' }),
1851+
expect.any(AbortSignal),
1852+
expect.objectContaining({
1853+
outputUpdateHandler: expect.any(Function),
1854+
}),
1855+
);
1856+
1857+
// Verify tool output was written to stdout
1858+
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
1859+
expect(processStdoutSpy).toHaveBeenCalledWith('[email protected] -> [email protected]\n');
1860+
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
1861+
});
17801862
});

packages/cli/src/nonInteractiveCli.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
7+
import type {
8+
Config,
9+
ToolCallRequestInfo,
10+
ToolResultDisplay,
11+
} from '@qwen-code/qwen-code-core';
812
import { isSlashCommand } from './ui/utils/commandUtils.js';
913
import type { LoadedSettings } from './config/settings.js';
1014
import {
@@ -333,7 +337,7 @@ export async function runNonInteractive(
333337
? options.controlService.permission.getToolCallUpdateCallback()
334338
: undefined;
335339

336-
// Only pass outputUpdateHandler for Task tool
340+
// Create output handler for Task tool (for subagent execution)
337341
const isTaskTool = finalRequestInfo.name === 'task';
338342
const taskToolProgress = isTaskTool
339343
? createTaskToolProgressHandler(
@@ -343,20 +347,41 @@ export async function runNonInteractive(
343347
)
344348
: undefined;
345349
const taskToolProgressHandler = taskToolProgress?.handler;
350+
351+
// Create output handler for non-Task tools in text mode (for console output)
352+
const nonTaskOutputHandler =
353+
!isTaskTool && !adapter
354+
? (callId: string, outputChunk: ToolResultDisplay) => {
355+
// Print tool output to console in text mode
356+
if (typeof outputChunk === 'string') {
357+
process.stdout.write(outputChunk);
358+
} else if (
359+
outputChunk &&
360+
typeof outputChunk === 'object' &&
361+
'ansiOutput' in outputChunk
362+
) {
363+
// Handle ANSI output - just print as string for now
364+
process.stdout.write(String(outputChunk.ansiOutput));
365+
}
366+
}
367+
: undefined;
368+
369+
// Combine output handlers
370+
const outputUpdateHandler =
371+
taskToolProgressHandler || nonTaskOutputHandler;
372+
346373
const toolResponse = await executeToolCall(
347374
config,
348375
finalRequestInfo,
349376
abortController.signal,
350-
isTaskTool && taskToolProgressHandler
377+
outputUpdateHandler || toolCallUpdateCallback
351378
? {
352-
outputUpdateHandler: taskToolProgressHandler,
353-
onToolCallsUpdate: toolCallUpdateCallback,
354-
}
355-
: toolCallUpdateCallback
356-
? {
379+
...(outputUpdateHandler && { outputUpdateHandler }),
380+
...(toolCallUpdateCallback && {
357381
onToolCallsUpdate: toolCallUpdateCallback,
358-
}
359-
: undefined,
382+
}),
383+
}
384+
: undefined,
360385
);
361386

362387
// Note: In JSON mode, subagent messages are automatically added to the main

packages/cli/src/utils/errors.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66

77
import { vi, type Mock, type MockInstance } from 'vitest';
88
import type { Config } from '@qwen-code/qwen-code-core';
9-
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
9+
import {
10+
OutputFormat,
11+
FatalInputError,
12+
ToolErrorType,
13+
} from '@qwen-code/qwen-code-core';
1014
import {
1115
getErrorMessage,
1216
handleError,
@@ -65,6 +69,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
6569
describe('errors', () => {
6670
let mockConfig: Config;
6771
let processExitSpy: MockInstance;
72+
let processStderrWriteSpy: MockInstance;
6873
let consoleErrorSpy: MockInstance;
6974

7075
beforeEach(() => {
@@ -74,6 +79,11 @@ describe('errors', () => {
7479
// Mock console.error
7580
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
7681

82+
// Mock process.stderr.write
83+
processStderrWriteSpy = vi
84+
.spyOn(process.stderr, 'write')
85+
.mockImplementation(() => true);
86+
7787
// Mock process.exit to throw instead of actually exiting
7888
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
7989
throw new Error(`process.exit called with code: ${code}`);
@@ -84,11 +94,13 @@ describe('errors', () => {
8494
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
8595
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
8696
getDebugMode: vi.fn().mockReturnValue(true),
97+
isInteractive: vi.fn().mockReturnValue(false),
8798
} as unknown as Config;
8899
});
89100

90101
afterEach(() => {
91102
consoleErrorSpy.mockRestore();
103+
processStderrWriteSpy.mockRestore();
92104
processExitSpy.mockRestore();
93105
});
94106

@@ -432,6 +444,87 @@ describe('errors', () => {
432444
expect(processExitSpy).not.toHaveBeenCalled();
433445
});
434446
});
447+
448+
describe('permission denied warnings', () => {
449+
it('should show warning when EXECUTION_DENIED in non-interactive text mode', () => {
450+
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
451+
(mockConfig.isInteractive as Mock).mockReturnValue(false);
452+
(
453+
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
454+
).mockReturnValue(OutputFormat.TEXT);
455+
456+
handleToolError(
457+
toolName,
458+
toolError,
459+
mockConfig,
460+
ToolErrorType.EXECUTION_DENIED,
461+
);
462+
463+
expect(processStderrWriteSpy).toHaveBeenCalledWith(
464+
expect.stringContaining(
465+
'Warning: Tool "test-tool" requires user approval',
466+
),
467+
);
468+
expect(processStderrWriteSpy).toHaveBeenCalledWith(
469+
expect.stringContaining('use the -y flag (YOLO mode)'),
470+
);
471+
expect(processExitSpy).not.toHaveBeenCalled();
472+
});
473+
474+
it('should not show warning when EXECUTION_DENIED in interactive mode', () => {
475+
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
476+
(mockConfig.isInteractive as Mock).mockReturnValue(true);
477+
(
478+
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
479+
).mockReturnValue(OutputFormat.TEXT);
480+
481+
handleToolError(
482+
toolName,
483+
toolError,
484+
mockConfig,
485+
ToolErrorType.EXECUTION_DENIED,
486+
);
487+
488+
expect(processStderrWriteSpy).not.toHaveBeenCalled();
489+
expect(processExitSpy).not.toHaveBeenCalled();
490+
});
491+
492+
it('should not show warning when EXECUTION_DENIED in JSON mode', () => {
493+
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
494+
(mockConfig.isInteractive as Mock).mockReturnValue(false);
495+
(
496+
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
497+
).mockReturnValue(OutputFormat.JSON);
498+
499+
handleToolError(
500+
toolName,
501+
toolError,
502+
mockConfig,
503+
ToolErrorType.EXECUTION_DENIED,
504+
);
505+
506+
expect(processStderrWriteSpy).not.toHaveBeenCalled();
507+
expect(processExitSpy).not.toHaveBeenCalled();
508+
});
509+
510+
it('should not show warning for non-EXECUTION_DENIED errors', () => {
511+
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
512+
(mockConfig.isInteractive as Mock).mockReturnValue(false);
513+
(
514+
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
515+
).mockReturnValue(OutputFormat.TEXT);
516+
517+
handleToolError(
518+
toolName,
519+
toolError,
520+
mockConfig,
521+
ToolErrorType.FILE_NOT_FOUND,
522+
);
523+
524+
expect(processStderrWriteSpy).not.toHaveBeenCalled();
525+
expect(processExitSpy).not.toHaveBeenCalled();
526+
});
527+
});
435528
});
436529

437530
describe('handleCancellationError', () => {

packages/cli/src/utils/errors.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
parseAndFormatApiError,
1212
FatalTurnLimitedError,
1313
FatalCancellationError,
14+
ToolErrorType,
1415
} from '@qwen-code/qwen-code-core';
1516

1617
export function getErrorMessage(error: unknown): string {
@@ -102,10 +103,24 @@ export function handleToolError(
102103
toolName: string,
103104
toolError: Error,
104105
config: Config,
105-
_errorCode?: string | number,
106+
errorCode?: string | number,
106107
resultDisplay?: string,
107108
): void {
108-
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
109+
// Check if this is a permission denied error in non-interactive mode
110+
const isExecutionDenied = errorCode === ToolErrorType.EXECUTION_DENIED;
111+
const isNonInteractive = !config.isInteractive();
112+
const isTextMode = config.getOutputFormat() === OutputFormat.TEXT;
113+
114+
// Show warning for permission denied errors in non-interactive text mode
115+
if (isExecutionDenied && isNonInteractive && isTextMode) {
116+
const warningMessage =
117+
`Warning: Tool "${toolName}" requires user approval but cannot execute in non-interactive mode.\n` +
118+
`To enable automatic tool execution, use the -y flag (YOLO mode):\n` +
119+
`Example: qwen -p 'your prompt' -y\n\n`;
120+
process.stderr.write(warningMessage);
121+
}
122+
123+
// Always log detailed error in debug mode
109124
if (config.getDebugMode()) {
110125
console.error(
111126
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,

0 commit comments

Comments
 (0)