diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index 072497000..ed8fe0b1b 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -64,6 +64,7 @@ export interface ResultOptions { readonly stats?: SessionMetrics; readonly summary?: string; readonly subtype?: string; + readonly showResult?: boolean; } /** diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts index 118fbc940..de5e6f4f1 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts @@ -67,9 +67,17 @@ export class JsonOutputAdapter ); this.messages.push(resultMessage); - // Emit the entire messages array as JSON (includes all main agent + subagent messages) - const json = JSON.stringify(this.messages); - process.stdout.write(`${json}\n`); + if (options.showResult) { + if (resultMessage.is_error) { + process.stderr.write(`${resultMessage.error?.message || ''}`); + } else { + process.stdout.write(`${resultMessage.result}`); + } + } else { + // Emit the entire messages array as JSON (includes all main agent + subagent messages) + const json = JSON.stringify(this.messages); + process.stdout.write(`${json}\n`); + } } emitMessage(message: CLIMessage): void { diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 840ba69d5..34598b70d 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -228,6 +228,7 @@ describe('runNonInteractive', () => { } it('should process input and write text output', async () => { + setupMetricsMock(); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Hello' }, { type: GeminiEventType.Content, value: ' World' }, @@ -253,13 +254,12 @@ describe('runNonInteractive', () => { 'prompt-id-1', { isContinuation: false }, ); - expect(processStdoutSpy).toHaveBeenCalledWith('Hello'); - expect(processStdoutSpy).toHaveBeenCalledWith(' World'); - expect(processStdoutSpy).toHaveBeenCalledWith('\n'); + expect(processStdoutSpy).toHaveBeenCalledWith('Hello World'); expect(mockShutdownTelemetry).toHaveBeenCalled(); }); it('should handle a single tool call and respond', async () => { + setupMetricsMock(); const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { @@ -298,9 +298,7 @@ describe('runNonInteractive', () => { mockConfig, expect.objectContaining({ name: 'testTool' }), expect.any(AbortSignal), - expect.objectContaining({ - outputUpdateHandler: expect.any(Function), - }), + undefined, ); // Verify first call has isContinuation: false expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( @@ -319,10 +317,10 @@ describe('runNonInteractive', () => { { isContinuation: true }, ); expect(processStdoutSpy).toHaveBeenCalledWith('Final answer'); - expect(processStdoutSpy).toHaveBeenCalledWith('\n'); }); it('should handle error during tool execution and should send error back to the model', async () => { + setupMetricsMock(); const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { @@ -397,6 +395,7 @@ describe('runNonInteractive', () => { }); it('should exit with error if sendMessageStream throws initially', async () => { + setupMetricsMock(); const apiError = new Error('API connection failed'); mockGeminiClient.sendMessageStream.mockImplementation(() => { throw apiError; @@ -413,6 +412,7 @@ describe('runNonInteractive', () => { }); it('should not exit if a tool is not found, and should send error back to model', async () => { + setupMetricsMock(); const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, value: { @@ -464,6 +464,7 @@ describe('runNonInteractive', () => { }); it('should exit when max session turns are exceeded', async () => { + setupMetricsMock(); vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); await expect( runNonInteractive( @@ -476,6 +477,7 @@ describe('runNonInteractive', () => { }); it('should preprocess @include commands before sending to the model', async () => { + setupMetricsMock(); // 1. Mock the imported atCommandProcessor const { handleAtCommand } = await import( './ui/hooks/atCommandProcessor.js' @@ -866,6 +868,7 @@ describe('runNonInteractive', () => { }); it('should execute a slash command that returns a prompt', async () => { + setupMetricsMock(); const mockCommand = { name: 'testcommand', description: 'a test command', @@ -907,6 +910,7 @@ describe('runNonInteractive', () => { }); it('should handle command that requires confirmation by returning early', async () => { + setupMetricsMock(); const mockCommand = { name: 'confirm', description: 'a command that needs confirmation', @@ -925,13 +929,14 @@ describe('runNonInteractive', () => { 'prompt-id-confirm', ); - // Should write error message to stderr + // Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter) expect(processStderrSpy).toHaveBeenCalledWith( - 'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n', + 'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.', ); }); it('should treat an unknown slash command as a regular prompt', async () => { + setupMetricsMock(); // No commands are mocked, so any slash command is "unknown" mockGetCommands.mockReturnValue([]); @@ -965,6 +970,7 @@ describe('runNonInteractive', () => { }); it('should handle known but unsupported slash commands like /help by returning early', async () => { + setupMetricsMock(); // Mock a built-in command that exists but is not in the allowed list const mockHelpCommand = { name: 'help', @@ -981,13 +987,14 @@ describe('runNonInteractive', () => { 'prompt-id-help', ); - // Should write error message to stderr + // Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter) expect(processStderrSpy).toHaveBeenCalledWith( - 'The command "/help" is not supported in non-interactive mode.\n', + 'The command "/help" is not supported in non-interactive mode.', ); }); it('should handle unhandled command result types by returning early with error', async () => { + setupMetricsMock(); const mockCommand = { name: 'noaction', description: 'unhandled type', @@ -1007,11 +1014,12 @@ describe('runNonInteractive', () => { // Should write error message to stderr expect(processStderrSpy).toHaveBeenCalledWith( - 'Unknown command result type: unhandled\n', + 'Unknown command result type: unhandled', ); }); it('should pass arguments to the slash command action', async () => { + setupMetricsMock(); const mockAction = vi.fn().mockResolvedValue({ type: 'submit_prompt', content: [{ text: 'Prompt from command' }], @@ -1825,84 +1833,4 @@ 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'); - }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 4088c9283..09bbd94c1 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -4,11 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Config, - ToolCallRequestInfo, - ToolResultDisplay, -} from '@qwen-code/qwen-code-core'; +import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; import { @@ -92,6 +88,7 @@ async function emitNonInteractiveFinalMessage(params: { usage, stats, summary: message, + showResult: config.getOutputFormat() === OutputFormat.TEXT, }); } @@ -127,7 +124,10 @@ export async function runNonInteractive( if (options.adapter) { adapter = options.adapter; - } else if (outputFormat === OutputFormat.JSON) { + } else if ( + outputFormat === OutputFormat.JSON || + outputFormat === OutputFormat.TEXT + ) { adapter = new JsonOutputAdapter(config); } else if (outputFormat === OutputFormat.STREAM_JSON) { adapter = new StreamJsonOutputAdapter( @@ -297,24 +297,18 @@ export async function runNonInteractive( if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); } - } else { - // Text output mode - direct stdout - if (event.type === GeminiEventType.Thought) { - process.stdout.write(event.value.description); - } else if (event.type === GeminiEventType.Content) { - process.stdout.write(event.value); - } else if (event.type === GeminiEventType.ToolCallRequest) { - toolCallRequests.push(event.value); - } else if (event.type === GeminiEventType.Error) { - // Format and output the error message for text mode - const errorText = parseAndFormatApiError( - event.value.error, - config.getContentGeneratorConfig()?.authType, - ); - process.stderr.write(`${errorText}\n`); - // Throw error to exit with non-zero code - throw new Error(errorText); - } + } + if ( + outputFormat === OutputFormat.TEXT && + event.type === GeminiEventType.Error + ) { + const errorText = parseAndFormatApiError( + event.value.error, + config.getContentGeneratorConfig()?.authType, + ); + process.stderr.write(`${errorText}\n`); + // Throw error to exit with non-zero code + throw new Error(errorText); } } @@ -350,35 +344,13 @@ 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, - outputUpdateHandler || toolCallUpdateCallback + taskToolProgressHandler || toolCallUpdateCallback ? { - ...(outputUpdateHandler && { outputUpdateHandler }), + ...(taskToolProgressHandler && { taskToolProgressHandler }), ...(toolCallUpdateCallback && { onToolCallsUpdate: toolCallUpdateCallback, }), @@ -431,10 +403,8 @@ export async function runNonInteractive( numTurns: turnCount, usage, stats, + showResult: outputFormat === OutputFormat.TEXT, }); - } else { - // Text output mode - no usage needed - process.stdout.write('\n'); } return; } @@ -458,6 +428,7 @@ export async function runNonInteractive( errorMessage: message, usage, stats, + showResult: outputFormat === OutputFormat.TEXT, }); } handleError(error, config);