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

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