mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 22:02:59 -07:00
Structured JSON Output (#8119)
This commit is contained in:
@@ -191,10 +191,19 @@ gemini -m gemini-2.5-flash
|
|||||||
|
|
||||||
#### Non-interactive mode for scripts
|
#### Non-interactive mode for scripts
|
||||||
|
|
||||||
|
Get a simple text response:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gemini -p "Explain the architecture of this codebase"
|
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
|
### Quick Examples
|
||||||
|
|
||||||
#### Start a new project
|
#### Start a new project
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ Settings are organized into categories. All settings should be placed within the
|
|||||||
- **Description:** Enable session checkpointing for recovery.
|
- **Description:** Enable session checkpointing for recovery.
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
|
||||||
|
#### `output`
|
||||||
|
|
||||||
|
- **`output.format`** (string):
|
||||||
|
- **Description:** The format of the CLI output.
|
||||||
|
- **Default:** `"text"`
|
||||||
|
- **Values:** `"text"`, `"json"`
|
||||||
|
|
||||||
#### `ui`
|
#### `ui`
|
||||||
|
|
||||||
- **`ui.theme`** (string):
|
- **`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`
|
- Example: `npm start -- --model gemini-1.5-pro-latest`
|
||||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a non-interactive mode.
|
- 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 <your_prompt>`** (**`-i <your_prompt>`**):
|
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||||
- Starts an interactive session with the provided prompt as the initial input.
|
- Starts an interactive session with the provided prompt as the initial input.
|
||||||
- The prompt is processed within the interactive session, not before it.
|
- The prompt is processed within the interactive session, not before it.
|
||||||
- Cannot be used when piping input from stdin.
|
- Cannot be used when piping input from stdin.
|
||||||
- Example: `gemini -i "explain this code"`
|
- Example: `gemini -i "explain this code"`
|
||||||
|
- **`--output-format <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`**):
|
- **`--sandbox`** (**`-s`**):
|
||||||
- Enables sandbox mode for this session.
|
- Enables sandbox mode for this session.
|
||||||
- **`--sandbox-image`**:
|
- **`--sandbox-image`**:
|
||||||
|
|||||||
@@ -27,3 +27,19 @@ Gemini CLI executes the command and prints the output to your terminal. Note tha
|
|||||||
```bash
|
```bash
|
||||||
gemini -p "What is fine tuning?"
|
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
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -284,8 +284,15 @@ export class TestRig {
|
|||||||
|
|
||||||
result = filteredLines.join('\n');
|
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}`;
|
result += `\n\nStdErr:\n${stderr}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
describe('parseArguments with positional prompt', () => {
|
||||||
const originalArgv = process.argv;
|
const originalArgv = process.argv;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
TelemetryTarget,
|
TelemetryTarget,
|
||||||
FileFilteringOptions,
|
FileFilteringOptions,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
|
OutputFormat,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { extensionsCommand } from '../commands/extensions.js';
|
import { extensionsCommand } from '../commands/extensions.js';
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +82,7 @@ export interface CliArgs {
|
|||||||
useSmartEdit: boolean | undefined;
|
useSmartEdit: boolean | undefined;
|
||||||
sessionSummary: string | undefined;
|
sessionSummary: string | undefined;
|
||||||
promptWords: string[] | undefined;
|
promptWords: string[] | undefined;
|
||||||
|
outputFormat: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||||
@@ -234,6 +236,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'File to write session summary to.',
|
description: 'File to write session summary to.',
|
||||||
})
|
})
|
||||||
|
.option('output-format', {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The format of the CLI output.',
|
||||||
|
choices: ['text', 'json'],
|
||||||
|
})
|
||||||
.deprecateOption(
|
.deprecateOption(
|
||||||
'telemetry',
|
'telemetry',
|
||||||
'Use the "telemetry.enabled" setting in settings.json instead. This flag will be removed in a future version.',
|
'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,
|
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||||
eventEmitter: appEvents,
|
eventEmitter: appEvents,
|
||||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||||
|
output: {
|
||||||
|
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
it('should handle chatCompression when only in user settings', () => {
|
||||||
(mockFsExistsSync as Mock).mockImplementation(
|
(mockFsExistsSync as Mock).mockImplementation(
|
||||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||||
|
|||||||
@@ -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: {
|
ui: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
useSmartEdit: undefined,
|
useSmartEdit: undefined,
|
||||||
sessionSummary: undefined,
|
sessionSummary: undefined,
|
||||||
promptWords: undefined,
|
promptWords: undefined,
|
||||||
|
outputFormat: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await main();
|
await main();
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import type {
|
|||||||
Config,
|
Config,
|
||||||
ToolRegistry,
|
ToolRegistry,
|
||||||
ServerGeminiStreamEvent,
|
ServerGeminiStreamEvent,
|
||||||
|
SessionMetrics,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
executeToolCall,
|
executeToolCall,
|
||||||
ToolErrorType,
|
ToolErrorType,
|
||||||
shutdownTelemetry,
|
shutdownTelemetry,
|
||||||
GeminiEventType,
|
GeminiEventType,
|
||||||
|
OutputFormat,
|
||||||
|
uiTelemetryService,
|
||||||
|
FatalInputError,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { Part } from '@google/genai';
|
import type { Part } from '@google/genai';
|
||||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||||
@@ -38,6 +42,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
shutdownTelemetry: vi.fn(),
|
shutdownTelemetry: vi.fn(),
|
||||||
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
|
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
|
||||||
ChatRecordingService: MockChatRecordingService,
|
ChatRecordingService: MockChatRecordingService,
|
||||||
|
uiTelemetryService: {
|
||||||
|
getMetrics: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +68,9 @@ describe('runNonInteractive', () => {
|
|||||||
processStdoutSpy = vi
|
processStdoutSpy = vi
|
||||||
.spyOn(process.stdout, 'write')
|
.spyOn(process.stdout, 'write')
|
||||||
.mockImplementation(() => true);
|
.mockImplementation(() => true);
|
||||||
|
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||||
|
throw new Error(`process.exit(${code}) called`);
|
||||||
|
});
|
||||||
|
|
||||||
mockToolRegistry = {
|
mockToolRegistry = {
|
||||||
getTool: vi.fn(),
|
getTool: vi.fn(),
|
||||||
@@ -91,6 +101,7 @@ describe('runNonInteractive', () => {
|
|||||||
getFullContext: vi.fn().mockReturnValue(false),
|
getFullContext: vi.fn().mockReturnValue(false),
|
||||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||||
getDebugMode: vi.fn().mockReturnValue(false),
|
getDebugMode: vi.fn().mockReturnValue(false),
|
||||||
|
getOutputFormat: vi.fn().mockReturnValue('text'),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleAtCommand } = await import(
|
const { handleAtCommand } = await import(
|
||||||
@@ -312,9 +323,7 @@ describe('runNonInteractive', () => {
|
|||||||
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
|
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
|
||||||
await expect(
|
await expect(
|
||||||
runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'),
|
runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6'),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow('process.exit(53) called');
|
||||||
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should preprocess @include commands before sending to the model', async () => {
|
it('should preprocess @include commands before sending to the model', async () => {
|
||||||
@@ -364,4 +373,274 @@ describe('runNonInteractive', () => {
|
|||||||
// 6. Assert the final output is correct
|
// 6. Assert the final output is correct
|
||||||
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,15 +10,22 @@ import {
|
|||||||
shutdownTelemetry,
|
shutdownTelemetry,
|
||||||
isTelemetrySdkInitialized,
|
isTelemetrySdkInitialized,
|
||||||
GeminiEventType,
|
GeminiEventType,
|
||||||
parseAndFormatApiError,
|
|
||||||
FatalInputError,
|
FatalInputError,
|
||||||
FatalTurnLimitedError,
|
|
||||||
promptIdContext,
|
promptIdContext,
|
||||||
|
OutputFormat,
|
||||||
|
JsonFormatter,
|
||||||
|
uiTelemetryService,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { Content, Part } from '@google/genai';
|
import type { Content, Part } from '@google/genai';
|
||||||
|
|
||||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||||
|
import {
|
||||||
|
handleError,
|
||||||
|
handleToolError,
|
||||||
|
handleCancellationError,
|
||||||
|
handleMaxTurnsExceededError,
|
||||||
|
} from './utils/errors.js';
|
||||||
|
|
||||||
export async function runNonInteractive(
|
export async function runNonInteractive(
|
||||||
config: Config,
|
config: Config,
|
||||||
@@ -73,9 +80,7 @@ export async function runNonInteractive(
|
|||||||
config.getMaxSessionTurns() >= 0 &&
|
config.getMaxSessionTurns() >= 0 &&
|
||||||
turnCount > config.getMaxSessionTurns()
|
turnCount > config.getMaxSessionTurns()
|
||||||
) {
|
) {
|
||||||
throw new FatalTurnLimitedError(
|
handleMaxTurnsExceededError(config);
|
||||||
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||||
|
|
||||||
@@ -85,14 +90,18 @@ export async function runNonInteractive(
|
|||||||
prompt_id,
|
prompt_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let responseText = '';
|
||||||
for await (const event of responseStream) {
|
for await (const event of responseStream) {
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
console.error('Operation cancelled.');
|
handleCancellationError(config);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === GeminiEventType.Content) {
|
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) {
|
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||||
toolCallRequests.push(event.value);
|
toolCallRequests.push(event.value);
|
||||||
}
|
}
|
||||||
@@ -108,8 +117,14 @@ export async function runNonInteractive(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (toolResponse.error) {
|
if (toolResponse.error) {
|
||||||
console.error(
|
handleToolError(
|
||||||
`Error executing tool ${requestInfo.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
|
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 }];
|
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||||
} else {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
handleError(error, config);
|
||||||
parseAndFormatApiError(
|
|
||||||
error,
|
|
||||||
config.getContentGeneratorConfig()?.authType,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
consolePatcher.cleanup();
|
consolePatcher.cleanup();
|
||||||
if (isTelemetrySdkInitialized()) {
|
if (isTelemetrySdkInitialized()) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,9 +4,159 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 {
|
export function getErrorMessage(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
return String(error);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
RipgrepFallbackEvent,
|
RipgrepFallbackEvent,
|
||||||
} from '../telemetry/types.js';
|
} from '../telemetry/types.js';
|
||||||
import type { FallbackModelHandler } from '../fallback/types.js';
|
import type { FallbackModelHandler } from '../fallback/types.js';
|
||||||
|
import { OutputFormat } from '../output/types.js';
|
||||||
|
|
||||||
// Re-export OAuth config type
|
// Re-export OAuth config type
|
||||||
export type { MCPOAuthConfig, AnyToolInvocation };
|
export type { MCPOAuthConfig, AnyToolInvocation };
|
||||||
@@ -105,6 +106,10 @@ export interface TelemetrySettings {
|
|||||||
outfile?: string;
|
outfile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OutputSettings {
|
||||||
|
format?: OutputFormat;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GeminiCLIExtension {
|
export interface GeminiCLIExtension {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -228,6 +233,7 @@ export interface ConfigParameters {
|
|||||||
enableToolOutputTruncation?: boolean;
|
enableToolOutputTruncation?: boolean;
|
||||||
eventEmitter?: EventEmitter;
|
eventEmitter?: EventEmitter;
|
||||||
useSmartEdit?: boolean;
|
useSmartEdit?: boolean;
|
||||||
|
output?: OutputSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
@@ -310,6 +316,7 @@ export class Config {
|
|||||||
private readonly fileExclusions: FileExclusions;
|
private readonly fileExclusions: FileExclusions;
|
||||||
private readonly eventEmitter?: EventEmitter;
|
private readonly eventEmitter?: EventEmitter;
|
||||||
private readonly useSmartEdit: boolean;
|
private readonly useSmartEdit: boolean;
|
||||||
|
private readonly outputSettings: OutputSettings;
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
this.sessionId = params.sessionId;
|
this.sessionId = params.sessionId;
|
||||||
@@ -393,6 +400,9 @@ export class Config {
|
|||||||
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
|
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
|
||||||
this.fileExclusions = new FileExclusions(this);
|
this.fileExclusions = new FileExclusions(this);
|
||||||
this.eventEmitter = params.eventEmitter;
|
this.eventEmitter = params.eventEmitter;
|
||||||
|
this.outputSettings = {
|
||||||
|
format: params.output?.format ?? OutputFormat.TEXT,
|
||||||
|
};
|
||||||
|
|
||||||
if (params.contextFileName) {
|
if (params.contextFileName) {
|
||||||
setGeminiMdFilename(params.contextFileName);
|
setGeminiMdFilename(params.contextFileName);
|
||||||
@@ -880,6 +890,12 @@ export class Config {
|
|||||||
return this.useSmartEdit;
|
return this.useSmartEdit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOutputFormat(): OutputFormat {
|
||||||
|
return this.outputSettings?.format
|
||||||
|
? this.outputSettings.format
|
||||||
|
: OutputFormat.TEXT;
|
||||||
|
}
|
||||||
|
|
||||||
async getGitService(): Promise<GitService> {
|
async getGitService(): Promise<GitService> {
|
||||||
if (!this.gitService) {
|
if (!this.gitService) {
|
||||||
this.gitService = new GitService(this.targetDir, this.storage);
|
this.gitService = new GitService(this.targetDir, this.storage);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
// Export config
|
// Export config
|
||||||
export * from './config/config.js';
|
export * from './config/config.js';
|
||||||
|
export * from './output/types.js';
|
||||||
|
export * from './output/json-formatter.js';
|
||||||
|
|
||||||
// Export Core Logic
|
// Export Core Logic
|
||||||
export * from './core/client.js';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -59,6 +59,16 @@ export class FatalTurnLimitedError extends FatalError {
|
|||||||
super(message, 53);
|
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 ForbiddenError extends Error {}
|
||||||
export class UnauthorizedError extends Error {}
|
export class UnauthorizedError extends Error {}
|
||||||
|
|||||||
Reference in New Issue
Block a user