diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index 3b77b2d935..1892cf67a3 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -37,6 +37,15 @@ describe('JSON output', () => { expect(typeof parsed.stats).toBe('object'); }); + it('should return a valid JSON with a session ID', async () => { + const result = await rig.run('Hello', '--output-format', 'json'); + const parsed = JSON.parse(result); + + expect(parsed).toHaveProperty('session_id'); + expect(typeof parsed.session_id).toBe('string'); + expect(parsed.session_id).not.toBe(''); + }); + it('should return a JSON error for sd auth mismatch before running', async () => { process.env['GOOGLE_GENAI_USE_GCA'] = 'true'; await rig.setup('json-output-auth-mismatch', { @@ -87,6 +96,9 @@ describe('JSON output', () => { "enforced authentication type is 'gemini-api-key'", ); expect(payload.error.message).toContain("current type is 'oauth-personal'"); + expect(payload).toHaveProperty('session_id'); + expect(typeof payload.session_id).toBe('string'); + expect(payload.session_id).not.toBe(''); }); it('should not exit on tool errors and allow model to self-correct in JSON mode', async () => { @@ -129,5 +141,9 @@ describe('JSON output', () => { // Should NOT have an error field at the top level expect(parsed.error).toBeUndefined(); + + expect(parsed).toHaveProperty('session_id'); + expect(typeof parsed.session_id).toBe('string'); + expect(parsed.session_id).not.toBe(''); }); }); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 67764f0345..f40dc68cbd 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -637,7 +637,11 @@ describe('runNonInteractive', () => { ); expect(processStdoutSpy).toHaveBeenCalledWith( JSON.stringify( - { response: 'Hello World', stats: MOCK_SESSION_METRICS }, + { + session_id: 'test-session-id', + response: 'Hello World', + stats: MOCK_SESSION_METRICS, + }, null, 2, ), @@ -720,7 +724,15 @@ describe('runNonInteractive', () => { // This should output JSON with empty response but include stats expect(processStdoutSpy).toHaveBeenCalledWith( - JSON.stringify({ response: '', stats: MOCK_SESSION_METRICS }, null, 2), + JSON.stringify( + { + session_id: 'test-session-id', + response: '', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), ); }); @@ -755,7 +767,15 @@ describe('runNonInteractive', () => { // This should output JSON with empty response but include stats expect(processStdoutSpy).toHaveBeenCalledWith( - JSON.stringify({ response: '', stats: MOCK_SESSION_METRICS }, null, 2), + JSON.stringify( + { + session_id: 'test-session-id', + response: '', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), ); }); @@ -792,6 +812,7 @@ describe('runNonInteractive', () => { expect(consoleErrorJsonSpy).toHaveBeenCalledWith( JSON.stringify( { + session_id: 'test-session-id', error: { type: 'Error', message: 'Invalid input provided', @@ -837,6 +858,7 @@ describe('runNonInteractive', () => { expect(consoleErrorJsonSpy).toHaveBeenCalledWith( JSON.stringify( { + session_id: 'test-session-id', error: { type: 'FatalInputError', message: 'Invalid command syntax provided', diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 1f7308c6f2..07a2bf5ca9 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -428,7 +428,9 @@ export async function runNonInteractive({ } else if (config.getOutputFormat() === OutputFormat.JSON) { const formatter = new JsonFormatter(); const stats = uiTelemetryService.getMetrics(); - textOutput.write(formatter.format(responseText, stats)); + textOutput.write( + formatter.format(config.getSessionId(), responseText, stats), + ); } else { textOutput.ensureTrailingNewline(); // Ensure a final newline } diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts index b2267bd5a0..f56edf5768 100644 --- a/packages/cli/src/utils/errors.test.ts +++ b/packages/cli/src/utils/errors.test.ts @@ -29,18 +29,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { 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 }), + formatError: vi.fn( + (error: Error, code?: string | number, sessionId?: string) => + JSON.stringify( + { + ...(sessionId && { session_id: sessionId }), + error: { + type: error.constructor.name, + message: error.message, + ...(code && { code }), + }, }, - }, - null, - 2, - ), + null, + 2, + ), ), })), StreamJsonFormatter: vi.fn().mockImplementation(() => ({ @@ -77,6 +79,8 @@ describe('errors', () => { let processExitSpy: MockInstance; let consoleErrorSpy: MockInstance; + const TEST_SESSION_ID = 'test-session-123'; + beforeEach(() => { // Reset mocks vi.clearAllMocks(); @@ -93,6 +97,7 @@ describe('errors', () => { mockConfig = { getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }), + getSessionId: vi.fn().mockReturnValue(TEST_SESSION_ID), } as unknown as Config; }); @@ -166,6 +171,7 @@ describe('errors', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( JSON.stringify( { + session_id: TEST_SESSION_ID, error: { type: 'Error', message: 'Test error', @@ -188,6 +194,7 @@ describe('errors', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( JSON.stringify( { + session_id: TEST_SESSION_ID, error: { type: 'Error', message: 'Test error', @@ -210,6 +217,7 @@ describe('errors', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( JSON.stringify( { + session_id: TEST_SESSION_ID, error: { type: 'FatalInputError', message: 'Fatal error', @@ -246,6 +254,7 @@ describe('errors', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( JSON.stringify( { + session_id: TEST_SESSION_ID, error: { type: 'Error', message: 'Error with status', @@ -398,6 +407,7 @@ describe('errors', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( JSON.stringify( { + session_id: TEST_SESSION_ID, error: { type: 'FatalToolExecutionError', message: 'Error executing tool test-tool: Tool failed', @@ -467,6 +477,7 @@ describe('errors', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( JSON.stringify( { + session_id: TEST_SESSION_ID, error: { type: 'FatalCancellationError', message: 'Operation cancelled.', @@ -529,6 +540,7 @@ describe('errors', () => { expect(consoleErrorSpy).toHaveBeenCalledWith( JSON.stringify( { + session_id: TEST_SESSION_ID, error: { type: 'FatalTurnLimitedError', message: diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index 29a527f865..e46db098f1 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -100,6 +100,7 @@ export function handleError( const formattedError = formatter.formatError( error instanceof Error ? error : new Error(getErrorMessage(error)), errorCode, + config.getSessionId(), ); console.error(formattedError); @@ -152,6 +153,7 @@ export function handleToolError( const formattedError = formatter.formatError( toolExecutionError, errorType ?? toolExecutionError.exitCode, + config.getSessionId(), ); console.error(formattedError); } else { @@ -191,6 +193,7 @@ export function handleCancellationError(config: Config): never { const formattedError = formatter.formatError( cancellationError, cancellationError.exitCode, + config.getSessionId(), ); console.error(formattedError); @@ -231,6 +234,7 @@ export function handleMaxTurnsExceededError(config: Config): never { const formattedError = formatter.formatError( maxTurnsError, maxTurnsError.exitCode, + config.getSessionId(), ); console.error(formattedError); diff --git a/packages/core/src/output/json-formatter.test.ts b/packages/core/src/output/json-formatter.test.ts index 587030a980..7df4aa770a 100644 --- a/packages/core/src/output/json-formatter.test.ts +++ b/packages/core/src/output/json-formatter.test.ts @@ -13,18 +13,30 @@ 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 formatted = formatter.format(undefined, response); const expected = { response, }; expect(JSON.parse(formatted)).toEqual(expected); }); + it('should format the response as JSON with a session ID', () => { + const formatter = new JsonFormatter(); + const response = 'This is a test response.'; + const sessionId = 'test-session-id'; + const formatted = formatter.format(sessionId, response); + const expected = { + session_id: sessionId, + 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 formatted = formatter.format(undefined, responseWithAnsi); const parsed = JSON.parse(formatted); expect(parsed.response).toBe('Red text and Green text'); }); @@ -33,7 +45,7 @@ describe('JsonFormatter', () => { const formatter = new JsonFormatter(); const responseWithControlChars = 'Text with\x07 bell\x08 and\x0B vertical tab'; - const formatted = formatter.format(responseWithControlChars); + const formatted = formatter.format(undefined, 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'); @@ -42,7 +54,7 @@ describe('JsonFormatter', () => { 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 formatted = formatter.format(undefined, responseWithWhitespace); const parsed = JSON.parse(formatted); expect(parsed.response).toBe('Line 1\nLine 2\r\nLine 3\twith tab'); }); @@ -114,7 +126,7 @@ describe('JsonFormatter', () => { totalLinesRemoved: 0, }, }; - const formatted = formatter.format(response, stats); + const formatted = formatter.format(undefined, response, stats); const expected = { response, stats, @@ -129,7 +141,7 @@ describe('JsonFormatter', () => { message: 'Invalid input provided', code: 400, }; - const formatted = formatter.format(undefined, undefined, error); + const formatted = formatter.format(undefined, undefined, undefined, error); const expected = { error, }; @@ -144,7 +156,7 @@ describe('JsonFormatter', () => { message: 'Request timed out', code: 'TIMEOUT', }; - const formatted = formatter.format(response, undefined, error); + const formatted = formatter.format(undefined, response, undefined, error); const expected = { response, error, @@ -167,6 +179,23 @@ describe('JsonFormatter', () => { }); }); + it('should format error using formatError method with a session ID', () => { + const formatter = new JsonFormatter(); + const error = new Error('Something went wrong'); + const sessionId = 'test-session-id'; + const formatted = formatter.formatError(error, 500, sessionId); + const parsed = JSON.parse(formatted); + + expect(parsed).toEqual({ + session_id: sessionId, + error: { + type: 'Error', + message: 'Something went wrong', + code: 500, + }, + }); + }); + it('should format custom error using formatError method', () => { class CustomError extends Error { constructor(message: string) { @@ -177,7 +206,7 @@ describe('JsonFormatter', () => { const formatter = new JsonFormatter(); const error = new CustomError('Custom error occurred'); - const formatted = formatter.formatError(error); + const formatted = formatter.formatError(error, undefined); const parsed = JSON.parse(formatted); expect(parsed).toEqual({ @@ -217,7 +246,7 @@ describe('JsonFormatter', () => { code: 429, }; - const formatted = formatter.format(response, stats, error); + const formatted = formatter.format(undefined, response, stats, error); const expected = { response, stats, diff --git a/packages/core/src/output/json-formatter.ts b/packages/core/src/output/json-formatter.ts index 83ea3e3862..dd3e558a6f 100644 --- a/packages/core/src/output/json-formatter.ts +++ b/packages/core/src/output/json-formatter.ts @@ -9,9 +9,18 @@ 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 { + format( + sessionId?: string, + response?: string, + stats?: SessionMetrics, + error?: JsonError, + ): string { const output: JsonOutput = {}; + if (sessionId) { + output.session_id = sessionId; + } + if (response !== undefined) { output.response = stripAnsi(response); } @@ -27,13 +36,17 @@ export class JsonFormatter { return JSON.stringify(output, null, 2); } - formatError(error: Error, code?: string | number): string { + formatError( + error: Error, + code?: string | number, + sessionId?: string, + ): string { const jsonError: JsonError = { type: error.constructor.name, message: stripAnsi(error.message), ...(code && { code }), }; - return this.format(undefined, undefined, jsonError); + return this.format(sessionId, undefined, undefined, jsonError); } } diff --git a/packages/core/src/output/types.ts b/packages/core/src/output/types.ts index 0448354255..81de667b22 100644 --- a/packages/core/src/output/types.ts +++ b/packages/core/src/output/types.ts @@ -19,6 +19,7 @@ export interface JsonError { } export interface JsonOutput { + session_id?: string; response?: string; stats?: SessionMetrics; error?: JsonError;