diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 1167bbbce4..8547e150ef 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -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 }, diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8db512f56d..04149a8b28 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -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 { @@ -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 { diff --git a/packages/cli/src/nonInteractiveCliAgentSession.test.ts b/packages/cli/src/nonInteractiveCliAgentSession.test.ts index 923109643c..5d3957421a 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.test.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.test.ts @@ -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, ); diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts index 0cf16da47d..e0a532becf 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.ts @@ -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': { diff --git a/packages/cli/src/ui/hooks/useAgentStream.ts b/packages/cli/src/ui/hooks/useAgentStream.ts index 4fb4a9c94f..926ba7cc7c 100644 --- a/packages/cli/src/ui/hooks/useAgentStream.ts +++ b/packages/cli/src/ui/hooks/useAgentStream.ts @@ -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': diff --git a/packages/core/src/agent/event-translator.test.ts b/packages/core/src/agent/event-translator.test.ts index cfb5cfe300..80ec96be10 100644 --- a/packages/core/src/agent/event-translator.test.ts +++ b/packages/core/src/agent/event-translator.test.ts @@ -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'); }); }); diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index dee56adbd0..fe8a73a31d 100644 --- a/packages/core/src/agent/event-translator.ts +++ b/packages/core/src/agent/event-translator.ts @@ -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' }, }), diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index 9ee8b032ad..8f5a24a881 100644 --- a/packages/core/src/agent/legacy-agent-session.test.ts +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -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'> => diff --git a/packages/core/src/output/json-formatter.test.ts b/packages/core/src/output/json-formatter.test.ts index 13321fae77..591e321b1c 100644 --- a/packages/core/src/output/json-formatter.test.ts +++ b/packages/core/src/output/json-formatter.test.ts @@ -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']); + }); }); diff --git a/packages/core/src/output/json-formatter.ts b/packages/core/src/output/json-formatter.ts index bce5055a6b..52c0695140 100644 --- a/packages/core/src/output/json-formatter.ts +++ b/packages/core/src/output/json-formatter.ts @@ -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); } diff --git a/packages/core/src/output/types.ts b/packages/core/src/output/types.ts index c67c8afe99..1d4159a1f8 100644 --- a/packages/core/src/output/types.ts +++ b/packages/core/src/output/types.ts @@ -23,6 +23,7 @@ export interface JsonOutput { response?: string; stats?: SessionMetrics; error?: JsonError; + warnings?: string[]; } // Streaming JSON event types