Add support for output-format stream-jsonflag for headless mode (#10883)

This commit is contained in:
anj-s
2025-10-15 13:55:37 -07:00
committed by GitHub
parent 7bed302f21
commit 47f693173a
12 changed files with 957 additions and 20 deletions
+16
View File
@@ -3432,6 +3432,22 @@ describe('Output format', () => {
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 () => {
process.argv = ['node', 'script.js', '--output-format', 'yaml'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
+1 -1
View File
@@ -286,7 +286,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
alias: 'o',
type: 'string',
description: 'The format of the CLI output.',
choices: ['text', 'json'],
choices: ['text', 'json', 'stream-json'],
})
.deprecateOption(
'show-memory-usage',
+94 -2
View File
@@ -20,6 +20,8 @@ import {
promptIdContext,
OutputFormat,
JsonFormatter,
StreamJsonFormatter,
JsonStreamEventType,
uiTelemetryService,
} from '@google/gemini-cli-core';
@@ -47,6 +49,12 @@ export async function runNonInteractive(
debugMode: config.getDebugMode(),
});
const startTime = Date.now();
const streamFormatter =
config.getOutputFormat() === OutputFormat.STREAM_JSON
? new StreamJsonFormatter()
: null;
try {
consolePatcher.patch();
// 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();
// 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();
let query: Part[] | undefined;
@@ -98,6 +116,16 @@ export async function runNonInteractive(
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 turnCount = 0;
@@ -124,13 +152,48 @@ export async function runNonInteractive(
}
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;
} else {
process.stdout.write(event.value);
}
} 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);
} 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);
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) {
handleToolError(
requestInfo.name,
@@ -180,7 +262,17 @@ export async function runNonInteractive(
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} 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 stats = uiTelemetryService.getMetrics();
process.stdout.write(formatter.format(responseText, stats));
+66 -4
View File
@@ -8,6 +8,9 @@ import type { Config } from '@google/gemini-cli-core';
import {
OutputFormat,
JsonFormatter,
StreamJsonFormatter,
JsonStreamEventType,
uiTelemetryService,
parseAndFormatApiError,
FatalTurnLimitedError,
FatalCancellationError,
@@ -58,6 +61,7 @@ function getNumericExitCode(errorCode: string | number): number {
/**
* Handles errors consistently for both JSON and text output formats.
* 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.
*/
export function handleError(
@@ -70,7 +74,24 @@ export function handleError(
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 errorCode = customErrorCode ?? extractErrorCode(error);
@@ -110,7 +131,20 @@ export function handleToolError(
if (isFatal) {
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 formattedError = formatter.formatError(
toolExecutionError,
@@ -133,7 +167,21 @@ export function handleToolError(
export function handleCancellationError(config: Config): never {
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 formattedError = formatter.formatError(
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.',
);
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 formattedError = formatter.formatError(
maxTurnsError,