feat: clarify output formats for non-interactive mode

This commit is contained in:
LaZzyMan 2026-01-22 17:06:17 +08:00
parent 21b26a400a
commit 650c625d86
4 changed files with 54 additions and 146 deletions

View file

@ -64,6 +64,7 @@ export interface ResultOptions {
readonly stats?: SessionMetrics;
readonly summary?: string;
readonly subtype?: string;
readonly showResult?: boolean;
}
/**

View file

@ -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 {

View file

@ -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');
});
});

View file

@ -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);