From 514767c88b27f1e2c4e072fd87bd9a4022a8014a Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Thu, 11 Sep 2025 05:19:47 +0900 Subject: [PATCH] Structured JSON Output (#8119) --- README.md | 9 + docs/cli/configuration.md | 14 + docs/cli/index.md | 16 + integration-tests/json-output.test.ts | 37 ++ integration-tests/test-helper.ts | 11 +- packages/cli/src/config/config.test.ts | 49 ++ packages/cli/src/config/config.ts | 10 + packages/cli/src/config/settings.test.ts | 24 + packages/cli/src/config/settingsSchema.ts | 24 + packages/cli/src/gemini.test.tsx | 1 + packages/cli/src/nonInteractiveCli.test.ts | 285 ++++++++++- packages/cli/src/nonInteractiveCli.ts | 51 +- packages/cli/src/utils/errors.test.ts | 476 ++++++++++++++++++ packages/cli/src/utils/errors.ts | 150 ++++++ packages/core/src/config/config.ts | 16 + packages/core/src/index.ts | 2 + .../core/src/output/json-formatter.test.ts | 301 +++++++++++ packages/core/src/output/json-formatter.ts | 39 ++ packages/core/src/output/types.ts | 24 + packages/core/src/utils/errors.ts | 10 + 20 files changed, 1526 insertions(+), 23 deletions(-) create mode 100644 integration-tests/json-output.test.ts create mode 100644 packages/cli/src/utils/errors.test.ts create mode 100644 packages/core/src/output/json-formatter.test.ts create mode 100644 packages/core/src/output/json-formatter.ts create mode 100644 packages/core/src/output/types.ts diff --git a/README.md b/README.md index e12e30be2d..db6cf9bfb6 100644 --- a/README.md +++ b/README.md @@ -191,10 +191,19 @@ gemini -m gemini-2.5-flash #### Non-interactive mode for scripts +Get a simple text response: + ```bash gemini -p "Explain the architecture of this codebase" ``` +For more advanced scripting, including how to parse JSON and handle errors, use +the `--output-format json` flag to get structured output: + +```bash +gemini -p "Explain the architecture of this codebase" --output-format json +``` + ### Quick Examples #### Start a new project diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index f512ec7cc0..039dbdab7f 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -76,6 +76,13 @@ Settings are organized into categories. All settings should be placed within the - **Description:** Enable session checkpointing for recovery. - **Default:** `false` +#### `output` + +- **`output.format`** (string): + - **Description:** The format of the CLI output. + - **Default:** `"text"` + - **Values:** `"text"`, `"json"` + #### `ui` - **`ui.theme`** (string): @@ -442,11 +449,18 @@ Arguments passed directly when running the CLI can override other configurations - Example: `npm start -- --model gemini-1.5-pro-latest` - **`--prompt `** (**`-p `**): - Used to pass a prompt directly to the command. This invokes Gemini CLI in a non-interactive mode. + - For scripting examples, use the `--output-format json` flag to get structured output. - **`--prompt-interactive `** (**`-i `**): - Starts an interactive session with the provided prompt as the initial input. - The prompt is processed within the interactive session, not before it. - Cannot be used when piping input from stdin. - Example: `gemini -i "explain this code"` +- **`--output-format `**: + - **Description:** Specifies the format of the CLI output for non-interactive mode. + - **Values:** + - `text`: (Default) The standard human-readable output. + - `json`: A machine-readable JSON output. + - **Note:** For structured output and scripting, use the `--output-format json` flag. - **`--sandbox`** (**`-s`**): - Enables sandbox mode for this session. - **`--sandbox-image`**: diff --git a/docs/cli/index.md b/docs/cli/index.md index 1b5e1796d6..d9fcd0a6e5 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -27,3 +27,19 @@ Gemini CLI executes the command and prints the output to your terminal. Note tha ```bash gemini -p "What is fine tuning?" ``` + +For non-interactive usage with structured output, use the `--output-format json` flag for scripting and automation. + +Get structured JSON output for scripting: + +```bash +gemini -p "What is fine tuning?" --output-format json +# Output: +# { +# "response": "Fine tuning is...", +# "stats": { +# "models": { "gemini-2.5-flash": { "tokens": {"total": 45} } } +# }, +# "error": null +# } +``` diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts new file mode 100644 index 0000000000..27caee4003 --- /dev/null +++ b/integration-tests/json-output.test.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; + +describe('JSON output', () => { + let rig: TestRig; + + beforeEach(async () => { + rig = new TestRig(); + await rig.setup('json-output-test'); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should return a valid JSON with response and stats', async () => { + const result = await rig.run( + 'What is the capital of France?', + '--output-format', + 'json', + ); + const parsed = JSON.parse(result); + + expect(parsed).toHaveProperty('response'); + expect(typeof parsed.response).toBe('string'); + expect(parsed.response.toLowerCase()).toContain('paris'); + + expect(parsed).toHaveProperty('stats'); + expect(typeof parsed.stats).toBe('object'); + }); +}); diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index a02b7a28c3..f86b72d787 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -284,8 +284,15 @@ export class TestRig { result = filteredLines.join('\n'); } - // If we have stderr output, include that also - if (stderr) { + + // Check if this is a JSON output test - if so, don't include stderr + // as it would corrupt the JSON + const isJsonOutput = + commandArgs.includes('--output-format') && + commandArgs.includes('json'); + + // If we have stderr output and it's not a JSON test, include that also + if (stderr && !isJsonOutput) { result += `\n\nStdErr:\n${stderr}`; } diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 33299375ba..22356b3f9a 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1972,6 +1972,55 @@ describe('loadCliConfig fileFiltering', () => { ); }); +describe('Output Format Configuration', () => { + const originalArgv = process.argv; + + afterEach(() => { + process.argv = originalArgv; + vi.restoreAllMocks(); + }); + + it('should default to text format when no setting or flag is provided', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const config = await loadCliConfig( + {} as Settings, + [], + 'test-session', + argv, + ); + expect(config.getOutputFormat()).toBe(ServerConfig.OutputFormat.TEXT); + }); + + it('should use the format from settings when no flag is provided', async () => { + process.argv = ['node', 'script.js']; + const settings: Settings = { output: { format: 'json' } }; + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getOutputFormat()).toBe(ServerConfig.OutputFormat.JSON); + }); + + it('should use the format from the flag when provided', async () => { + process.argv = ['node', 'script.js', '--output-format', 'json']; + const argv = await parseArguments({} as Settings); + const config = await loadCliConfig( + {} as Settings, + [], + 'test-session', + argv, + ); + expect(config.getOutputFormat()).toBe(ServerConfig.OutputFormat.JSON); + }); + + it('should prioritize the flag over the setting', async () => { + process.argv = ['node', 'script.js', '--output-format', 'text']; + const settings: Settings = { output: { format: 'json' } }; + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getOutputFormat()).toBe(ServerConfig.OutputFormat.TEXT); + }); +}); + describe('parseArguments with positional prompt', () => { const originalArgv = process.argv; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 8a23230add..3d0ba230d7 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -15,6 +15,7 @@ import type { TelemetryTarget, FileFilteringOptions, MCPServerConfig, + OutputFormat, } from '@google/gemini-cli-core'; import { extensionsCommand } from '../commands/extensions.js'; import { @@ -81,6 +82,7 @@ export interface CliArgs { useSmartEdit: boolean | undefined; sessionSummary: string | undefined; promptWords: string[] | undefined; + outputFormat: string | undefined; } export async function parseArguments(settings: Settings): Promise { @@ -234,6 +236,11 @@ export async function parseArguments(settings: Settings): Promise { type: 'string', description: 'File to write session summary to.', }) + .option('output-format', { + type: 'string', + description: 'The format of the CLI output.', + choices: ['text', 'json'], + }) .deprecateOption( 'telemetry', 'Use the "telemetry.enabled" setting in settings.json instead. This flag will be removed in a future version.', @@ -627,6 +634,9 @@ export async function loadCliConfig( enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: appEvents, useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, + output: { + format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, + }, }); } diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index b9f605f263..abce521c2a 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -1078,6 +1078,30 @@ describe('Settings Loading and Merging', () => { }); }); + it('should merge output format settings, with workspace taking precedence', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + output: { format: 'text' }, + }; + const workspaceSettingsContent = { + output: { format: 'json' }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.output?.format).toBe('json'); + }); + it('should handle chatCompression when only in user settings', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index be8996fa61..7641250e8a 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -187,6 +187,30 @@ const SETTINGS_SCHEMA = { }, }, }, + output: { + type: 'object', + label: 'Output', + category: 'General', + requiresRestart: false, + default: {}, + description: 'Settings for the CLI output.', + showInDialog: false, + properties: { + format: { + type: 'enum', + label: 'Output Format', + category: 'General', + requiresRestart: false, + default: 'text', + description: 'The format of the CLI output.', + showInDialog: true, + options: [ + { value: 'text', label: 'Text' }, + { value: 'json', label: 'JSON' }, + ], + }, + }, + }, ui: { type: 'object', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 78c0589f79..3ed8d6ea80 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -235,6 +235,7 @@ describe('gemini.tsx main function kitty protocol', () => { useSmartEdit: undefined, sessionSummary: undefined, promptWords: undefined, + outputFormat: undefined, }); await main(); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index bf90bdc6cc..fcf80979eb 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -8,12 +8,16 @@ import type { Config, ToolRegistry, ServerGeminiStreamEvent, + SessionMetrics, } from '@google/gemini-cli-core'; import { executeToolCall, ToolErrorType, shutdownTelemetry, GeminiEventType, + OutputFormat, + uiTelemetryService, + FatalInputError, } from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; @@ -38,6 +42,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { shutdownTelemetry: vi.fn(), isTelemetrySdkInitialized: vi.fn().mockReturnValue(true), ChatRecordingService: MockChatRecordingService, + uiTelemetryService: { + getMetrics: vi.fn(), + }, }; }); @@ -61,6 +68,9 @@ describe('runNonInteractive', () => { processStdoutSpy = vi .spyOn(process.stdout, 'write') .mockImplementation(() => true); + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code}) called`); + }); mockToolRegistry = { getTool: vi.fn(), @@ -91,6 +101,7 @@ describe('runNonInteractive', () => { getFullContext: vi.fn().mockReturnValue(false), getContentGeneratorConfig: vi.fn().mockReturnValue({}), getDebugMode: vi.fn().mockReturnValue(false), + getOutputFormat: vi.fn().mockReturnValue('text'), } as unknown as Config; const { handleAtCommand } = await import( @@ -312,9 +323,7 @@ describe('runNonInteractive', () => { vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); await expect( runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'), - ).rejects.toThrow( - 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', - ); + ).rejects.toThrow('process.exit(53) called'); }); it('should preprocess @include commands before sending to the model', async () => { @@ -364,4 +373,274 @@ describe('runNonInteractive', () => { // 6. Assert the final output is correct expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.'); }); + + it('should process input and write JSON output with stats', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello World' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + const mockMetrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + + await runNonInteractive(mockConfig, 'Test input', 'prompt-id-1'); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Test input' }], + expect.any(AbortSignal), + 'prompt-id-1', + ); + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify({ response: 'Hello World', stats: mockMetrics }, null, 2), + ); + }); + + it('should write JSON output with stats for tool-only commands (no text response)', async () => { + // Test the scenario where a command completes successfully with only tool calls + // but no text response - this would have caught the original bug + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-tool-only', + }, + }; + const toolResponse: Part[] = [{ text: 'Tool executed successfully' }]; + mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse }); + + // First call returns only tool call, no content + const firstCallEvents: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + + // Second call returns no content (tool-only completion) + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + const mockMetrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 1, + totalSuccess: 1, + totalFail: 0, + totalDurationMs: 100, + totalDecisions: { + accept: 1, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: { + testTool: { + count: 1, + success: 1, + fail: 0, + durationMs: 100, + decisions: { + accept: 1, + reject: 0, + modify: 0, + auto_accept: 0, + }, + }, + }, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + + await runNonInteractive( + mockConfig, + 'Execute tool only', + 'prompt-id-tool-only', + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockCoreExecuteToolCall).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ name: 'testTool' }), + expect.any(AbortSignal), + ); + + // This should output JSON with empty response but include stats + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify({ response: '', stats: mockMetrics }, null, 2), + ); + }); + + it('should write JSON output with stats for empty response commands', async () => { + // Test the scenario where a command completes but produces no content at all + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + const mockMetrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + + await runNonInteractive( + mockConfig, + 'Empty response test', + 'prompt-id-empty', + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Empty response test' }], + expect.any(AbortSignal), + 'prompt-id-empty', + ); + + // This should output JSON with empty response but include stats + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify({ response: '', stats: mockMetrics }, null, 2), + ); + }); + + it('should handle errors in JSON format', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + const testError = new Error('Invalid input provided'); + + mockGeminiClient.sendMessageStream.mockImplementation(() => { + throw testError; + }); + + // Mock console.error to capture JSON error output + const consoleErrorJsonSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + let thrownError: Error | null = null; + try { + await runNonInteractive(mockConfig, 'Test input', 'prompt-id-error'); + // Should not reach here + expect.fail('Expected process.exit to be called'); + } catch (error) { + thrownError = error as Error; + } + + // Should throw because of mocked process.exit + expect(thrownError?.message).toBe('process.exit(1) called'); + + expect(consoleErrorJsonSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'Error', + message: 'Invalid input provided', + code: 1, + }, + }, + null, + 2, + ), + ); + }); + + it('should handle FatalInputError with custom exit code in JSON format', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + const fatalError = new FatalInputError('Invalid command syntax provided'); + + mockGeminiClient.sendMessageStream.mockImplementation(() => { + throw fatalError; + }); + + // Mock console.error to capture JSON error output + const consoleErrorJsonSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + let thrownError: Error | null = null; + try { + await runNonInteractive(mockConfig, 'Invalid syntax', 'prompt-id-fatal'); + // Should not reach here + expect.fail('Expected process.exit to be called'); + } catch (error) { + thrownError = error as Error; + } + + // Should throw because of mocked process.exit with custom exit code + expect(thrownError?.message).toBe('process.exit(42) called'); + + expect(consoleErrorJsonSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'FatalInputError', + message: 'Invalid command syntax provided', + code: 42, + }, + }, + null, + 2, + ), + ); + }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index ff33bd86ec..d98b1c91c9 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -10,15 +10,22 @@ import { shutdownTelemetry, isTelemetrySdkInitialized, GeminiEventType, - parseAndFormatApiError, FatalInputError, - FatalTurnLimitedError, promptIdContext, + OutputFormat, + JsonFormatter, + uiTelemetryService, } from '@google/gemini-cli-core'; import type { Content, Part } from '@google/genai'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; +import { + handleError, + handleToolError, + handleCancellationError, + handleMaxTurnsExceededError, +} from './utils/errors.js'; export async function runNonInteractive( config: Config, @@ -73,9 +80,7 @@ export async function runNonInteractive( config.getMaxSessionTurns() >= 0 && turnCount > config.getMaxSessionTurns() ) { - throw new FatalTurnLimitedError( - 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', - ); + handleMaxTurnsExceededError(config); } const toolCallRequests: ToolCallRequestInfo[] = []; @@ -85,14 +90,18 @@ export async function runNonInteractive( prompt_id, ); + let responseText = ''; for await (const event of responseStream) { if (abortController.signal.aborted) { - console.error('Operation cancelled.'); - return; + handleCancellationError(config); } if (event.type === GeminiEventType.Content) { - process.stdout.write(event.value); + if (config.getOutputFormat() === OutputFormat.JSON) { + responseText += event.value; + } else { + process.stdout.write(event.value); + } } else if (event.type === GeminiEventType.ToolCallRequest) { toolCallRequests.push(event.value); } @@ -108,8 +117,14 @@ export async function runNonInteractive( ); if (toolResponse.error) { - console.error( - `Error executing tool ${requestInfo.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`, + handleToolError( + requestInfo.name, + toolResponse.error, + config, + toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, ); } @@ -119,18 +134,18 @@ export async function runNonInteractive( } currentMessages = [{ role: 'user', parts: toolResponseParts }]; } else { - process.stdout.write('\n'); // Ensure a final newline + if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const stats = uiTelemetryService.getMetrics(); + process.stdout.write(formatter.format(responseText, stats)); + } else { + process.stdout.write('\n'); // Ensure a final newline + } return; } } } catch (error) { - console.error( - parseAndFormatApiError( - error, - config.getContentGeneratorConfig()?.authType, - ), - ); - throw error; + handleError(error, config); } finally { consolePatcher.cleanup(); if (isTelemetrySdkInitialized()) { diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts new file mode 100644 index 0000000000..0df7ddf118 --- /dev/null +++ b/packages/cli/src/utils/errors.test.ts @@ -0,0 +1,476 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, type MockInstance } from 'vitest'; +import type { Config } from '@google/gemini-cli-core'; +import { OutputFormat, FatalInputError } from '@google/gemini-cli-core'; +import { + getErrorMessage, + handleError, + handleToolError, + handleCancellationError, + handleMaxTurnsExceededError, +} from './errors.js'; + +// Mock the core modules +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + return { + ...original, + parseAndFormatApiError: vi.fn((error: unknown) => { + if (error instanceof Error) { + return `API Error: ${error.message}`; + } + return `API Error: ${String(error)}`; + }), + JsonFormatter: vi.fn().mockImplementation(() => ({ + formatError: vi.fn((error: Error, code?: string | number) => + JSON.stringify( + { + error: { + type: error.constructor.name, + message: error.message, + ...(code && { code }), + }, + }, + null, + 2, + ), + ), + })), + FatalToolExecutionError: class extends Error { + constructor(message: string) { + super(message); + this.name = 'FatalToolExecutionError'; + this.exitCode = 54; + } + exitCode: number; + }, + FatalCancellationError: class extends Error { + constructor(message: string) { + super(message); + this.name = 'FatalCancellationError'; + this.exitCode = 130; + } + exitCode: number; + }, + }; +}); + +describe('errors', () => { + let mockConfig: Config; + let processExitSpy: MockInstance; + let consoleErrorSpy: MockInstance; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Mock console.error + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Mock process.exit to throw instead of actually exiting + processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit called with code: ${code}`); + }); + + // Create mock config + mockConfig = { + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), + getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }), + } as unknown as Config; + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + describe('getErrorMessage', () => { + it('should return error message for Error instances', () => { + const error = new Error('Test error message'); + expect(getErrorMessage(error)).toBe('Test error message'); + }); + + it('should convert non-Error values to strings', () => { + expect(getErrorMessage('string error')).toBe('string error'); + expect(getErrorMessage(123)).toBe('123'); + expect(getErrorMessage(null)).toBe('null'); + expect(getErrorMessage(undefined)).toBe('undefined'); + }); + + it('should handle objects', () => { + const obj = { message: 'test' }; + expect(getErrorMessage(obj)).toBe('[object Object]'); + }); + }); + + describe('handleError', () => { + describe('in text mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + }); + + it('should log error message and re-throw', () => { + const testError = new Error('Test error'); + + expect(() => { + handleError(testError, mockConfig); + }).toThrow(testError); + + expect(consoleErrorSpy).toHaveBeenCalledWith('API Error: Test error'); + }); + + it('should handle non-Error objects', () => { + const testError = 'String error'; + + expect(() => { + handleError(testError, mockConfig); + }).toThrow(testError); + + expect(consoleErrorSpy).toHaveBeenCalledWith('API Error: String error'); + }); + }); + + describe('in JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + }); + + it('should format error as JSON and exit with default code', () => { + const testError = new Error('Test error'); + + expect(() => { + handleError(testError, mockConfig); + }).toThrow('process.exit called with code: 1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'Error', + message: 'Test error', + code: 1, + }, + }, + null, + 2, + ), + ); + }); + + it('should use custom error code when provided', () => { + const testError = new Error('Test error'); + + expect(() => { + handleError(testError, mockConfig, 42); + }).toThrow('process.exit called with code: 42'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'Error', + message: 'Test error', + code: 42, + }, + }, + null, + 2, + ), + ); + }); + + it('should extract exitCode from FatalError instances', () => { + const fatalError = new FatalInputError('Fatal error'); + + expect(() => { + handleError(fatalError, mockConfig); + }).toThrow('process.exit called with code: 42'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'FatalInputError', + message: 'Fatal error', + code: 42, + }, + }, + null, + 2, + ), + ); + }); + + it('should handle error with code property', () => { + const errorWithCode = new Error('Error with code') as Error & { + code: number; + }; + errorWithCode.code = 404; + + expect(() => { + handleError(errorWithCode, mockConfig); + }).toThrow('process.exit called with code: 404'); + }); + + it('should handle error with status property', () => { + const errorWithStatus = new Error('Error with status') as Error & { + status: string; + }; + errorWithStatus.status = 'TIMEOUT'; + + expect(() => { + handleError(errorWithStatus, mockConfig); + }).toThrow('process.exit called with code: 1'); // string codes become 1 + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'Error', + message: 'Error with status', + code: 'TIMEOUT', + }, + }, + null, + 2, + ), + ); + }); + }); + }); + + describe('handleToolError', () => { + const toolName = 'test-tool'; + const toolError = new Error('Tool failed'); + + describe('in text mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + }); + + it('should log error message to stderr', () => { + handleToolError(toolName, toolError, mockConfig); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + }); + + it('should use resultDisplay when provided', () => { + handleToolError( + toolName, + toolError, + mockConfig, + 'CUSTOM_ERROR', + 'Custom display message', + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Custom display message', + ); + }); + }); + + describe('in JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + }); + + it('should format error as JSON and exit with default code', () => { + expect(() => { + handleToolError(toolName, toolError, mockConfig); + }).toThrow('process.exit called with code: 54'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'FatalToolExecutionError', + message: 'Error executing tool test-tool: Tool failed', + code: 54, + }, + }, + null, + 2, + ), + ); + }); + + it('should use custom error code', () => { + expect(() => { + handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR'); + }).toThrow('process.exit called with code: 54'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'FatalToolExecutionError', + message: 'Error executing tool test-tool: Tool failed', + code: 'CUSTOM_TOOL_ERROR', + }, + }, + null, + 2, + ), + ); + }); + + it('should use numeric error code and exit with that code', () => { + expect(() => { + handleToolError(toolName, toolError, mockConfig, 500); + }).toThrow('process.exit called with code: 500'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'FatalToolExecutionError', + message: 'Error executing tool test-tool: Tool failed', + code: 500, + }, + }, + null, + 2, + ), + ); + }); + + it('should prefer resultDisplay over error message', () => { + expect(() => { + handleToolError( + toolName, + toolError, + mockConfig, + 'DISPLAY_ERROR', + 'Display message', + ); + }).toThrow('process.exit called with code: 54'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'FatalToolExecutionError', + message: 'Error executing tool test-tool: Display message', + code: 'DISPLAY_ERROR', + }, + }, + null, + 2, + ), + ); + }); + }); + }); + + describe('handleCancellationError', () => { + describe('in text mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + }); + + it('should log cancellation message and exit with 130', () => { + expect(() => { + handleCancellationError(mockConfig); + }).toThrow('process.exit called with code: 130'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Operation cancelled.'); + }); + }); + + describe('in JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + }); + + it('should format cancellation as JSON and exit with 130', () => { + expect(() => { + handleCancellationError(mockConfig); + }).toThrow('process.exit called with code: 130'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'FatalCancellationError', + message: 'Operation cancelled.', + code: 130, + }, + }, + null, + 2, + ), + ); + }); + }); + }); + + describe('handleMaxTurnsExceededError', () => { + describe('in text mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + }); + + it('should log max turns message and exit with 53', () => { + expect(() => { + handleMaxTurnsExceededError(mockConfig); + }).toThrow('process.exit called with code: 53'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + ); + }); + }); + + describe('in JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + }); + + it('should format max turns error as JSON and exit with 53', () => { + expect(() => { + handleMaxTurnsExceededError(mockConfig); + }).toThrow('process.exit called with code: 53'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + JSON.stringify( + { + error: { + type: 'FatalTurnLimitedError', + message: + 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + code: 53, + }, + }, + null, + 2, + ), + ); + }); + }); + }); +}); diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index c1544dd9b4..7ff27fb92a 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -4,9 +4,159 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Config } from '@google/gemini-cli-core'; +import { + OutputFormat, + JsonFormatter, + parseAndFormatApiError, + FatalTurnLimitedError, + FatalToolExecutionError, + FatalCancellationError, +} from '@google/gemini-cli-core'; + export function getErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } + +interface ErrorWithCode extends Error { + exitCode?: number; + code?: string | number; + status?: string | number; +} + +/** + * Extracts the appropriate error code from an error object. + */ +function extractErrorCode(error: unknown): string | number { + const errorWithCode = error as ErrorWithCode; + + // Prioritize exitCode for FatalError types, fall back to other codes + if (typeof errorWithCode.exitCode === 'number') { + return errorWithCode.exitCode; + } + if (errorWithCode.code !== undefined) { + return errorWithCode.code; + } + if (errorWithCode.status !== undefined) { + return errorWithCode.status; + } + + return 1; // Default exit code +} + +/** + * Converts an error code to a numeric exit code. + */ +function getNumericExitCode(errorCode: string | number): number { + return typeof errorCode === 'number' ? errorCode : 1; +} + +/** + * Handles errors consistently for both JSON and text output formats. + * In JSON mode, outputs formatted JSON error and exits. + * In text mode, outputs error message and re-throws. + */ +export function handleError( + error: unknown, + config: Config, + customErrorCode?: string | number, +): never { + const errorMessage = parseAndFormatApiError( + error, + config.getContentGeneratorConfig()?.authType, + ); + + if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const errorCode = customErrorCode ?? extractErrorCode(error); + + const formattedError = formatter.formatError( + error instanceof Error ? error : new Error(getErrorMessage(error)), + errorCode, + ); + + console.error(formattedError); + process.exit(getNumericExitCode(errorCode)); + } else { + console.error(errorMessage); + throw error; + } +} + +/** + * Handles tool execution errors specifically. + * In JSON mode, outputs formatted JSON error and exits. + * In text mode, outputs error message to stderr only. + */ +export function handleToolError( + toolName: string, + toolError: Error, + config: Config, + errorCode?: string | number, + resultDisplay?: string, +): void { + const errorMessage = `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`; + const toolExecutionError = new FatalToolExecutionError(errorMessage); + + if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const formattedError = formatter.formatError( + toolExecutionError, + errorCode ?? toolExecutionError.exitCode, + ); + + console.error(formattedError); + process.exit( + typeof errorCode === 'number' ? errorCode : toolExecutionError.exitCode, + ); + } else { + console.error(errorMessage); + } +} + +/** + * Handles cancellation/abort signals consistently. + */ +export function handleCancellationError(config: Config): never { + const cancellationError = new FatalCancellationError('Operation cancelled.'); + + if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const formattedError = formatter.formatError( + cancellationError, + cancellationError.exitCode, + ); + + console.error(formattedError); + process.exit(cancellationError.exitCode); + } else { + console.error(cancellationError.message); + process.exit(cancellationError.exitCode); + } +} + +/** + * Handles max session turns exceeded consistently. + */ +export function handleMaxTurnsExceededError(config: Config): never { + const maxTurnsError = new FatalTurnLimitedError( + 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + ); + + if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const formattedError = formatter.formatError( + maxTurnsError, + maxTurnsError.exitCode, + ); + + console.error(formattedError); + process.exit(maxTurnsError.exitCode); + } else { + console.error(maxTurnsError.message); + process.exit(maxTurnsError.exitCode); + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index abbd6d3754..84c2fdb434 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -62,6 +62,7 @@ import { RipgrepFallbackEvent, } from '../telemetry/types.js'; import type { FallbackModelHandler } from '../fallback/types.js'; +import { OutputFormat } from '../output/types.js'; // Re-export OAuth config type export type { MCPOAuthConfig, AnyToolInvocation }; @@ -105,6 +106,10 @@ export interface TelemetrySettings { outfile?: string; } +export interface OutputSettings { + format?: OutputFormat; +} + export interface GeminiCLIExtension { name: string; version: string; @@ -228,6 +233,7 @@ export interface ConfigParameters { enableToolOutputTruncation?: boolean; eventEmitter?: EventEmitter; useSmartEdit?: boolean; + output?: OutputSettings; } export class Config { @@ -310,6 +316,7 @@ export class Config { private readonly fileExclusions: FileExclusions; private readonly eventEmitter?: EventEmitter; private readonly useSmartEdit: boolean; + private readonly outputSettings: OutputSettings; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -393,6 +400,9 @@ export class Config { this.enablePromptCompletion = params.enablePromptCompletion ?? false; this.fileExclusions = new FileExclusions(this); this.eventEmitter = params.eventEmitter; + this.outputSettings = { + format: params.output?.format ?? OutputFormat.TEXT, + }; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -880,6 +890,12 @@ export class Config { return this.useSmartEdit; } + getOutputFormat(): OutputFormat { + return this.outputSettings?.format + ? this.outputSettings.format + : OutputFormat.TEXT; + } + async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir, this.storage); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 417fc4b64a..d8eaeb070b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,8 @@ // Export config export * from './config/config.js'; +export * from './output/types.js'; +export * from './output/json-formatter.js'; // Export Core Logic export * from './core/client.js'; diff --git a/packages/core/src/output/json-formatter.test.ts b/packages/core/src/output/json-formatter.test.ts new file mode 100644 index 0000000000..587030a980 --- /dev/null +++ b/packages/core/src/output/json-formatter.test.ts @@ -0,0 +1,301 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it } from 'vitest'; +import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; +import { JsonFormatter } from './json-formatter.js'; +import type { JsonError } from './types.js'; + +describe('JsonFormatter', () => { + it('should format the response as JSON', () => { + const formatter = new JsonFormatter(); + const response = 'This is a test response.'; + const formatted = formatter.format(response); + const expected = { + response, + }; + expect(JSON.parse(formatted)).toEqual(expected); + }); + + it('should strip ANSI escape sequences from response text', () => { + const formatter = new JsonFormatter(); + const responseWithAnsi = + '\x1B[31mRed text\x1B[0m and \x1B[32mGreen text\x1B[0m'; + const formatted = formatter.format(responseWithAnsi); + const parsed = JSON.parse(formatted); + expect(parsed.response).toBe('Red text and Green text'); + }); + + it('should strip control characters from response text', () => { + const formatter = new JsonFormatter(); + const responseWithControlChars = + 'Text with\x07 bell\x08 and\x0B vertical tab'; + const formatted = formatter.format(responseWithControlChars); + const parsed = JSON.parse(formatted); + // Only ANSI codes are stripped, other control chars are preserved + expect(parsed.response).toBe('Text with\x07 bell\x08 and\x0B vertical tab'); + }); + + it('should preserve newlines and tabs in response text', () => { + const formatter = new JsonFormatter(); + const responseWithWhitespace = 'Line 1\nLine 2\r\nLine 3\twith tab'; + const formatted = formatter.format(responseWithWhitespace); + const parsed = JSON.parse(formatted); + expect(parsed.response).toBe('Line 1\nLine 2\r\nLine 3\twith tab'); + }); + + it('should format the response as JSON with stats', () => { + const formatter = new JsonFormatter(); + const response = 'This is a test response.'; + const stats: SessionMetrics = { + models: { + 'gemini-2.5-pro': { + api: { + totalRequests: 2, + totalErrors: 0, + totalLatencyMs: 5672, + }, + tokens: { + prompt: 24401, + candidates: 215, + total: 24719, + cached: 10656, + thoughts: 103, + tool: 0, + }, + }, + 'gemini-2.5-flash': { + api: { + totalRequests: 2, + totalErrors: 0, + totalLatencyMs: 5914, + }, + tokens: { + prompt: 20803, + candidates: 716, + total: 21657, + cached: 0, + thoughts: 138, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 1, + totalSuccess: 1, + totalFail: 0, + totalDurationMs: 4582, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 1, + }, + byName: { + google_web_search: { + count: 1, + success: 1, + fail: 0, + durationMs: 4582, + decisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 1, + }, + }, + }, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const formatted = formatter.format(response, stats); + const expected = { + response, + stats, + }; + expect(JSON.parse(formatted)).toEqual(expected); + }); + + it('should format error as JSON', () => { + const formatter = new JsonFormatter(); + const error: JsonError = { + type: 'ValidationError', + message: 'Invalid input provided', + code: 400, + }; + const formatted = formatter.format(undefined, undefined, error); + const expected = { + error, + }; + expect(JSON.parse(formatted)).toEqual(expected); + }); + + it('should format response with error as JSON', () => { + const formatter = new JsonFormatter(); + const response = 'Partial response'; + const error: JsonError = { + type: 'TimeoutError', + message: 'Request timed out', + code: 'TIMEOUT', + }; + const formatted = formatter.format(response, undefined, error); + const expected = { + response, + error, + }; + expect(JSON.parse(formatted)).toEqual(expected); + }); + + it('should format error using formatError method', () => { + const formatter = new JsonFormatter(); + const error = new Error('Something went wrong'); + const formatted = formatter.formatError(error, 500); + const parsed = JSON.parse(formatted); + + expect(parsed).toEqual({ + error: { + type: 'Error', + message: 'Something went wrong', + code: 500, + }, + }); + }); + + it('should format custom error using formatError method', () => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + + const formatter = new JsonFormatter(); + const error = new CustomError('Custom error occurred'); + const formatted = formatter.formatError(error); + const parsed = JSON.parse(formatted); + + expect(parsed).toEqual({ + error: { + type: 'CustomError', + message: 'Custom error occurred', + }, + }); + }); + + it('should format complete JSON output with response, stats, and error', () => { + const formatter = new JsonFormatter(); + const response = 'Partial response before error'; + const stats: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 1, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const error: JsonError = { + type: 'ApiError', + message: 'Rate limit exceeded', + code: 429, + }; + + const formatted = formatter.format(response, stats, error); + const expected = { + response, + stats, + error, + }; + expect(JSON.parse(formatted)).toEqual(expected); + }); + + it('should handle error messages containing JSON content', () => { + const formatter = new JsonFormatter(); + const errorWithJson = new Error( + 'API returned: {"error": "Invalid request", "code": 400}', + ); + const formatted = formatter.formatError(errorWithJson, 'API_ERROR'); + const parsed = JSON.parse(formatted); + + expect(parsed).toEqual({ + error: { + type: 'Error', + message: 'API returned: {"error": "Invalid request", "code": 400}', + code: 'API_ERROR', + }, + }); + + // Verify the entire output is valid JSON + expect(() => JSON.parse(formatted)).not.toThrow(); + }); + + it('should handle error messages with quotes and special characters', () => { + const formatter = new JsonFormatter(); + const errorWithQuotes = new Error('Error: "quoted text" and \\backslash'); + const formatted = formatter.formatError(errorWithQuotes); + const parsed = JSON.parse(formatted); + + expect(parsed).toEqual({ + error: { + type: 'Error', + message: 'Error: "quoted text" and \\backslash', + }, + }); + + // Verify the entire output is valid JSON + expect(() => JSON.parse(formatted)).not.toThrow(); + }); + + it('should handle error messages with control characters', () => { + const formatter = new JsonFormatter(); + const errorWithControlChars = new Error('Error with\n newline and\t tab'); + const formatted = formatter.formatError(errorWithControlChars); + const parsed = JSON.parse(formatted); + + // Should preserve newlines and tabs as they are common whitespace characters + expect(parsed.error.message).toBe('Error with\n newline and\t tab'); + + // Verify the entire output is valid JSON + expect(() => JSON.parse(formatted)).not.toThrow(); + }); + + it('should strip ANSI escape sequences from error messages', () => { + const formatter = new JsonFormatter(); + const errorWithAnsi = new Error('\x1B[31mRed error\x1B[0m message'); + const formatted = formatter.formatError(errorWithAnsi); + const parsed = JSON.parse(formatted); + + expect(parsed.error.message).toBe('Red error message'); + expect(() => JSON.parse(formatted)).not.toThrow(); + }); + + it('should strip unsafe control characters from error messages', () => { + const formatter = new JsonFormatter(); + const errorWithControlChars = new Error( + 'Error\x07 with\x08 control\x0B chars', + ); + const formatted = formatter.formatError(errorWithControlChars); + const parsed = JSON.parse(formatted); + + // Only ANSI codes are stripped, other control chars are preserved + expect(parsed.error.message).toBe('Error\x07 with\x08 control\x0B chars'); + expect(() => JSON.parse(formatted)).not.toThrow(); + }); +}); diff --git a/packages/core/src/output/json-formatter.ts b/packages/core/src/output/json-formatter.ts new file mode 100644 index 0000000000..83ea3e3862 --- /dev/null +++ b/packages/core/src/output/json-formatter.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import stripAnsi from 'strip-ansi'; +import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; +import type { JsonError, JsonOutput } from './types.js'; + +export class JsonFormatter { + format(response?: string, stats?: SessionMetrics, error?: JsonError): string { + const output: JsonOutput = {}; + + if (response !== undefined) { + output.response = stripAnsi(response); + } + + if (stats) { + output.stats = stats; + } + + if (error) { + output.error = error; + } + + return JSON.stringify(output, null, 2); + } + + formatError(error: Error, code?: string | number): string { + const jsonError: JsonError = { + type: error.constructor.name, + message: stripAnsi(error.message), + ...(code && { code }), + }; + + return this.format(undefined, undefined, jsonError); + } +} diff --git a/packages/core/src/output/types.ts b/packages/core/src/output/types.ts new file mode 100644 index 0000000000..08477d21ed --- /dev/null +++ b/packages/core/src/output/types.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; + +export enum OutputFormat { + TEXT = 'text', + JSON = 'json', +} + +export interface JsonError { + type: string; + message: string; + code?: string | number; +} + +export interface JsonOutput { + response?: string; + stats?: SessionMetrics; + error?: JsonError; +} diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index a02399ea9e..030910ce88 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -59,6 +59,16 @@ export class FatalTurnLimitedError extends FatalError { super(message, 53); } } +export class FatalToolExecutionError extends FatalError { + constructor(message: string) { + super(message, 54); + } +} +export class FatalCancellationError extends FatalError { + constructor(message: string) { + super(message, 130); // Standard exit code for SIGINT + } +} export class ForbiddenError extends Error {} export class UnauthorizedError extends Error {}