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
+49
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;
+10
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,
},
});
}
+24
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,
+24
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',
+1
View File
@@ -235,6 +235,7 @@ describe('gemini.tsx main function kitty protocol', () => {
useSmartEdit: undefined,
sessionSummary: undefined,
promptWords: undefined,
outputFormat: undefined,
});
await main();
+282 -3
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,
),
);
});
});
+33 -18
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()) {
+476
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,
),
);
});
});
});
});
+150
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);
}
}
+16
View File
@@ -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<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir, this.storage);
+2
View File
@@ -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';
@@ -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();
});
});
@@ -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);
}
}
+24
View File
@@ -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;
}
+10
View File
@@ -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 {}