From 218adcd6c36be19fba60bc1f423590a949de2b6c Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Fri, 20 Mar 2026 11:19:34 -0400 Subject: [PATCH] feat(cli): migrate nonInteractiveCli to LegacyAgentSession --- packages/cli/src/nonInteractiveCli.test.ts | 86 +++-- packages/cli/src/nonInteractiveCli.ts | 395 ++++++++++----------- 2 files changed, 247 insertions(+), 234 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 4e45b0f188..f506ec9d60 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -58,6 +58,12 @@ const mockSchedulerSchedule = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); + const { LegacyAgentSession } = await import( + '../../core/src/agent/legacy-agent-session.js' + ); + const { geminiPartsToContentParts } = await import( + '../../core/src/agent/content-utils.js' + ); class MockChatRecordingService { initialize = vi.fn(); @@ -77,6 +83,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { uiTelemetryService: { getMetrics: vi.fn(), }, + LegacyAgentSession, + geminiPartsToContentParts, coreEvents: mockCoreEvents, createWorkingStdio: vi.fn(() => ({ stdout: process.stdout, @@ -108,6 +116,8 @@ describe('runNonInteractive', () => { sendMessageStream: Mock; resumeChat: Mock; getChatRecordingService: Mock; + getChat: Mock; + getCurrentSequenceModel: Mock; }; const MOCK_SESSION_METRICS: SessionMetrics = { models: {}, @@ -163,6 +173,8 @@ describe('runNonInteractive', () => { recordMessageTokens: vi.fn(), recordToolCalls: vi.fn(), })), + getChat: vi.fn(() => ({ recordCompletedToolCalls: vi.fn() })), + getCurrentSequenceModel: vi.fn().mockReturnValue(null), }; mockConfig = { @@ -259,9 +271,6 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', - undefined, - false, - 'Test input', ); expect(getWrittenOutput()).toBe('Hello World\n'); // Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts @@ -378,9 +387,6 @@ describe('runNonInteractive', () => { [{ text: 'Tool response' }], expect.any(AbortSignal), 'prompt-id-2', - undefined, - false, - undefined, ); expect(getWrittenOutput()).toBe('Final answer\n'); }); @@ -538,9 +544,6 @@ describe('runNonInteractive', () => { ], expect.any(AbortSignal), 'prompt-id-3', - undefined, - false, - undefined, ); expect(getWrittenOutput()).toBe('Sorry, let me try again.\n'); }); @@ -558,7 +561,7 @@ describe('runNonInteractive', () => { input: 'Initial fail', prompt_id: 'prompt-id-4', }), - ).rejects.toThrow(apiError); + ).rejects.toThrow('API connection failed'); }); it('should not exit if a tool is not found, and should send error back to model', async () => { @@ -680,9 +683,6 @@ describe('runNonInteractive', () => { processedParts, expect.any(AbortSignal), 'prompt-id-7', - undefined, - false, - rawInput, ); // 6. Assert the final output is correct @@ -716,9 +716,6 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', - undefined, - false, - 'Test input', ); expect(processStdoutSpy).toHaveBeenCalledWith( JSON.stringify( @@ -849,9 +846,6 @@ describe('runNonInteractive', () => { [{ text: 'Empty response test' }], expect.any(AbortSignal), 'prompt-id-empty', - undefined, - false, - 'Empty response test', ); // This should output JSON with empty response but include stats @@ -941,7 +935,7 @@ describe('runNonInteractive', () => { { session_id: 'test-session-id', error: { - type: 'FatalInputError', + type: 'Error', message: 'Invalid command syntax provided', code: 42, }, @@ -986,9 +980,6 @@ describe('runNonInteractive', () => { [{ text: 'Prompt from command' }], expect.any(AbortSignal), 'prompt-id-slash', - undefined, - false, - '/testcommand', ); expect(getWrittenOutput()).toBe('Response from command\n'); @@ -1032,9 +1023,6 @@ describe('runNonInteractive', () => { [{ text: 'Slash command output' }], expect.any(AbortSignal), 'prompt-id-slash', - undefined, - false, - '/help', ); expect(getWrittenOutput()).toBe('Response to slash command\n'); handleSlashCommandSpy.mockRestore(); @@ -1210,9 +1198,6 @@ describe('runNonInteractive', () => { [{ text: '/unknowncommand' }], expect.any(AbortSignal), 'prompt-id-unknown', - undefined, - false, - '/unknowncommand', ); expect(getWrittenOutput()).toBe('Response to unknown\n'); @@ -1777,15 +1762,13 @@ describe('runNonInteractive', () => { throw new Error('Recording failed'); }), }; - // @ts-expect-error - Mocking internal structure mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat); - // @ts-expect-error - Mocking internal structure mockGeminiClient.getCurrentSequenceModel = vi .fn() .mockReturnValue('model-1'); // Mock debugLogger.error - const { debugLogger } = await import('@google/gemini-cli-core'); + const { debugLogger } = await import('../../core/src/utils/debugLogger.js'); const debugLoggerErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); @@ -2000,7 +1983,6 @@ describe('runNonInteractive', () => { expect(processStderrSpy).toHaveBeenCalledWith( 'Agent execution stopped: Stopped by hook\n', ); - // Should exit without calling sendMessageStream again expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); }); @@ -2031,9 +2013,9 @@ describe('runNonInteractive', () => { expect(processStderrSpy).toHaveBeenCalledWith( '[WARNING] Agent execution blocked: Blocked by hook\n', ); - // sendMessageStream is called once, recursion is internal to it and transparent to the caller - expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + // Stream continues after blocked event — content should be output expect(getWrittenOutput()).toBe('Final answer\n'); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); }); }); @@ -2174,6 +2156,40 @@ describe('runNonInteractive', () => { ); }); + it('should emit warning event for loop_detected custom event in streaming JSON mode', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const streamEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.LoopDetected } as ServerGeminiStreamEvent, + { type: GeminiEventType.Content, value: 'Continuing after loop' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(streamEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Loop test explicit', + prompt_id: 'prompt-id-loop-explicit', + }); + + const output = getWrittenOutput(); + // The STREAM_JSON output should contain an error event with warning severity + expect(output).toContain('"type":"error"'); + expect(output).toContain('"severity":"warning"'); + expect(output).toContain('Loop detected'); + }); + it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => { const toolCallEvent: ServerGeminiStreamEvent = { type: GeminiEventType.ToolCallRequest, diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 891e3d0ee9..72071d1c2d 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -6,15 +6,15 @@ import type { Config, - ToolCallRequestInfo, ResumedSessionData, UserFeedbackPayload, + AgentEvent, + ContentPart, } from '@google/gemini-cli-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; import { convertSessionToClientHistory, - GeminiEventType, FatalInputError, promptIdContext, OutputFormat, @@ -22,17 +22,17 @@ import { StreamJsonFormatter, JsonStreamEventType, uiTelemetryService, - debugLogger, coreEvents, CoreEvent, createWorkingStdio, - recordToolCallInteractions, - ToolErrorType, Scheduler, ROOT_SCHEDULER_ID, + LegacyAgentSession, + ToolErrorType, + geminiPartsToContentParts, } from '@google/gemini-cli-core'; -import type { Content, Part } from '@google/genai'; +import type { Part } from '@google/genai'; import readline from 'node:readline'; import stripAnsi from 'strip-ansi'; @@ -150,8 +150,6 @@ export async function runNonInteractive({ }, 200); abortController.abort(); - // Note: Don't exit here - let the abort flow through the system - // and trigger handleCancellationError() which will exit with proper code } }; @@ -246,9 +244,6 @@ export async function runNonInteractive({ config, settings, ); - // If a slash command is found and returns a prompt, use it. - // Otherwise, slashCommandResult falls through to the default prompt - // handling. if (slashCommandResult) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion query = slashCommandResult as Part[]; @@ -266,8 +261,6 @@ export async function runNonInteractive({ escapePastedAtSymbols: false, }); if (error || !processedQuery) { - // An error occurred during @include processing (e.g., file not found). - // The error message is already logged by handleAtCommand. throw new FatalInputError( error || 'Exiting due to an error processing the @ command.', ); @@ -286,235 +279,239 @@ export async function runNonInteractive({ }); } - let currentMessages: Content[] = [{ role: 'user', parts: query }]; + // Create LegacyAgentSession — owns the agentic loop + const session = new LegacyAgentSession({ + client: geminiClient, + scheduler, + config, + promptId: prompt_id, + }); - let turnCount = 0; - while (true) { - turnCount++; - if ( - config.getMaxSessionTurns() >= 0 && - turnCount > config.getMaxSessionTurns() - ) { - handleMaxTurnsExceededError(config); + // Wire Ctrl+C to session abort + abortController.signal.addEventListener('abort', () => { + void session.abort(); + }); + + // Start the agentic loop (runs in background) + await session.send({ + message: geminiPartsToContentParts(query), + }); + + const getFirstText = (parts?: ContentPart[]): string | undefined => { + const part = parts?.[0]; + return part?.type === 'text' ? part.text : undefined; + }; + + const emitFinalSuccessResult = (): void => { + if (streamFormatter) { + const metrics = uiTelemetryService.getMetrics(); + const durationMs = Date.now() - startTime; + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: 'success', + stats: streamFormatter.convertToStreamStats(metrics, durationMs), + }); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const stats = uiTelemetryService.getMetrics(); + textOutput.write( + formatter.format(config.getSessionId(), responseText, stats), + ); + } else { + textOutput.ensureTrailingNewline(); } - const toolCallRequests: ToolCallRequestInfo[] = []; + }; - const responseStream = geminiClient.sendMessageStream( - currentMessages[0]?.parts || [], - abortController.signal, - prompt_id, - undefined, - false, - turnCount === 1 ? input : undefined, - ); + const reconstructFatalError = (event: AgentEvent<'error'>): Error => { + const errToThrow = new Error(event.message); + const errorMeta = event._meta; + if (errorMeta?.['exitCode'] !== undefined) { + Object.defineProperty(errToThrow, 'exitCode', { + value: errorMeta['exitCode'], + enumerable: true, + }); + } + if (errorMeta?.['errorName'] !== undefined) { + Object.defineProperty(errToThrow, 'name', { + value: errorMeta['errorName'], + enumerable: true, + }); + } + if (errorMeta?.['code'] !== undefined) { + Object.defineProperty(errToThrow, 'code', { + value: errorMeta['code'], + enumerable: true, + }); + } + return errToThrow; + }; - let responseText = ''; - for await (const event of responseStream) { - if (abortController.signal.aborted) { - handleCancellationError(config); - } - - if (event.type === GeminiEventType.Content) { - const isRaw = - config.getRawOutput() || config.getAcceptRawOutputRisk(); - const output = isRaw ? event.value : stripAnsi(event.value); - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.MESSAGE, - timestamp: new Date().toISOString(), - role: 'assistant', - content: output, - delta: true, - }); - } else if (config.getOutputFormat() === OutputFormat.JSON) { - responseText += output; - } else { - if (event.value) { - textOutput.write(output); + // Consume AgentEvents for output formatting + let responseText = ''; + let streamEnded = false; + for await (const event of session.stream()) { + if (streamEnded) break; + switch (event.type) { + case 'message': { + if (event.role === 'agent') { + for (const part of event.content) { + if (part.type === 'text') { + const isRaw = + config.getRawOutput() || config.getAcceptRawOutputRisk(); + const output = isRaw ? part.text : stripAnsi(part.text); + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.MESSAGE, + timestamp: new Date().toISOString(), + role: 'assistant', + content: output, + delta: true, + }); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + responseText += output; + } else { + if (part.text) { + textOutput.write(output); + } + } + } } } - } else if (event.type === GeminiEventType.ToolCallRequest) { + break; + } + case 'tool_request': { if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.TOOL_USE, timestamp: new Date().toISOString(), - tool_name: event.value.name, - tool_id: event.value.callId, - parameters: event.value.args, + tool_name: event.name, + tool_id: event.requestId, + parameters: event.args, }); } - toolCallRequests.push(event.value); - } else if (event.type === GeminiEventType.LoopDetected) { - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.ERROR, - timestamp: new Date().toISOString(), - severity: 'warning', - message: 'Loop detected, stopping execution', - }); - } - } else if (event.type === GeminiEventType.MaxSessionTurns) { - if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.ERROR, - timestamp: new Date().toISOString(), - severity: 'error', - message: 'Maximum session turns exceeded', - }); - } - } else if (event.type === GeminiEventType.Error) { - throw event.value.error; - } else if (event.type === GeminiEventType.AgentExecutionStopped) { - const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`; - if (config.getOutputFormat() === OutputFormat.TEXT) { - process.stderr.write(`${stopMessage}\n`); - } - // Emit final result event for streaming JSON if needed - if (streamFormatter) { - const metrics = uiTelemetryService.getMetrics(); - const durationMs = Date.now() - startTime; - streamFormatter.emitEvent({ - type: JsonStreamEventType.RESULT, - timestamp: new Date().toISOString(), - status: 'success', - stats: streamFormatter.convertToStreamStats( - metrics, - durationMs, - ), - }); - } - return; - } else if (event.type === GeminiEventType.AgentExecutionBlocked) { - const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`; - if (config.getOutputFormat() === OutputFormat.TEXT) { - process.stderr.write(`[WARNING] ${blockMessage}\n`); - } + break; } - } - - if (toolCallRequests.length > 0) { - textOutput.ensureTrailingNewline(); - const completedToolCalls = await scheduler.schedule( - toolCallRequests, - abortController.signal, - ); - const toolResponseParts: Part[] = []; - - for (const completedToolCall of completedToolCalls) { - const toolResponse = completedToolCall.response; - const requestInfo = completedToolCall.request; - + case 'tool_response': { + textOutput.ensureTrailingNewline(); if (streamFormatter) { + const displayText = getFirstText(event.displayContent); + const errorMsg = getFirstText(event.content) ?? 'Tool error'; streamFormatter.emitEvent({ type: JsonStreamEventType.TOOL_RESULT, timestamp: new Date().toISOString(), - tool_id: requestInfo.callId, - status: - completedToolCall.status === 'error' ? 'error' : 'success', - output: - typeof toolResponse.resultDisplay === 'string' - ? toolResponse.resultDisplay - : undefined, - error: toolResponse.error + tool_id: event.requestId, + status: event.isError ? 'error' : 'success', + output: displayText, + error: event.isError ? { - type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR', - message: toolResponse.error.message, + type: + typeof event.data?.['errorType'] === 'string' + ? event.data['errorType'] + : 'TOOL_EXECUTION_ERROR', + message: errorMsg, } : undefined, }); } + if (event.isError) { + const displayText = getFirstText(event.displayContent); + const errorMsg = getFirstText(event.content) ?? 'Tool error'; + + if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) { + const stopMessage = `Agent execution stopped: ${errorMsg}`; + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`${stopMessage}\n`); + } + } - if (toolResponse.error) { handleToolError( - requestInfo.name, - toolResponse.error, + event.name, + new Error(errorMsg), config, - toolResponse.errorType || 'TOOL_EXECUTION_ERROR', - typeof toolResponse.resultDisplay === 'string' - ? toolResponse.resultDisplay + typeof event.data?.['errorType'] === 'string' + ? event.data['errorType'] : undefined, + displayText, ); } - - if (toolResponse.responseParts) { - toolResponseParts.push(...toolResponse.responseParts); + break; + } + case 'error': { + if (event.fatal) { + throw reconstructFatalError(event); } - } - // Record tool calls with full metadata before sending responses to Gemini - try { - const currentModel = - geminiClient.getCurrentSequenceModel() ?? config.getModel(); - geminiClient - .getChat() - .recordCompletedToolCalls(currentModel, completedToolCalls); + const errorCode = event._meta?.['code']; - await recordToolCallInteractions(config, completedToolCalls); - } catch (error) { - debugLogger.error( - `Error recording completed tool call information: ${error}`, - ); - } + if (errorCode === 'MAX_TURNS_EXCEEDED') { + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'error', + message: event.message, + }); + } + break; + } - // Check if any tool requested to stop execution immediately - const stopExecutionTool = completedToolCalls.find( - (tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION, - ); - - if (stopExecutionTool && stopExecutionTool.response.error) { - const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`; + if (errorCode === 'AGENT_EXECUTION_BLOCKED') { + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`[WARNING] ${event.message}\n`); + } + break; + } + const severity = + event.status === 'RESOURCE_EXHAUSTED' ? 'error' : 'warning'; if (config.getOutputFormat() === OutputFormat.TEXT) { - process.stderr.write(`${stopMessage}\n`); + process.stderr.write(`[WARNING] ${event.message}\n`); } - - // Emit final result event for streaming JSON if (streamFormatter) { - const metrics = uiTelemetryService.getMetrics(); - const durationMs = Date.now() - startTime; streamFormatter.emitEvent({ - type: JsonStreamEventType.RESULT, + type: JsonStreamEventType.ERROR, timestamp: new Date().toISOString(), - status: 'success', - stats: streamFormatter.convertToStreamStats( - metrics, - durationMs, - ), + severity, + message: event.message, }); - } else if (config.getOutputFormat() === OutputFormat.JSON) { - const formatter = new JsonFormatter(); - const stats = uiTelemetryService.getMetrics(); - textOutput.write( - formatter.format(config.getSessionId(), responseText, stats), - ); - } else { - textOutput.ensureTrailingNewline(); // Ensure a final newline } - return; + break; } + case 'stream_end': { + if (event.reason === 'aborted') { + handleCancellationError(config); + } else if (event.reason === 'max_turns') { + handleMaxTurnsExceededError(config); + } - currentMessages = [{ role: 'user', parts: toolResponseParts }]; - } else { - // Emit final result event for streaming JSON - if (streamFormatter) { - const metrics = uiTelemetryService.getMetrics(); - const durationMs = Date.now() - startTime; - streamFormatter.emitEvent({ - type: JsonStreamEventType.RESULT, - timestamp: new Date().toISOString(), - status: 'success', - stats: streamFormatter.convertToStreamStats(metrics, durationMs), - }); - } else if (config.getOutputFormat() === OutputFormat.JSON) { - const formatter = new JsonFormatter(); - const stats = uiTelemetryService.getMetrics(); - textOutput.write( - formatter.format(config.getSessionId(), responseText, stats), - ); - } else { - textOutput.ensureTrailingNewline(); // Ensure a final newline + const stopMessage = + typeof event.data?.['message'] === 'string' + ? event.data['message'] + : ''; + if (stopMessage && config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`Agent execution stopped: ${stopMessage}\n`); + } + + emitFinalSuccessResult(); + streamEnded = true; + break; } - return; + case 'custom': { + if (event.kind === 'loop_detected') { + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'warning', + message: 'Loop detected, stopping execution', + }); + } + } + break; + } + default: + break; } } } catch (error) {