mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
Add support for output-format stream-jsonflag for headless mode (#10883)
This commit is contained in:
@@ -228,6 +228,13 @@ the `--output-format json` flag to get structured output:
|
|||||||
gemini -p "Explain the architecture of this codebase" --output-format json
|
gemini -p "Explain the architecture of this codebase" --output-format json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For real-time event streaming (useful for monitoring long-running operations),
|
||||||
|
use `--output-format stream-json` to get newline-delimited JSON events:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini -p "Run tests and deploy" --output-format stream-json
|
||||||
|
```
|
||||||
|
|
||||||
### Quick Examples
|
### Quick Examples
|
||||||
|
|
||||||
#### Start a new project
|
#### Start a new project
|
||||||
|
|||||||
+73
-10
@@ -15,6 +15,13 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
|||||||
- [JSON Output](#json-output)
|
- [JSON Output](#json-output)
|
||||||
- [Response Schema](#response-schema)
|
- [Response Schema](#response-schema)
|
||||||
- [Example Usage](#example-usage)
|
- [Example Usage](#example-usage)
|
||||||
|
- [Streaming JSON Output](#streaming-json-output)
|
||||||
|
- [When to Use Streaming JSON](#when-to-use-streaming-json)
|
||||||
|
- [Event Types](#event-types)
|
||||||
|
- [Basic Usage](#basic-usage)
|
||||||
|
- [Example Output](#example-output)
|
||||||
|
- [Processing Stream Events](#processing-stream-events)
|
||||||
|
- [Real-World Examples](#real-world-examples)
|
||||||
- [File Redirection](#file-redirection)
|
- [File Redirection](#file-redirection)
|
||||||
- [Configuration Options](#configuration-options)
|
- [Configuration Options](#configuration-options)
|
||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
@@ -211,6 +218,62 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Streaming JSON Output
|
||||||
|
|
||||||
|
Returns real-time events as newline-delimited JSON (JSONL). Each significant
|
||||||
|
action (initialization, messages, tool calls, results) emits immediately as it
|
||||||
|
occurs. This format is ideal for monitoring long-running operations, building
|
||||||
|
UIs with live progress, and creating automation pipelines that react to events.
|
||||||
|
|
||||||
|
#### When to Use Streaming JSON
|
||||||
|
|
||||||
|
Use `--output-format stream-json` when you need:
|
||||||
|
|
||||||
|
- **Real-time progress monitoring** - See tool calls and responses as they
|
||||||
|
happen
|
||||||
|
- **Event-driven automation** - React to specific events (e.g., tool failures)
|
||||||
|
- **Live UI updates** - Build interfaces showing AI agent activity in real-time
|
||||||
|
- **Detailed execution logs** - Capture complete interaction history with
|
||||||
|
timestamps
|
||||||
|
- **Pipeline integration** - Stream events to logging/monitoring systems
|
||||||
|
|
||||||
|
#### Event Types
|
||||||
|
|
||||||
|
The streaming format emits 6 event types:
|
||||||
|
|
||||||
|
1. **`init`** - Session starts (includes session_id, model)
|
||||||
|
2. **`message`** - User prompts and assistant responses
|
||||||
|
3. **`tool_use`** - Tool call requests with parameters
|
||||||
|
4. **`tool_result`** - Tool execution results (success/error)
|
||||||
|
5. **`error`** - Non-fatal errors and warnings
|
||||||
|
6. **`result`** - Final session outcome with aggregated stats
|
||||||
|
|
||||||
|
#### Basic Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stream events to console
|
||||||
|
gemini --output-format stream-json --prompt "What is 2+2?"
|
||||||
|
|
||||||
|
# Save event stream to file
|
||||||
|
gemini --output-format stream-json --prompt "Analyze this code" > events.jsonl
|
||||||
|
|
||||||
|
# Parse with jq
|
||||||
|
gemini --output-format stream-json --prompt "List files" | jq -r '.type'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example Output
|
||||||
|
|
||||||
|
Each line is a complete JSON event:
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{"type":"init","timestamp":"2025-10-10T12:00:00.000Z","session_id":"abc123","model":"gemini-2.0-flash-exp"}
|
||||||
|
{"type":"message","role":"user","content":"List files in current directory","timestamp":"2025-10-10T12:00:01.000Z"}
|
||||||
|
{"type":"tool_use","tool_name":"Bash","tool_id":"bash-123","parameters":{"command":"ls -la"},"timestamp":"2025-10-10T12:00:02.000Z"}
|
||||||
|
{"type":"tool_result","tool_id":"bash-123","status":"success","output":"file1.txt\nfile2.txt","timestamp":"2025-10-10T12:00:03.000Z"}
|
||||||
|
{"type":"message","role":"assistant","content":"Here are the files...","delta":true,"timestamp":"2025-10-10T12:00:04.000Z"}
|
||||||
|
{"type":"result","status":"success","stats":{"total_tokens":250,"input_tokens":50,"output_tokens":200,"duration_ms":3000,"tool_calls":1},"timestamp":"2025-10-10T12:00:05.000Z"}
|
||||||
|
```
|
||||||
|
|
||||||
### File Redirection
|
### File Redirection
|
||||||
|
|
||||||
Save output to files or pipe to other commands:
|
Save output to files or pipe to other commands:
|
||||||
@@ -233,16 +296,16 @@ gemini -p "List programming languages" | grep -i "python"
|
|||||||
|
|
||||||
Key command-line options for headless usage:
|
Key command-line options for headless usage:
|
||||||
|
|
||||||
| Option | Description | Example |
|
| Option | Description | Example |
|
||||||
| ----------------------- | ---------------------------------- | -------------------------------------------------- |
|
| ----------------------- | ----------------------------------------------- | -------------------------------------------------- |
|
||||||
| `--prompt`, `-p` | Run in headless mode | `gemini -p "query"` |
|
| `--prompt`, `-p` | Run in headless mode | `gemini -p "query"` |
|
||||||
| `--output-format` | Specify output format (text, json) | `gemini -p "query" --output-format json` |
|
| `--output-format` | Specify output format (text, json, stream-json) | `gemini -p "query" --output-format stream-json` |
|
||||||
| `--model`, `-m` | Specify the Gemini model | `gemini -p "query" -m gemini-2.5-flash` |
|
| `--model`, `-m` | Specify the Gemini model | `gemini -p "query" -m gemini-2.5-flash` |
|
||||||
| `--debug`, `-d` | Enable debug mode | `gemini -p "query" --debug` |
|
| `--debug`, `-d` | Enable debug mode | `gemini -p "query" --debug` |
|
||||||
| `--all-files`, `-a` | Include all files in context | `gemini -p "query" --all-files` |
|
| `--all-files`, `-a` | Include all files in context | `gemini -p "query" --all-files` |
|
||||||
| `--include-directories` | Include additional directories | `gemini -p "query" --include-directories src,docs` |
|
| `--include-directories` | Include additional directories | `gemini -p "query" --include-directories src,docs` |
|
||||||
| `--yolo`, `-y` | Auto-approve all actions | `gemini -p "query" --yolo` |
|
| `--yolo`, `-y` | Auto-approve all actions | `gemini -p "query" --yolo` |
|
||||||
| `--approval-mode` | Set approval mode | `gemini -p "query" --approval-mode auto_edit` |
|
| `--approval-mode` | Set approval mode | `gemini -p "query" --approval-mode auto_edit` |
|
||||||
|
|
||||||
For complete details on all available configuration options, settings files, and
|
For complete details on all available configuration options, settings files, and
|
||||||
environment variables, see the
|
environment variables, see the
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ for Gemini CLI:
|
|||||||
- `file_filtering_respect_git_ignore` (boolean)
|
- `file_filtering_respect_git_ignore` (boolean)
|
||||||
- `debug_mode` (boolean)
|
- `debug_mode` (boolean)
|
||||||
- `mcp_servers` (string)
|
- `mcp_servers` (string)
|
||||||
- `output_format` (string: "text" or "json")
|
- `output_format` (string: "text", "json", or "stream-json")
|
||||||
|
|
||||||
- `gemini_cli.user_prompt`: This event occurs when a user submits a prompt.
|
- `gemini_cli.user_prompt`: This event occurs when a user submits a prompt.
|
||||||
- **Attributes**:
|
- **Attributes**:
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **`output.format`** (string):
|
- **`output.format`** (string):
|
||||||
- **Description:** The format of the CLI output.
|
- **Description:** The format of the CLI output.
|
||||||
- **Default:** `"text"`
|
- **Default:** `"text"`
|
||||||
- **Values:** `"text"`, `"json"`
|
- **Values:** `"text"`, `"json"`, `"stream-json"`
|
||||||
|
|
||||||
#### `ui`
|
#### `ui`
|
||||||
|
|
||||||
@@ -718,8 +718,9 @@ for that specific session.
|
|||||||
- **Values:**
|
- **Values:**
|
||||||
- `text`: (Default) The standard human-readable output.
|
- `text`: (Default) The standard human-readable output.
|
||||||
- `json`: A machine-readable JSON output.
|
- `json`: A machine-readable JSON output.
|
||||||
|
- `stream-json`: A streaming JSON output that emits real-time events.
|
||||||
- **Note:** For structured output and scripting, use the
|
- **Note:** For structured output and scripting, use the
|
||||||
`--output-format json` flag.
|
`--output-format json` or `--output-format stream-json` flag.
|
||||||
- **`--sandbox`** (**`-s`**):
|
- **`--sandbox`** (**`-s`**):
|
||||||
- Enables sandbox mode for this session.
|
- Enables sandbox mode for this session.
|
||||||
- **`--sandbox-image`**:
|
- **`--sandbox-image`**:
|
||||||
|
|||||||
@@ -3432,6 +3432,22 @@ describe('Output format', () => {
|
|||||||
expect(config.getOutputFormat()).toBe(OutputFormat.JSON);
|
expect(config.getOutputFormat()).toBe(OutputFormat.JSON);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should accept stream-json as a valid output format', async () => {
|
||||||
|
process.argv = ['node', 'script.js', '--output-format', 'stream-json'];
|
||||||
|
const argv = await parseArguments({} as Settings);
|
||||||
|
const config = await loadCliConfig(
|
||||||
|
{},
|
||||||
|
[],
|
||||||
|
new ExtensionEnablementManager(
|
||||||
|
ExtensionStorage.getUserExtensionsDir(),
|
||||||
|
argv.extensions,
|
||||||
|
),
|
||||||
|
'test-session',
|
||||||
|
argv,
|
||||||
|
);
|
||||||
|
expect(config.getOutputFormat()).toBe(OutputFormat.STREAM_JSON);
|
||||||
|
});
|
||||||
|
|
||||||
it('should error on invalid --output-format argument', async () => {
|
it('should error on invalid --output-format argument', async () => {
|
||||||
process.argv = ['node', 'script.js', '--output-format', 'yaml'];
|
process.argv = ['node', 'script.js', '--output-format', 'yaml'];
|
||||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
alias: 'o',
|
alias: 'o',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The format of the CLI output.',
|
description: 'The format of the CLI output.',
|
||||||
choices: ['text', 'json'],
|
choices: ['text', 'json', 'stream-json'],
|
||||||
})
|
})
|
||||||
.deprecateOption(
|
.deprecateOption(
|
||||||
'show-memory-usage',
|
'show-memory-usage',
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
promptIdContext,
|
promptIdContext,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
JsonFormatter,
|
JsonFormatter,
|
||||||
|
StreamJsonFormatter,
|
||||||
|
JsonStreamEventType,
|
||||||
uiTelemetryService,
|
uiTelemetryService,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
@@ -47,6 +49,12 @@ export async function runNonInteractive(
|
|||||||
debugMode: config.getDebugMode(),
|
debugMode: config.getDebugMode(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const streamFormatter =
|
||||||
|
config.getOutputFormat() === OutputFormat.STREAM_JSON
|
||||||
|
? new StreamJsonFormatter()
|
||||||
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
consolePatcher.patch();
|
consolePatcher.patch();
|
||||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
// Handle EPIPE errors when the output is piped to a command that closes early.
|
||||||
@@ -59,6 +67,16 @@ export async function runNonInteractive(
|
|||||||
|
|
||||||
const geminiClient = config.getGeminiClient();
|
const geminiClient = config.getGeminiClient();
|
||||||
|
|
||||||
|
// Emit init event for streaming JSON
|
||||||
|
if (streamFormatter) {
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.INIT,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
session_id: config.getSessionId(),
|
||||||
|
model: config.getModel(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
let query: Part[] | undefined;
|
let query: Part[] | undefined;
|
||||||
@@ -98,6 +116,16 @@ export async function runNonInteractive(
|
|||||||
query = processedQuery as Part[];
|
query = processedQuery as Part[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit user message event for streaming JSON
|
||||||
|
if (streamFormatter) {
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.MESSAGE,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
role: 'user',
|
||||||
|
content: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
||||||
|
|
||||||
let turnCount = 0;
|
let turnCount = 0;
|
||||||
@@ -124,13 +152,48 @@ export async function runNonInteractive(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === GeminiEventType.Content) {
|
if (event.type === GeminiEventType.Content) {
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
if (streamFormatter) {
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.MESSAGE,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: event.value,
|
||||||
|
delta: true,
|
||||||
|
});
|
||||||
|
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||||
responseText += event.value;
|
responseText += event.value;
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(event.value);
|
process.stdout.write(event.value);
|
||||||
}
|
}
|
||||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||||
|
if (streamFormatter) {
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.TOOL_USE,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
tool_name: event.value.name,
|
||||||
|
tool_id: event.value.callId,
|
||||||
|
parameters: event.value.args,
|
||||||
|
});
|
||||||
|
}
|
||||||
toolCallRequests.push(event.value);
|
toolCallRequests.push(event.value);
|
||||||
|
} else if (event.type === GeminiEventType.LoopDetected) {
|
||||||
|
if (streamFormatter) {
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.ERROR,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Loop detected, stopping execution',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (event.type === GeminiEventType.MaxSessionTurns) {
|
||||||
|
if (streamFormatter) {
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.ERROR,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
severity: 'error',
|
||||||
|
message: 'Maximum session turns exceeded',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +211,25 @@ export async function runNonInteractive(
|
|||||||
|
|
||||||
completedToolCalls.push(completedToolCall);
|
completedToolCalls.push(completedToolCall);
|
||||||
|
|
||||||
|
if (streamFormatter) {
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.TOOL_RESULT,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
tool_id: requestInfo.callId,
|
||||||
|
status: toolResponse.error ? 'error' : 'success',
|
||||||
|
output:
|
||||||
|
typeof toolResponse.resultDisplay === 'string'
|
||||||
|
? toolResponse.resultDisplay
|
||||||
|
: undefined,
|
||||||
|
error: toolResponse.error
|
||||||
|
? {
|
||||||
|
type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||||
|
message: toolResponse.error.message,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (toolResponse.error) {
|
if (toolResponse.error) {
|
||||||
handleToolError(
|
handleToolError(
|
||||||
requestInfo.name,
|
requestInfo.name,
|
||||||
@@ -180,7 +262,17 @@ export async function runNonInteractive(
|
|||||||
|
|
||||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||||
} else {
|
} else {
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
// Emit final result event for streaming JSON
|
||||||
|
if (streamFormatter) {
|
||||||
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
|
const durationMs = Date.now() - startTime;
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.RESULT,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'success',
|
||||||
|
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||||
|
});
|
||||||
|
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||||
const formatter = new JsonFormatter();
|
const formatter = new JsonFormatter();
|
||||||
const stats = uiTelemetryService.getMetrics();
|
const stats = uiTelemetryService.getMetrics();
|
||||||
process.stdout.write(formatter.format(responseText, stats));
|
process.stdout.write(formatter.format(responseText, stats));
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import type { Config } from '@google/gemini-cli-core';
|
|||||||
import {
|
import {
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
JsonFormatter,
|
JsonFormatter,
|
||||||
|
StreamJsonFormatter,
|
||||||
|
JsonStreamEventType,
|
||||||
|
uiTelemetryService,
|
||||||
parseAndFormatApiError,
|
parseAndFormatApiError,
|
||||||
FatalTurnLimitedError,
|
FatalTurnLimitedError,
|
||||||
FatalCancellationError,
|
FatalCancellationError,
|
||||||
@@ -58,6 +61,7 @@ function getNumericExitCode(errorCode: string | number): number {
|
|||||||
/**
|
/**
|
||||||
* Handles errors consistently for both JSON and text output formats.
|
* Handles errors consistently for both JSON and text output formats.
|
||||||
* In JSON mode, outputs formatted JSON error and exits.
|
* In JSON mode, outputs formatted JSON error and exits.
|
||||||
|
* In streaming JSON mode, emits a result event with error status.
|
||||||
* In text mode, outputs error message and re-throws.
|
* In text mode, outputs error message and re-throws.
|
||||||
*/
|
*/
|
||||||
export function handleError(
|
export function handleError(
|
||||||
@@ -70,7 +74,24 @@ export function handleError(
|
|||||||
config.getContentGeneratorConfig()?.authType,
|
config.getContentGeneratorConfig()?.authType,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
if (config.getOutputFormat() === OutputFormat.STREAM_JSON) {
|
||||||
|
const streamFormatter = new StreamJsonFormatter();
|
||||||
|
const errorCode = customErrorCode ?? extractErrorCode(error);
|
||||||
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
|
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.RESULT,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
type: error instanceof Error ? error.constructor.name : 'Error',
|
||||||
|
message: errorMessage,
|
||||||
|
},
|
||||||
|
stats: streamFormatter.convertToStreamStats(metrics, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
process.exit(getNumericExitCode(errorCode));
|
||||||
|
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||||
const formatter = new JsonFormatter();
|
const formatter = new JsonFormatter();
|
||||||
const errorCode = customErrorCode ?? extractErrorCode(error);
|
const errorCode = customErrorCode ?? extractErrorCode(error);
|
||||||
|
|
||||||
@@ -110,7 +131,20 @@ export function handleToolError(
|
|||||||
|
|
||||||
if (isFatal) {
|
if (isFatal) {
|
||||||
const toolExecutionError = new FatalToolExecutionError(errorMessage);
|
const toolExecutionError = new FatalToolExecutionError(errorMessage);
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
if (config.getOutputFormat() === OutputFormat.STREAM_JSON) {
|
||||||
|
const streamFormatter = new StreamJsonFormatter();
|
||||||
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.RESULT,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
type: errorType ?? 'FatalToolExecutionError',
|
||||||
|
message: toolExecutionError.message,
|
||||||
|
},
|
||||||
|
stats: streamFormatter.convertToStreamStats(metrics, 0),
|
||||||
|
});
|
||||||
|
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||||
const formatter = new JsonFormatter();
|
const formatter = new JsonFormatter();
|
||||||
const formattedError = formatter.formatError(
|
const formattedError = formatter.formatError(
|
||||||
toolExecutionError,
|
toolExecutionError,
|
||||||
@@ -133,7 +167,21 @@ export function handleToolError(
|
|||||||
export function handleCancellationError(config: Config): never {
|
export function handleCancellationError(config: Config): never {
|
||||||
const cancellationError = new FatalCancellationError('Operation cancelled.');
|
const cancellationError = new FatalCancellationError('Operation cancelled.');
|
||||||
|
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
if (config.getOutputFormat() === OutputFormat.STREAM_JSON) {
|
||||||
|
const streamFormatter = new StreamJsonFormatter();
|
||||||
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.RESULT,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'FatalCancellationError',
|
||||||
|
message: cancellationError.message,
|
||||||
|
},
|
||||||
|
stats: streamFormatter.convertToStreamStats(metrics, 0),
|
||||||
|
});
|
||||||
|
process.exit(cancellationError.exitCode);
|
||||||
|
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||||
const formatter = new JsonFormatter();
|
const formatter = new JsonFormatter();
|
||||||
const formattedError = formatter.formatError(
|
const formattedError = formatter.formatError(
|
||||||
cancellationError,
|
cancellationError,
|
||||||
@@ -156,7 +204,21 @@ export function handleMaxTurnsExceededError(config: Config): never {
|
|||||||
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
if (config.getOutputFormat() === OutputFormat.STREAM_JSON) {
|
||||||
|
const streamFormatter = new StreamJsonFormatter();
|
||||||
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
|
streamFormatter.emitEvent({
|
||||||
|
type: JsonStreamEventType.RESULT,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'FatalTurnLimitedError',
|
||||||
|
message: maxTurnsError.message,
|
||||||
|
},
|
||||||
|
stats: streamFormatter.convertToStreamStats(metrics, 0),
|
||||||
|
});
|
||||||
|
process.exit(maxTurnsError.exitCode);
|
||||||
|
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||||
const formatter = new JsonFormatter();
|
const formatter = new JsonFormatter();
|
||||||
const formattedError = formatter.formatError(
|
const formattedError = formatter.formatError(
|
||||||
maxTurnsError,
|
maxTurnsError,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
export * from './config/config.js';
|
export * from './config/config.js';
|
||||||
export * from './output/types.js';
|
export * from './output/types.js';
|
||||||
export * from './output/json-formatter.js';
|
export * from './output/json-formatter.js';
|
||||||
|
export * from './output/stream-json-formatter.js';
|
||||||
export * from './policy/types.js';
|
export * from './policy/types.js';
|
||||||
export * from './policy/policy-engine.js';
|
export * from './policy/policy-engine.js';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { StreamJsonFormatter } from './stream-json-formatter.js';
|
||||||
|
import { JsonStreamEventType } from './types.js';
|
||||||
|
import type {
|
||||||
|
InitEvent,
|
||||||
|
MessageEvent,
|
||||||
|
ToolUseEvent,
|
||||||
|
ToolResultEvent,
|
||||||
|
ErrorEvent,
|
||||||
|
ResultEvent,
|
||||||
|
} from './types.js';
|
||||||
|
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
|
||||||
|
import { ToolCallDecision } from '../telemetry/tool-call-decision.js';
|
||||||
|
|
||||||
|
describe('StreamJsonFormatter', () => {
|
||||||
|
let formatter: StreamJsonFormatter;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let stdoutWriteSpy: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
formatter = new StreamJsonFormatter();
|
||||||
|
stdoutWriteSpy = vi
|
||||||
|
.spyOn(process.stdout, 'write')
|
||||||
|
.mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stdoutWriteSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatEvent', () => {
|
||||||
|
it('should format init event as JSONL', () => {
|
||||||
|
const event: InitEvent = {
|
||||||
|
type: JsonStreamEventType.INIT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
session_id: 'test-session-123',
|
||||||
|
model: 'gemini-2.0-flash-exp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify(event) + '\n');
|
||||||
|
expect(JSON.parse(result.trim())).toEqual(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format user message event', () => {
|
||||||
|
const event: MessageEvent = {
|
||||||
|
type: JsonStreamEventType.MESSAGE,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
role: 'user',
|
||||||
|
content: 'What is 2+2?',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify(event) + '\n');
|
||||||
|
expect(JSON.parse(result.trim())).toEqual(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format assistant message event with delta', () => {
|
||||||
|
const event: MessageEvent = {
|
||||||
|
type: JsonStreamEventType.MESSAGE,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '4',
|
||||||
|
delta: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify(event) + '\n');
|
||||||
|
const parsed = JSON.parse(result.trim());
|
||||||
|
expect(parsed.delta).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format tool_use event', () => {
|
||||||
|
const event: ToolUseEvent = {
|
||||||
|
type: JsonStreamEventType.TOOL_USE,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
tool_name: 'Read',
|
||||||
|
tool_id: 'read-123',
|
||||||
|
parameters: { file_path: '/path/to/file.txt' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify(event) + '\n');
|
||||||
|
expect(JSON.parse(result.trim())).toEqual(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format tool_result event (success)', () => {
|
||||||
|
const event: ToolResultEvent = {
|
||||||
|
type: JsonStreamEventType.TOOL_RESULT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
tool_id: 'read-123',
|
||||||
|
status: 'success',
|
||||||
|
output: 'File contents here',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify(event) + '\n');
|
||||||
|
expect(JSON.parse(result.trim())).toEqual(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format tool_result event (error)', () => {
|
||||||
|
const event: ToolResultEvent = {
|
||||||
|
type: JsonStreamEventType.TOOL_RESULT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
tool_id: 'read-123',
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'FILE_NOT_FOUND',
|
||||||
|
message: 'File not found',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify(event) + '\n');
|
||||||
|
expect(JSON.parse(result.trim())).toEqual(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format error event', () => {
|
||||||
|
const event: ErrorEvent = {
|
||||||
|
type: JsonStreamEventType.ERROR,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Loop detected, stopping execution',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify(event) + '\n');
|
||||||
|
expect(JSON.parse(result.trim())).toEqual(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format result event with success status', () => {
|
||||||
|
const event: ResultEvent = {
|
||||||
|
type: JsonStreamEventType.RESULT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
status: 'success',
|
||||||
|
stats: {
|
||||||
|
total_tokens: 100,
|
||||||
|
input_tokens: 50,
|
||||||
|
output_tokens: 50,
|
||||||
|
duration_ms: 1200,
|
||||||
|
tool_calls: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify(event) + '\n');
|
||||||
|
expect(JSON.parse(result.trim())).toEqual(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format result event with error status', () => {
|
||||||
|
const event: ResultEvent = {
|
||||||
|
type: JsonStreamEventType.RESULT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'MaxSessionTurnsError',
|
||||||
|
message: 'Maximum session turns exceeded',
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
total_tokens: 100,
|
||||||
|
input_tokens: 50,
|
||||||
|
output_tokens: 50,
|
||||||
|
duration_ms: 1200,
|
||||||
|
tool_calls: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify(event) + '\n');
|
||||||
|
expect(JSON.parse(result.trim())).toEqual(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce minified JSON without pretty-printing', () => {
|
||||||
|
const event: MessageEvent = {
|
||||||
|
type: JsonStreamEventType.MESSAGE,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
role: 'user',
|
||||||
|
content: 'Test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.formatEvent(event);
|
||||||
|
|
||||||
|
// Should not contain multiple spaces or newlines (except trailing)
|
||||||
|
expect(result).not.toContain(' ');
|
||||||
|
expect(result.split('\n').length).toBe(2); // JSON + trailing newline
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emitEvent', () => {
|
||||||
|
it('should write formatted event to stdout', () => {
|
||||||
|
const event: InitEvent = {
|
||||||
|
type: JsonStreamEventType.INIT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
session_id: 'test-session',
|
||||||
|
model: 'gemini-2.0-flash-exp',
|
||||||
|
};
|
||||||
|
|
||||||
|
formatter.emitEvent(event);
|
||||||
|
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenCalledWith(JSON.stringify(event) + '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit multiple events sequentially', () => {
|
||||||
|
const event1: InitEvent = {
|
||||||
|
type: JsonStreamEventType.INIT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
session_id: 'test-session',
|
||||||
|
model: 'gemini-2.0-flash-exp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const event2: MessageEvent = {
|
||||||
|
type: JsonStreamEventType.MESSAGE,
|
||||||
|
timestamp: '2025-10-10T12:00:01.000Z',
|
||||||
|
role: 'user',
|
||||||
|
content: 'Hello',
|
||||||
|
};
|
||||||
|
|
||||||
|
formatter.emitEvent(event1);
|
||||||
|
formatter.emitEvent(event2);
|
||||||
|
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
JSON.stringify(event1) + '\n',
|
||||||
|
);
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
JSON.stringify(event2) + '\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('convertToStreamStats', () => {
|
||||||
|
it('should aggregate token counts from single model', () => {
|
||||||
|
const metrics: SessionMetrics = {
|
||||||
|
models: {
|
||||||
|
'gemini-2.0-flash': {
|
||||||
|
api: {
|
||||||
|
totalRequests: 1,
|
||||||
|
totalErrors: 0,
|
||||||
|
totalLatencyMs: 1000,
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
prompt: 50,
|
||||||
|
candidates: 30,
|
||||||
|
total: 80,
|
||||||
|
cached: 0,
|
||||||
|
thoughts: 0,
|
||||||
|
tool: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
totalCalls: 2,
|
||||||
|
totalSuccess: 2,
|
||||||
|
totalFail: 0,
|
||||||
|
totalDurationMs: 500,
|
||||||
|
totalDecisions: {
|
||||||
|
[ToolCallDecision.ACCEPT]: 0,
|
||||||
|
[ToolCallDecision.REJECT]: 0,
|
||||||
|
[ToolCallDecision.MODIFY]: 0,
|
||||||
|
[ToolCallDecision.AUTO_ACCEPT]: 2,
|
||||||
|
},
|
||||||
|
byName: {},
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.convertToStreamStats(metrics, 1200);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
total_tokens: 80,
|
||||||
|
input_tokens: 50,
|
||||||
|
output_tokens: 30,
|
||||||
|
duration_ms: 1200,
|
||||||
|
tool_calls: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should aggregate token counts from multiple models', () => {
|
||||||
|
const metrics: SessionMetrics = {
|
||||||
|
models: {
|
||||||
|
'gemini-2.0-flash': {
|
||||||
|
api: {
|
||||||
|
totalRequests: 1,
|
||||||
|
totalErrors: 0,
|
||||||
|
totalLatencyMs: 1000,
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
prompt: 50,
|
||||||
|
candidates: 30,
|
||||||
|
total: 80,
|
||||||
|
cached: 0,
|
||||||
|
thoughts: 0,
|
||||||
|
tool: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'gemini-1.5-pro': {
|
||||||
|
api: {
|
||||||
|
totalRequests: 1,
|
||||||
|
totalErrors: 0,
|
||||||
|
totalLatencyMs: 2000,
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
prompt: 100,
|
||||||
|
candidates: 70,
|
||||||
|
total: 170,
|
||||||
|
cached: 0,
|
||||||
|
thoughts: 0,
|
||||||
|
tool: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
totalCalls: 5,
|
||||||
|
totalSuccess: 5,
|
||||||
|
totalFail: 0,
|
||||||
|
totalDurationMs: 1000,
|
||||||
|
totalDecisions: {
|
||||||
|
[ToolCallDecision.ACCEPT]: 0,
|
||||||
|
[ToolCallDecision.REJECT]: 0,
|
||||||
|
[ToolCallDecision.MODIFY]: 0,
|
||||||
|
[ToolCallDecision.AUTO_ACCEPT]: 5,
|
||||||
|
},
|
||||||
|
byName: {},
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.convertToStreamStats(metrics, 3000);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
total_tokens: 250, // 80 + 170
|
||||||
|
input_tokens: 150, // 50 + 100
|
||||||
|
output_tokens: 100, // 30 + 70
|
||||||
|
duration_ms: 3000,
|
||||||
|
tool_calls: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty metrics', () => {
|
||||||
|
const metrics: SessionMetrics = {
|
||||||
|
models: {},
|
||||||
|
tools: {
|
||||||
|
totalCalls: 0,
|
||||||
|
totalSuccess: 0,
|
||||||
|
totalFail: 0,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
totalDecisions: {
|
||||||
|
[ToolCallDecision.ACCEPT]: 0,
|
||||||
|
[ToolCallDecision.REJECT]: 0,
|
||||||
|
[ToolCallDecision.MODIFY]: 0,
|
||||||
|
[ToolCallDecision.AUTO_ACCEPT]: 0,
|
||||||
|
},
|
||||||
|
byName: {},
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.convertToStreamStats(metrics, 100);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
total_tokens: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
duration_ms: 100,
|
||||||
|
tool_calls: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use session-level tool calls count', () => {
|
||||||
|
const metrics: SessionMetrics = {
|
||||||
|
models: {},
|
||||||
|
tools: {
|
||||||
|
totalCalls: 3,
|
||||||
|
totalSuccess: 2,
|
||||||
|
totalFail: 1,
|
||||||
|
totalDurationMs: 500,
|
||||||
|
totalDecisions: {
|
||||||
|
[ToolCallDecision.ACCEPT]: 0,
|
||||||
|
[ToolCallDecision.REJECT]: 0,
|
||||||
|
[ToolCallDecision.MODIFY]: 0,
|
||||||
|
[ToolCallDecision.AUTO_ACCEPT]: 3,
|
||||||
|
},
|
||||||
|
byName: {
|
||||||
|
Read: {
|
||||||
|
count: 2,
|
||||||
|
success: 2,
|
||||||
|
fail: 0,
|
||||||
|
durationMs: 300,
|
||||||
|
decisions: {
|
||||||
|
[ToolCallDecision.ACCEPT]: 0,
|
||||||
|
[ToolCallDecision.REJECT]: 0,
|
||||||
|
[ToolCallDecision.MODIFY]: 0,
|
||||||
|
[ToolCallDecision.AUTO_ACCEPT]: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Glob: {
|
||||||
|
count: 1,
|
||||||
|
success: 0,
|
||||||
|
fail: 1,
|
||||||
|
durationMs: 200,
|
||||||
|
decisions: {
|
||||||
|
[ToolCallDecision.ACCEPT]: 0,
|
||||||
|
[ToolCallDecision.REJECT]: 0,
|
||||||
|
[ToolCallDecision.MODIFY]: 0,
|
||||||
|
[ToolCallDecision.AUTO_ACCEPT]: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.convertToStreamStats(metrics, 1000);
|
||||||
|
|
||||||
|
expect(result.tool_calls).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass through duration unchanged', () => {
|
||||||
|
const metrics: SessionMetrics = {
|
||||||
|
models: {},
|
||||||
|
tools: {
|
||||||
|
totalCalls: 0,
|
||||||
|
totalSuccess: 0,
|
||||||
|
totalFail: 0,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
totalDecisions: {
|
||||||
|
[ToolCallDecision.ACCEPT]: 0,
|
||||||
|
[ToolCallDecision.REJECT]: 0,
|
||||||
|
[ToolCallDecision.MODIFY]: 0,
|
||||||
|
[ToolCallDecision.AUTO_ACCEPT]: 0,
|
||||||
|
},
|
||||||
|
byName: {},
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatter.convertToStreamStats(metrics, 5000);
|
||||||
|
|
||||||
|
expect(result.duration_ms).toBe(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JSON validity', () => {
|
||||||
|
it('should produce valid JSON for all event types', () => {
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
type: JsonStreamEventType.INIT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
session_id: 'test',
|
||||||
|
model: 'gemini-2.0-flash',
|
||||||
|
} as InitEvent,
|
||||||
|
{
|
||||||
|
type: JsonStreamEventType.MESSAGE,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
role: 'user',
|
||||||
|
content: 'Test',
|
||||||
|
} as MessageEvent,
|
||||||
|
{
|
||||||
|
type: JsonStreamEventType.TOOL_USE,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
tool_name: 'Read',
|
||||||
|
tool_id: 'read-1',
|
||||||
|
parameters: {},
|
||||||
|
} as ToolUseEvent,
|
||||||
|
{
|
||||||
|
type: JsonStreamEventType.TOOL_RESULT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
tool_id: 'read-1',
|
||||||
|
status: 'success',
|
||||||
|
} as ToolResultEvent,
|
||||||
|
{
|
||||||
|
type: JsonStreamEventType.ERROR,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
severity: 'error',
|
||||||
|
message: 'Test error',
|
||||||
|
} as ErrorEvent,
|
||||||
|
{
|
||||||
|
type: JsonStreamEventType.RESULT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
status: 'success',
|
||||||
|
stats: {
|
||||||
|
total_tokens: 0,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
duration_ms: 0,
|
||||||
|
tool_calls: 0,
|
||||||
|
},
|
||||||
|
} as ResultEvent,
|
||||||
|
];
|
||||||
|
|
||||||
|
events.forEach((event) => {
|
||||||
|
const formatted = formatter.formatEvent(event);
|
||||||
|
expect(() => JSON.parse(formatted)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve field types', () => {
|
||||||
|
const event: ResultEvent = {
|
||||||
|
type: JsonStreamEventType.RESULT,
|
||||||
|
timestamp: '2025-10-10T12:00:00.000Z',
|
||||||
|
status: 'success',
|
||||||
|
stats: {
|
||||||
|
total_tokens: 100,
|
||||||
|
input_tokens: 50,
|
||||||
|
output_tokens: 50,
|
||||||
|
duration_ms: 1200,
|
||||||
|
tool_calls: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatted = formatter.formatEvent(event);
|
||||||
|
const parsed = JSON.parse(formatted.trim());
|
||||||
|
|
||||||
|
expect(typeof parsed.stats.total_tokens).toBe('number');
|
||||||
|
expect(typeof parsed.stats.input_tokens).toBe('number');
|
||||||
|
expect(typeof parsed.stats.output_tokens).toBe('number');
|
||||||
|
expect(typeof parsed.stats.duration_ms).toBe('number');
|
||||||
|
expect(typeof parsed.stats.tool_calls).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JsonStreamEvent, StreamStats } from './types.js';
|
||||||
|
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatter for streaming JSON output.
|
||||||
|
* Emits newline-delimited JSON (JSONL) events to stdout in real-time.
|
||||||
|
*/
|
||||||
|
export class StreamJsonFormatter {
|
||||||
|
/**
|
||||||
|
* Formats a single event as a JSON string with newline (JSONL format).
|
||||||
|
* @param event - The stream event to format
|
||||||
|
* @returns JSON string with trailing newline
|
||||||
|
*/
|
||||||
|
formatEvent(event: JsonStreamEvent): string {
|
||||||
|
return JSON.stringify(event) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits an event directly to stdout in JSONL format.
|
||||||
|
* @param event - The stream event to emit
|
||||||
|
*/
|
||||||
|
emitEvent(event: JsonStreamEvent): void {
|
||||||
|
process.stdout.write(this.formatEvent(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts SessionMetrics to simplified StreamStats format.
|
||||||
|
* Aggregates token counts across all models.
|
||||||
|
* @param metrics - The session metrics from telemetry
|
||||||
|
* @param durationMs - The session duration in milliseconds
|
||||||
|
* @returns Simplified stats for streaming output
|
||||||
|
*/
|
||||||
|
convertToStreamStats(
|
||||||
|
metrics: SessionMetrics,
|
||||||
|
durationMs: number,
|
||||||
|
): StreamStats {
|
||||||
|
let totalTokens = 0;
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
|
||||||
|
// Aggregate token counts across all models
|
||||||
|
for (const modelMetrics of Object.values(metrics.models)) {
|
||||||
|
totalTokens += modelMetrics.tokens.total;
|
||||||
|
inputTokens += modelMetrics.tokens.prompt;
|
||||||
|
outputTokens += modelMetrics.tokens.candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_tokens: totalTokens,
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
duration_ms: durationMs,
|
||||||
|
tool_calls: metrics.tools.totalCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
|
|||||||
export enum OutputFormat {
|
export enum OutputFormat {
|
||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
JSON = 'json',
|
JSON = 'json',
|
||||||
|
STREAM_JSON = 'stream-json',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JsonError {
|
export interface JsonError {
|
||||||
@@ -22,3 +23,81 @@ export interface JsonOutput {
|
|||||||
stats?: SessionMetrics;
|
stats?: SessionMetrics;
|
||||||
error?: JsonError;
|
error?: JsonError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Streaming JSON event types
|
||||||
|
export enum JsonStreamEventType {
|
||||||
|
INIT = 'init',
|
||||||
|
MESSAGE = 'message',
|
||||||
|
TOOL_USE = 'tool_use',
|
||||||
|
TOOL_RESULT = 'tool_result',
|
||||||
|
ERROR = 'error',
|
||||||
|
RESULT = 'result',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseJsonStreamEvent {
|
||||||
|
type: JsonStreamEventType;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitEvent extends BaseJsonStreamEvent {
|
||||||
|
type: JsonStreamEventType.INIT;
|
||||||
|
session_id: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageEvent extends BaseJsonStreamEvent {
|
||||||
|
type: JsonStreamEventType.MESSAGE;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
delta?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolUseEvent extends BaseJsonStreamEvent {
|
||||||
|
type: JsonStreamEventType.TOOL_USE;
|
||||||
|
tool_name: string;
|
||||||
|
tool_id: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultEvent extends BaseJsonStreamEvent {
|
||||||
|
type: JsonStreamEventType.TOOL_RESULT;
|
||||||
|
tool_id: string;
|
||||||
|
status: 'success' | 'error';
|
||||||
|
output?: string;
|
||||||
|
error?: {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorEvent extends BaseJsonStreamEvent {
|
||||||
|
type: JsonStreamEventType.ERROR;
|
||||||
|
severity: 'warning' | 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamStats {
|
||||||
|
total_tokens: number;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
duration_ms: number;
|
||||||
|
tool_calls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultEvent extends BaseJsonStreamEvent {
|
||||||
|
type: JsonStreamEventType.RESULT;
|
||||||
|
status: 'success' | 'error';
|
||||||
|
error?: {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
stats?: StreamStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonStreamEvent =
|
||||||
|
| InitEvent
|
||||||
|
| MessageEvent
|
||||||
|
| ToolUseEvent
|
||||||
|
| ToolResultEvent
|
||||||
|
| ErrorEvent
|
||||||
|
| ResultEvent;
|
||||||
|
|||||||
Reference in New Issue
Block a user