Structured JSON Output (#8119)

This commit is contained in:
Jerop Kipruto
2025-09-11 05:19:47 +09:00
committed by GitHub
parent db99fc70b6
commit 514767c88b
20 changed files with 1526 additions and 23 deletions

View File

@@ -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;

View File

@@ -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<CliArgs> {
@@ -234,6 +236,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
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,
},
});
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -235,6 +235,7 @@ describe('gemini.tsx main function kitty protocol', () => {
useSmartEdit: undefined,
sessionSummary: undefined,
promptWords: undefined,
outputFormat: undefined,
});
await main();

View File

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

View File

@@ -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()) {

View File

@@ -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<typeof import('@google/gemini-cli-core')>();
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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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,
),
);
});
});
});
});

View File

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