fix(cli): Add delimiter before printing tool response in non-interactive mode (#11351)

This commit is contained in:
Krishna Bajpai
2025-10-27 07:57:54 -07:00
committed by GitHub
parent 2fa13420ae
commit c7817aee30
7 changed files with 305 additions and 32 deletions
+82 -14
View File
@@ -190,6 +190,9 @@ describe('runNonInteractive', () => {
}
}
const getWrittenOutput = () =>
processStdoutSpy.mock.calls.map((c) => c[0]).join('');
it('should process input and write text output', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Hello' },
@@ -215,9 +218,7 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-1',
);
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
expect(getWrittenOutput()).toBe('Hello World\n');
expect(mockShutdownTelemetry).toHaveBeenCalled();
});
@@ -285,8 +286,77 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-2',
);
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
expect(getWrittenOutput()).toBe('Final answer\n');
});
it('should write a single newline between sequential text outputs from the model', async () => {
// This test simulates a multi-turn conversation to ensure that a single newline
// is printed between each block of text output from the model.
// 1. Define the tool requests that the model will ask the CLI to run.
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'mock-tool',
name: 'mockTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-multi',
},
};
// 2. Mock the execution of the tools. We just need them to succeed.
mockCoreExecuteToolCall.mockResolvedValue({
status: 'success',
request: toolCallEvent.value, // This is generic enough for both calls
tool: {} as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
response: {
responseParts: [],
callId: 'mock-tool',
},
});
// 3. Define the sequence of events streamed from the mock model.
// Turn 1: Model outputs text, then requests a tool call.
const modelTurn1: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Use mock tool' },
toolCallEvent,
];
// Turn 2: Model outputs more text, then requests another tool call.
const modelTurn2: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Use mock tool again' },
toolCallEvent,
];
// Turn 3: Model outputs a final answer.
const modelTurn3: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Finished.' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(modelTurn1))
.mockReturnValueOnce(createStreamFromEvents(modelTurn2))
.mockReturnValueOnce(createStreamFromEvents(modelTurn3));
// 4. Run the command.
await runNonInteractive(
mockConfig,
mockSettings,
'Use mock tool multiple times',
'prompt-id-multi',
);
// 5. Verify the output.
// The rendered output should contain the text from each turn, separated by a
// single newline, with a final newline at the end.
expect(getWrittenOutput()).toMatchSnapshot();
// Also verify the tools were called as expected.
expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(2);
});
it('should handle error during tool execution and should send error back to the model', async () => {
@@ -369,7 +439,7 @@ describe('runNonInteractive', () => {
expect.any(AbortSignal),
'prompt-id-3',
);
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
});
it('should exit with error if sendMessageStream throws initially', async () => {
@@ -444,9 +514,7 @@ describe('runNonInteractive', () => {
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(processStdoutSpy).toHaveBeenCalledWith(
"Sorry, I can't find that tool.",
);
expect(getWrittenOutput()).toBe("Sorry, I can't find that tool.\n");
});
it('should exit when max session turns are exceeded', async () => {
@@ -506,7 +574,7 @@ describe('runNonInteractive', () => {
);
// 6. Assert the final output is correct
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
expect(getWrittenOutput()).toBe('Summary complete.\n');
});
it('should process input and write JSON output with stats', async () => {
@@ -850,7 +918,7 @@ describe('runNonInteractive', () => {
'prompt-id-slash',
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
expect(getWrittenOutput()).toBe('Response from command\n');
});
it('should throw FatalInputError if a command requires confirmation', async () => {
@@ -905,7 +973,7 @@ describe('runNonInteractive', () => {
'prompt-id-unknown',
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
expect(getWrittenOutput()).toBe('Response to unknown\n');
});
it('should throw for unhandled command result types', async () => {
@@ -962,7 +1030,7 @@ describe('runNonInteractive', () => {
expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2');
expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged');
expect(getWrittenOutput()).toBe('Acknowledged\n');
});
it('should instantiate CommandService with correct loaders for slash commands', async () => {
@@ -1073,7 +1141,7 @@ describe('runNonInteractive', () => {
expect.objectContaining({ name: 'ShellTool' }),
expect.any(AbortSignal),
);
expect(processStdoutSpy).toHaveBeenCalledWith('file.txt');
expect(getWrittenOutput()).toBe('file.txt\n');
});
describe('CoreEvents Integration', () => {