fix: report AgentExecutionBlocked in non-interactive programmatic modes (#26262)

This commit is contained in:
Coco Sheng
2026-04-30 16:41:35 -04:00
committed by GitHub
parent a03ec92436
commit 2f0c7518ad
11 changed files with 488 additions and 24 deletions
+196
View File
@@ -21,6 +21,7 @@ import {
FatalInputError,
CoreEvent,
CoreToolCallStatus,
JsonStreamEventType,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
@@ -1726,6 +1727,53 @@ describe('runNonInteractive', () => {
},
);
it.each([
{
name: 'loop detected',
events: [
{ type: GeminiEventType.LoopDetected },
] as ServerGeminiStreamEvent[],
expectedWarning: 'Loop detected, stopping execution',
},
{
name: 'max session turns',
events: [
{ type: GeminiEventType.MaxSessionTurns },
] as ServerGeminiStreamEvent[],
expectedWarning: 'Maximum session turns exceeded',
},
])(
'should include warning in JSON mode for: $name',
async ({ events, expectedWarning }) => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
const streamEvents: ServerGeminiStreamEvent[] = [
...events,
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(streamEvents),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test',
prompt_id: 'test',
});
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toBeDefined();
expect(output.warnings).toContain(expectedWarning);
},
);
it('should log error when tool recording fails', async () => {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
@@ -2038,6 +2086,154 @@ describe('runNonInteractive', () => {
expect(getWrittenOutput()).toBe('Final answer\n');
});
it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => {
const allEvents: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Blocked by hook' },
},
{ type: GeminiEventType.Content, value: 'Final answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(allEvents),
);
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
// Setup stream-json format
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test block',
prompt_id: 'prompt-id-block',
});
const calls = processStdoutSpy.mock.calls.map((call) =>
JSON.parse(call[0] as string),
);
const errorEvent = calls.find(
(c) => c.type === JsonStreamEventType.ERROR,
);
expect(errorEvent).toBeDefined();
expect(errorEvent.message).toContain(
'Agent execution blocked: Blocked by hook',
);
expect(errorEvent.severity).toBe('warning');
});
it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => {
const allEvents: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Blocked by hook' },
},
{ type: GeminiEventType.Content, value: 'Final answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(allEvents),
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test block',
prompt_id: 'prompt-id-block',
});
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toBeDefined();
expect(output.warnings).toContain(
'Agent execution blocked: Blocked by hook',
);
});
it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => {
const allEvents: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Block 1', systemMessage: 'Reason 1' },
},
{
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Block 2', systemMessage: 'Reason 2' },
},
{ type: GeminiEventType.Content, value: 'Final answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockImplementation(() =>
createStreamFromEvents(allEvents),
);
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test',
prompt_id: 'test',
});
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toHaveLength(2);
expect(output.warnings).toContain('Agent execution blocked: Reason 1');
expect(output.warnings).toContain('Agent execution blocked: Reason 2');
});
it('should not include warnings field in JSON output if no blocks occur', async () => {
const allEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Clean answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockImplementation(() =>
createStreamFromEvents(allEvents),
);
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test',
prompt_id: 'test',
});
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toBeUndefined();
});
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
+30 -3
View File
@@ -56,6 +56,13 @@ interface RunNonInteractiveParams {
resumedSessionData?: ResumedSessionData;
}
/**
* Runs the non-interactive CLI loop.
*
* Programmatic output formats (JSON, STREAM_JSON) use lenient sanitization
* by stripping ANSI escape sequences from messages to ensure clean,
* parseable output for downstream consumers.
*/
export async function runNonInteractive(
params: RunNonInteractiveParams,
): Promise<void> {
@@ -296,6 +303,7 @@ export async function runNonInteractive(
let turnCount = 0;
let invalidStreamError: string | undefined;
const warnings: string[] = [];
while (true) {
turnCount++;
if (
@@ -352,23 +360,27 @@ export async function runNonInteractive(
}
toolCallRequests.push(event.value);
} else if (event.type === GeminiEventType.LoopDetected) {
const message = 'Loop detected, stopping execution';
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'warning',
message: 'Loop detected, stopping execution',
message,
});
}
warnings.push(message);
} else if (event.type === GeminiEventType.MaxSessionTurns) {
const message = 'Maximum session turns exceeded';
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'error',
message: 'Maximum session turns exceeded',
message,
});
}
warnings.push(message);
} else if (event.type === GeminiEventType.Error) {
throw event.value.error;
} else if (event.type === GeminiEventType.AgentExecutionStopped) {
@@ -395,7 +407,15 @@ export async function runNonInteractive(
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`[WARNING] ${blockMessage}\n`);
} else if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'warning',
message: stripAnsi(blockMessage),
});
}
warnings.push(blockMessage);
} else if (event.type === GeminiEventType.InvalidStream) {
invalidStreamError =
'Invalid stream: The model returned an empty response or malformed tool call.';
@@ -507,7 +527,13 @@ export async function runNonInteractive(
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
textOutput.write(
formatter.format(config.getSessionId(), responseText, stats),
formatter.format(
config.getSessionId(),
responseText,
stats,
undefined,
warnings,
),
);
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
@@ -538,6 +564,7 @@ export async function runNonInteractive(
invalidStreamError
? { type: 'INVALID_STREAM', message: invalidStreamError }
: undefined,
warnings,
),
);
} else {
@@ -21,6 +21,7 @@ import {
FatalInputError,
CoreEvent,
CoreToolCallStatus,
JsonStreamEventType,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
@@ -757,7 +758,7 @@ describe('runNonInteractive', () => {
createStreamFromEvents(events),
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -847,7 +848,7 @@ describe('runNonInteractive', () => {
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -927,7 +928,7 @@ describe('runNonInteractive', () => {
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -963,7 +964,7 @@ describe('runNonInteractive', () => {
createStreamFromEvents(events),
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -1699,7 +1700,7 @@ describe('runNonInteractive', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -1861,7 +1862,7 @@ describe('runNonInteractive', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -1895,6 +1896,50 @@ describe('runNonInteractive', () => {
},
);
it.each([
{
name: 'loop detected',
events: [
{ type: GeminiEventType.LoopDetected },
] as ServerGeminiStreamEvent[],
expectedWarning: 'Loop detected, stopping execution',
},
])(
'should include warning in JSON mode for: $name',
async ({ events, expectedWarning }) => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
const streamEvents: ServerGeminiStreamEvent[] = [
...events,
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(streamEvents),
);
try {
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test',
prompt_id: 'test',
});
} catch {
// Expected exit for max turns
}
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toBeDefined();
expect(output.warnings[0]).toContain(expectedWarning);
},
);
it('should log error when tool recording fails', async () => {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
@@ -2034,7 +2079,7 @@ describe('runNonInteractive', () => {
it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -2098,7 +2143,7 @@ describe('runNonInteractive', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -2203,6 +2248,154 @@ describe('runNonInteractive', () => {
expect(getWrittenOutput()).toBe('Final answer\n');
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => {
const allEvents: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Blocked by hook' },
},
{ type: GeminiEventType.Content, value: 'Final answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(allEvents),
);
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
// Setup stream-json format
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test block',
prompt_id: 'prompt-id-block',
});
const calls = processStdoutSpy.mock.calls.map((call) =>
JSON.parse(call[0] as string),
);
const errorEvent = calls.find(
(c) => c.type === JsonStreamEventType.ERROR,
);
expect(errorEvent).toBeDefined();
expect(errorEvent.message).toContain(
'Agent execution blocked: Blocked by hook',
);
expect(errorEvent.severity).toBe('warning');
});
it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => {
const allEvents: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Blocked by hook' },
},
{ type: GeminiEventType.Content, value: 'Final answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(allEvents),
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test block',
prompt_id: 'prompt-id-block',
});
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toBeDefined();
expect(output.warnings).toContain(
'Agent execution blocked: Blocked by hook',
);
});
it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => {
const allEvents: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Block 1', systemMessage: 'Reason 1' },
},
{
type: GeminiEventType.AgentExecutionBlocked,
value: { reason: 'Block 2', systemMessage: 'Reason 2' },
},
{ type: GeminiEventType.Content, value: 'Final answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockImplementation(() =>
createStreamFromEvents(allEvents),
);
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test',
prompt_id: 'test',
});
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toHaveLength(2);
expect(output.warnings).toContain('Agent execution blocked: Reason 1');
expect(output.warnings).toContain('Agent execution blocked: Reason 2');
});
it('should not include warnings field in JSON output if no blocks occur', async () => {
const allEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Clean answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockImplementation(() =>
createStreamFromEvents(allEvents),
);
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test',
prompt_id: 'test',
});
const output = JSON.parse(getWrittenOutput());
expect(output.warnings).toBeUndefined();
});
});
describe('Output Sanitization', () => {
@@ -2346,7 +2539,7 @@ describe('runNonInteractive', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -2418,7 +2611,7 @@ describe('runNonInteractive', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -59,6 +59,13 @@ interface RunNonInteractiveParams {
resumedSessionData?: ResumedSessionData;
}
/**
* Runs the non-interactive CLI using the LegacyAgentSession.
*
* Programmatic output formats (JSON, STREAM_JSON) use lenient sanitization
* by stripping ANSI escape sequences from messages to ensure clean,
* parseable output for downstream consumers.
*/
export async function runNonInteractive({
config,
settings,
@@ -339,7 +346,13 @@ export async function runNonInteractive({
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
textOutput.write(
formatter.format(config.getSessionId(), responseText, stats),
formatter.format(
config.getSessionId(),
responseText,
stats,
undefined,
warnings,
),
);
} else {
textOutput.ensureTrailingNewline();
@@ -420,6 +433,7 @@ export async function runNonInteractive({
let responseText = '';
let preToolResponseText: string | undefined;
let streamEnded = false;
const warnings: string[] = [];
for await (const event of session.stream({ streamId })) {
if (streamEnded) break;
switch (event.type) {
@@ -538,9 +552,18 @@ export async function runNonInteractive({
const errorCode = event._meta?.['code'];
if (errorCode === 'AGENT_EXECUTION_BLOCKED') {
const blockMessage = `Agent execution blocked: ${event.message.trim()}`;
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`[WARNING] ${event.message}\n`);
process.stderr.write(`[WARNING] ${blockMessage}\n`);
} else if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'warning',
message: stripAnsi(blockMessage),
});
}
warnings.push(blockMessage);
break;
}
@@ -554,9 +577,10 @@ export async function runNonInteractive({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity,
message: event.message,
message: stripAnsi(event.message),
});
}
warnings.push(event.message);
break;
}
case 'agent_end': {
+7 -2
View File
@@ -279,12 +279,17 @@ export const useAgentStream = ({
break;
}
case 'error':
case 'error': {
const message =
event._meta?.['code'] === 'AGENT_EXECUTION_BLOCKED'
? `Agent execution blocked: ${event.message}`
: event.message;
addItem(
{ type: MessageType.ERROR, text: event.message },
{ type: MessageType.ERROR, text: message },
userMessageTimestampRef.current,
);
break;
}
case 'initialize':
case 'session_update':
@@ -378,7 +378,7 @@ describe('translateEvent', () => {
expect(err.type).toBe('error');
expect(err.fatal).toBe(false);
expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED');
expect(err.message).toBe('Agent execution blocked: Policy violation');
expect(err.message).toBe('Policy violation');
});
it('uses systemMessage in the final error message when available', () => {
@@ -393,9 +393,7 @@ describe('translateEvent', () => {
};
const result = translateEvent(event, state);
const err = result[0] as AgentEvent<'error'>;
expect(err.message).toBe(
'Agent execution blocked: Blocked by policy hook',
);
expect(err.message).toBe('Blocked by policy hook');
});
});
+1 -1
View File
@@ -210,7 +210,7 @@ export function translateEvent(
out.push(
makeEvent('error', state, {
status: 'PERMISSION_DENIED',
message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`,
message: event.value.systemMessage?.trim() || event.value.reason,
fatal: false,
_meta: { code: 'AGENT_EXECUTION_BLOCKED' },
}),
@@ -647,7 +647,7 @@ describe('LegacyAgentSession', () => {
e.type === 'error' && e._meta?.['code'] === 'AGENT_EXECUTION_BLOCKED',
);
expect(blocked?.fatal).toBe(false);
expect(blocked?.message).toBe('Agent execution blocked: Blocked by hook');
expect(blocked?.message).toBe('Blocked by hook');
const messages = events.filter(
(e): e is AgentEvent<'message'> =>
@@ -331,4 +331,19 @@ describe('JsonFormatter', () => {
expect(parsed.error.message).toBe('Error\x07 with\x08 control\x0B chars');
expect(() => JSON.parse(formatted)).not.toThrow();
});
it('should format warnings as JSON', () => {
const formatter = new JsonFormatter();
const warnings = ['Warning 1', '\x1B[33mWarning 2 with ANSI\x1B[0m'];
const formatted = formatter.format(
undefined,
undefined,
undefined,
undefined,
warnings,
);
const parsed = JSON.parse(formatted);
expect(parsed.warnings).toEqual(['Warning 1', 'Warning 2 with ANSI']);
});
});
@@ -15,6 +15,7 @@ export class JsonFormatter {
response?: string,
stats?: SessionMetrics,
error?: JsonError,
warnings?: string[],
): string {
const output: JsonOutput = {};
@@ -34,6 +35,10 @@ export class JsonFormatter {
output.error = error;
}
if (warnings && warnings.length > 0) {
output.warnings = warnings.map((w) => stripAnsi(w));
}
return JSON.stringify(output, null, 2);
}
+1
View File
@@ -23,6 +23,7 @@ export interface JsonOutput {
response?: string;
stats?: SessionMetrics;
error?: JsonError;
warnings?: string[];
}
// Streaming JSON event types