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
@@ -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,
);