mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
feat: clarify output formats for non-interactive mode
This commit is contained in:
parent
21b26a400a
commit
650c625d86
4 changed files with 54 additions and 146 deletions
|
|
@ -64,6 +64,7 @@ export interface ResultOptions {
|
|||
readonly stats?: SessionMetrics;
|
||||
readonly summary?: string;
|
||||
readonly subtype?: string;
|
||||
readonly showResult?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue