mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
fix: report AgentExecutionBlocked in non-interactive programmatic modes (#26262)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user