From c7817aee305712c74a139ecb08333fec81a633b9 Mon Sep 17 00:00:00 2001 From: Krishna Bajpai Date: Mon, 27 Oct 2025 07:57:54 -0700 Subject: [PATCH] fix(cli): Add delimiter before printing tool response in non-interactive mode (#11351) --- package-lock.json | 46 ++++++--- .../nonInteractiveCli.test.ts.snap | 8 ++ packages/cli/src/nonInteractiveCli.test.ts | 96 +++++++++++++++--- packages/cli/src/nonInteractiveCli.ts | 11 ++- .../__snapshots__/textOutput.test.ts.snap | 23 +++++ packages/cli/src/ui/utils/textOutput.test.ts | 99 +++++++++++++++++++ packages/cli/src/ui/utils/textOutput.ts | 54 ++++++++++ 7 files changed, 305 insertions(+), 32 deletions(-) create mode 100644 packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap create mode 100644 packages/cli/src/ui/utils/__snapshots__/textOutput.test.ts.snap create mode 100644 packages/cli/src/ui/utils/textOutput.test.ts create mode 100644 packages/cli/src/ui/utils/textOutput.ts diff --git a/package-lock.json b/package-lock.json index a0e554676c..69fb107bc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -598,6 +598,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -621,6 +622,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2426,6 +2428,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2606,6 +2609,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2639,6 +2643,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3007,6 +3012,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3040,6 +3046,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3092,6 +3099,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -3807,6 +3815,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4339,6 +4348,7 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4349,6 +4359,7 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4626,6 +4637,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5393,6 +5405,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5756,8 +5769,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -7003,7 +7015,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8051,6 +8062,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8640,7 +8652,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -8650,7 +8661,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8660,7 +8670,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -8890,7 +8899,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8909,7 +8917,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8918,15 +8925,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -10143,6 +10148,7 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -13279,8 +13285,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -13814,6 +13819,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13824,6 +13830,7 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13857,6 +13864,7 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15920,6 +15928,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16130,7 +16139,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -16138,6 +16148,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16322,6 +16333,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16483,7 +16495,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16539,6 +16550,7 @@ "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16655,6 +16667,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16668,6 +16681,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17419,6 +17433,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17960,6 +17975,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap new file mode 100644 index 0000000000..5d41472b89 --- /dev/null +++ b/packages/cli/src/__snapshots__/nonInteractiveCli.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = ` +"Use mock tool +Use mock tool again +Finished. +" +`; diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index da5d097c64..cff544305d 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -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', () => { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 7b89732b10..efb0e3186d 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -40,6 +40,7 @@ import { handleCancellationError, handleMaxTurnsExceededError, } from './utils/errors.js'; +import { TextOutput } from './ui/utils/textOutput.js'; export async function runNonInteractive( config: Config, @@ -52,6 +53,7 @@ export async function runNonInteractive( stderr: true, debugMode: config.getDebugMode(), }); + const textOutput = new TextOutput(); const handleUserFeedback = (payload: UserFeedbackPayload) => { const prefix = payload.severity.toUpperCase(); @@ -183,7 +185,9 @@ export async function runNonInteractive( } else if (config.getOutputFormat() === OutputFormat.JSON) { responseText += event.value; } else { - process.stdout.write(event.value); + if (event.value) { + textOutput.write(event.value); + } } } else if (event.type === GeminiEventType.ToolCallRequest) { if (streamFormatter) { @@ -220,6 +224,7 @@ export async function runNonInteractive( } if (toolCallRequests.length > 0) { + textOutput.ensureTrailingNewline(); const toolResponseParts: Part[] = []; const completedToolCalls: CompletedToolCall[] = []; @@ -297,9 +302,9 @@ export async function runNonInteractive( } else if (config.getOutputFormat() === OutputFormat.JSON) { const formatter = new JsonFormatter(); const stats = uiTelemetryService.getMetrics(); - process.stdout.write(formatter.format(responseText, stats)); + textOutput.write(formatter.format(responseText, stats)); } else { - process.stdout.write('\n'); // Ensure a final newline + textOutput.ensureTrailingNewline(); // Ensure a final newline } return; } diff --git a/packages/cli/src/ui/utils/__snapshots__/textOutput.test.ts.snap b/packages/cli/src/ui/utils/__snapshots__/textOutput.test.ts.snap new file mode 100644 index 0000000000..4618d553b3 --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/textOutput.test.ts.snap @@ -0,0 +1,23 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TextOutput > should correctly handle ANSI escape codes when determining line breaks 1`] = ` +"hello +world +next" +`; + +exports[`TextOutput > should handle ANSI codes that do not end with a newline 1`] = ` +"hello +world" +`; + +exports[`TextOutput > should handle a sequence of calls correctly 1`] = ` +"first +second part +third" +`; + +exports[`TextOutput > should handle empty strings with ANSI codes 1`] = ` +"hello +world" +`; diff --git a/packages/cli/src/ui/utils/textOutput.test.ts b/packages/cli/src/ui/utils/textOutput.test.ts new file mode 100644 index 0000000000..b8a0882d64 --- /dev/null +++ b/packages/cli/src/ui/utils/textOutput.test.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import type { MockInstance } from 'vitest'; +import { vi } from 'vitest'; +import { TextOutput } from './textOutput.js'; + +describe('TextOutput', () => { + let stdoutSpy: MockInstance; + let textOutput: TextOutput; + + beforeEach(() => { + stdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + textOutput = new TextOutput(); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + }); + + const getWrittenOutput = () => stdoutSpy.mock.calls.map((c) => c[0]).join(''); + + it('write() should call process.stdout.write', () => { + textOutput.write('hello'); + expect(stdoutSpy).toHaveBeenCalledWith('hello'); + }); + + it('write() should not call process.stdout.write for empty strings', () => { + textOutput.write(''); + expect(stdoutSpy).not.toHaveBeenCalled(); + }); + + it('writeOnNewLine() should not add a newline if the last char was a newline', () => { + // Default state starts at the beginning of a line + textOutput.writeOnNewLine('hello'); + expect(getWrittenOutput()).toBe('hello'); + }); + + it('writeOnNewLine() should add a newline if the last char was not a newline', () => { + textOutput.write('previous'); + textOutput.writeOnNewLine('hello'); + expect(getWrittenOutput()).toBe('previous\nhello'); + }); + + it('ensureTrailingNewline() should add a newline if one is missing', () => { + textOutput.write('hello'); + textOutput.ensureTrailingNewline(); + expect(getWrittenOutput()).toBe('hello\n'); + }); + + it('ensureTrailingNewline() should not add a newline if one already exists', () => { + textOutput.write('hello\n'); + textOutput.ensureTrailingNewline(); + expect(getWrittenOutput()).toBe('hello\n'); + }); + + it('should handle a sequence of calls correctly', () => { + textOutput.write('first'); + textOutput.writeOnNewLine('second'); + textOutput.write(' part'); + textOutput.ensureTrailingNewline(); + textOutput.ensureTrailingNewline(); // second call should do nothing + textOutput.write('third'); + + expect(getWrittenOutput()).toMatchSnapshot(); + }); + + it('should correctly handle ANSI escape codes when determining line breaks', () => { + const blue = (s: string) => `\u001b[34m${s}\u001b[39m`; + const bold = (s: string) => `\u001b[1m${s}\u001b[22m`; + + textOutput.write(blue('hello')); + textOutput.writeOnNewLine(bold('world')); + textOutput.write(blue('\n')); + textOutput.writeOnNewLine('next'); + + expect(getWrittenOutput()).toMatchSnapshot(); + }); + + it('should handle empty strings with ANSI codes', () => { + textOutput.write('hello'); + textOutput.write('\u001b[34m\u001b[39m'); // Empty blue string + textOutput.writeOnNewLine('world'); + expect(getWrittenOutput()).toMatchSnapshot(); + }); + + it('should handle ANSI codes that do not end with a newline', () => { + textOutput.write('hello\u001b[34m'); + textOutput.writeOnNewLine('world'); + expect(getWrittenOutput()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/utils/textOutput.ts b/packages/cli/src/ui/utils/textOutput.ts new file mode 100644 index 0000000000..420f774044 --- /dev/null +++ b/packages/cli/src/ui/utils/textOutput.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A utility to manage writing text to stdout, ensuring that newlines + * are handled consistently and robustly across the application. + */ + +import stripAnsi from 'strip-ansi'; + +export class TextOutput { + private atStartOfLine = true; + + /** + * Writes a string to stdout. + * @param str The string to write. + */ + write(str: string): void { + if (str.length === 0) { + return; + } + process.stdout.write(str); + const strippedStr = stripAnsi(str); + if (strippedStr.length > 0) { + this.atStartOfLine = strippedStr.endsWith('\n'); + } + } + + /** + * Writes a string to stdout, ensuring it starts on a new line. + * If the previous output did not end with a newline, one will be added. + * This prevents adding extra blank lines if a newline already exists. + * @param str The string to write. + */ + writeOnNewLine(str: string): void { + if (!this.atStartOfLine) { + this.write('\n'); + } + this.write(str); + } + + /** + * Ensures that the output ends with a newline. If the last character + * written was not a newline, one will be added. + */ + ensureTrailingNewline(): void { + if (!this.atStartOfLine) { + this.write('\n'); + } + } +}