diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index d2fa89088b..26daaf66a1 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -6,41 +6,33 @@ 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, - FatalError, - FatalAuthenticationError, + GeminiEventType, FatalInputError, - FatalSandboxError, - FatalConfigError, - FatalTurnLimitedError, - FatalToolExecutionError, - FatalCancellationError, promptIdContext, OutputFormat, JsonFormatter, StreamJsonFormatter, JsonStreamEventType, uiTelemetryService, + debugLogger, coreEvents, CoreEvent, createWorkingStdio, + recordToolCallInteractions, + ToolErrorType, Scheduler, ROOT_SCHEDULER_ID, - LegacyAgentSession, - ToolErrorType, - geminiPartsToContentParts, - debugLogger, } from '@google/gemini-cli-core'; -import type { Part } from '@google/genai'; +import type { Content, Part } from '@google/genai'; import readline from 'node:readline'; import stripAnsi from 'strip-ansi'; @@ -163,6 +155,8 @@ 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 } }; @@ -193,8 +187,6 @@ export async function runNonInteractive( }; let errorToHandle: unknown | undefined; - let terminalProcessExitHandled = false; - let abortSession = () => {}; try { consolePatcher.patch(); @@ -259,6 +251,9 @@ 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[]; @@ -276,6 +271,8 @@ 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.', ); @@ -294,335 +291,235 @@ export async function runNonInteractive( }); } - // Create LegacyAgentSession — owns the agentic loop - const session = new LegacyAgentSession({ - client: geminiClient, - scheduler, - config, - promptId: prompt_id, - }); + let currentMessages: Content[] = [{ role: 'user', parts: query }]; - // Wire Ctrl+C to session abort - abortSession = () => { - void session.abort(); - }; - abortController.signal.addEventListener('abort', abortSession); - if (abortController.signal.aborted) { - return handleCancellationError(config); - } + let turnCount = 0; + while (true) { + turnCount++; + if ( + config.getMaxSessionTurns() >= 0 && + turnCount > config.getMaxSessionTurns() + ) { + handleMaxTurnsExceededError(config); + } + const toolCallRequests: ToolCallRequestInfo[] = []; - // Start the agentic loop (runs in background) - const { streamId } = await session.send({ - message: { - content: geminiPartsToContentParts(query), - displayContent: input, - }, - }); - if (streamId === null) { - throw new Error( - 'LegacyAgentSession.send() unexpectedly returned no stream for a message send.', + const responseStream = geminiClient.sendMessageStream( + currentMessages[0]?.parts || [], + abortController.signal, + prompt_id, + undefined, + false, + turnCount === 1 ? input : undefined, ); - } - const getTextContent = (parts?: ContentPart[]): string | undefined => { - const text = parts - ?.map((part) => (part.type === 'text' ? part.text : '')) - .join(''); - return text ? text : undefined; - }; + let responseText = ''; + for await (const event of responseStream) { + if (abortController.signal.aborted) { + handleCancellationError(config); + } - 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 reconstructFatalError = (event: AgentEvent<'error'>): Error => { - const errorMeta = event._meta; - const name = - typeof errorMeta?.['errorName'] === 'string' - ? errorMeta['errorName'] - : undefined; - - let errToThrow: Error; - switch (name) { - case 'FatalAuthenticationError': - errToThrow = new FatalAuthenticationError(event.message); - break; - case 'FatalInputError': - errToThrow = new FatalInputError(event.message); - break; - case 'FatalSandboxError': - errToThrow = new FatalSandboxError(event.message); - break; - case 'FatalConfigError': - errToThrow = new FatalConfigError(event.message); - break; - case 'FatalTurnLimitedError': - errToThrow = new FatalTurnLimitedError(event.message); - break; - case 'FatalToolExecutionError': - errToThrow = new FatalToolExecutionError(event.message); - break; - case 'FatalCancellationError': - errToThrow = new FatalCancellationError(event.message); - break; - case 'FatalError': - errToThrow = new FatalError( - event.message, - typeof errorMeta?.['exitCode'] === 'number' - ? errorMeta['exitCode'] - : 1, - ); - break; - default: - errToThrow = new Error(event.message); - if (name) { - Object.defineProperty(errToThrow, 'name', { - value: name, - enumerable: true, + 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, }); - } - break; - } - - if (errorMeta?.['exitCode'] !== undefined) { - Object.defineProperty(errToThrow, 'exitCode', { - value: errorMeta['exitCode'], - enumerable: true, - }); - } - if (errorMeta?.['code'] !== undefined) { - Object.defineProperty(errToThrow, 'code', { - value: errorMeta['code'], - enumerable: true, - }); - } - if (errorMeta?.['status'] !== undefined) { - Object.defineProperty(errToThrow, 'status', { - value: errorMeta['status'], - enumerable: true, - }); - } - return errToThrow; - }; - - const runTerminalExitHandler = (handler: () => never): never => { - terminalProcessExitHandled = true; - return handler(); - }; - - // Consume AgentEvents for output formatting - let responseText = ''; - let preToolResponseText: string | undefined; - let streamEnded = false; - for await (const event of session.stream({ streamId })) { - 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 (config.getOutputFormat() === OutputFormat.JSON) { + responseText += output; + } else { + if (event.value) { + textOutput.write(output); } } - break; - } - case 'tool_request': { - if (config.getOutputFormat() === OutputFormat.JSON) { - // Final JSON output should reflect the last assistant answer after - // any tool orchestration, not intermediate pre-tool text. - preToolResponseText = responseText || preToolResponseText; - responseText = ''; - } + } else if (event.type === GeminiEventType.ToolCallRequest) { if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.TOOL_USE, timestamp: new Date().toISOString(), - tool_name: event.name, - tool_id: event.requestId, - parameters: event.args, + tool_name: event.value.name, + tool_id: event.value.callId, + parameters: event.value.args, }); } - break; - } - case 'tool_response': { - textOutput.ensureTrailingNewline(); - if (streamFormatter) { - const displayText = getTextContent(event.displayContent); - const errorMsg = getTextContent(event.content) ?? 'Tool error'; - streamFormatter.emitEvent({ - type: JsonStreamEventType.TOOL_RESULT, - timestamp: new Date().toISOString(), - tool_id: event.requestId, - status: event.isError ? 'error' : 'success', - output: displayText, - error: event.isError - ? { - type: - typeof event.data?.['errorType'] === 'string' - ? event.data['errorType'] - : 'TOOL_EXECUTION_ERROR', - message: errorMsg, - } - : undefined, - }); - } - if (event.isError) { - const displayText = getTextContent(event.displayContent); - const errorMsg = getTextContent(event.content) ?? 'Tool error'; - - if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) { - if ( - config.getOutputFormat() === OutputFormat.JSON && - !responseText && - preToolResponseText - ) { - responseText = preToolResponseText; - } - const stopMessage = `Agent execution stopped: ${errorMsg}`; - if (config.getOutputFormat() === OutputFormat.TEXT) { - process.stderr.write(`${stopMessage}\n`); - } - } - - if (event.data?.['errorType'] === ToolErrorType.NO_SPACE_LEFT) { - terminalProcessExitHandled = true; - handleToolError( - event.name, - new Error(errorMsg), - config, - typeof event.data?.['errorType'] === 'string' - ? event.data['errorType'] - : undefined, - displayText, - ); - return; - } - handleToolError( - event.name, - new Error(errorMsg), - config, - typeof event.data?.['errorType'] === 'string' - ? event.data['errorType'] - : undefined, - displayText, - ); - } - break; - } - case 'error': { - if (event.fatal) { - throw reconstructFatalError(event); - } - - const errorCode = event._meta?.['code']; - - 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(`[WARNING] ${event.message}\n`); - } + toolCallRequests.push(event.value); + } else if (event.type === GeminiEventType.LoopDetected) { if (streamFormatter) { streamFormatter.emitEvent({ type: JsonStreamEventType.ERROR, timestamp: new Date().toISOString(), - severity, - message: event.message, + severity: 'warning', + message: 'Loop detected, stopping execution', }); } - break; + } 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`); + } } - case 'agent_end': { - if (event.reason === 'aborted') { - runTerminalExitHandler(() => handleCancellationError(config)); - } else if (event.reason === 'max_turns') { - const isConfiguredTurnLimit = - typeof event.data?.['maxTurns'] === 'number' || - typeof event.data?.['turnCount'] === 'number'; + } - if (isConfiguredTurnLimit) { - runTerminalExitHandler(() => - handleMaxTurnsExceededError(config), - ); - } else if (streamFormatter) { - streamFormatter.emitEvent({ - type: JsonStreamEventType.ERROR, - timestamp: new Date().toISOString(), - severity: 'error', - message: 'Maximum session turns exceeded', - }); - } + 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; + + if (streamFormatter) { + 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 + ? { + type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + message: toolResponse.error.message, + } + : undefined, + }); } - const stopMessage = - typeof event.data?.['message'] === 'string' - ? event.data['message'] - : ''; - if (stopMessage && config.getOutputFormat() === OutputFormat.TEXT) { - process.stderr.write(`Agent execution stopped: ${stopMessage}\n`); + if (toolResponse.error) { + handleToolError( + requestInfo.name, + toolResponse.error, + config, + toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, + ); } - emitFinalSuccessResult(); - streamEnded = true; - break; + if (toolResponse.responseParts) { + toolResponseParts.push(...toolResponse.responseParts); + } } - case 'initialize': - case 'session_update': - case 'agent_start': - case 'tool_update': - case 'elicitation_request': - case 'elicitation_response': - case 'usage': - // TODO: We should think about converting this into the usage event. - // fallthrough - case 'custom': - // Explicitly ignore these non-interactive events - break; - default: - debugLogger.error('Unknown agent event type:', event); - event satisfies never; - break; + + // Record tool calls with full metadata before sending responses to Gemini + try { + const currentModel = + geminiClient.getCurrentSequenceModel() ?? config.getModel(); + geminiClient + .getChat() + .recordCompletedToolCalls(currentModel, completedToolCalls); + + await recordToolCallInteractions(config, completedToolCalls); + } catch (error) { + debugLogger.error( + `Error recording completed tool call information: ${error}`, + ); + } + + // 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 (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`${stopMessage}\n`); + } + + // 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 + } + return; + } + + 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 + } + return; } } } catch (error) { @@ -630,16 +527,12 @@ export async function runNonInteractive( } finally { // Cleanup stdin cancellation before other cleanup cleanupStdinCancellation(); - abortController.signal.removeEventListener('abort', abortSession); consolePatcher.cleanup(); coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); } if (errorToHandle) { - if (terminalProcessExitHandled) { - throw errorToHandle; - } handleError(errorToHandle, config); } }); diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts index 78fc18be4e..fe5fbceba2 100644 --- a/packages/cli/src/nonInteractiveCliAgentSession.ts +++ b/packages/cli/src/nonInteractiveCliAgentSession.ts @@ -37,6 +37,7 @@ import { LegacyAgentSession, ToolErrorType, geminiPartsToContentParts, + debugLogger, } from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; @@ -599,6 +600,7 @@ export async function runNonInteractive({ // Explicitly ignore these non-interactive events break; default: + debugLogger.error('Unknown agent event type:', event); event satisfies never; break; } diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index cdc060d9d7..a630cb690b 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -11,6 +11,17 @@ Enter to submit · Esc to cancel " `; +exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 2`] = ` +"Select your preferred language: + +● 1. TypeScript + 2. JavaScript + 3. Enter a custom value + +Enter to select · ↑/↓ to navigate · Esc to cancel +" +`; + exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = ` "Select your preferred language: @@ -22,6 +33,17 @@ Enter to submit · Esc to cancel " `; +exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 2`] = ` +"Select your preferred language: + +● 1. TypeScript + 2. JavaScript + 3. Type another language... + +Enter to select · ↑/↓ to navigate · Esc to cancel +" +`; + exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = ` "Choose an option @@ -181,6 +203,19 @@ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel " `; +exports[`AskUserDialog > shows warning for unanswered questions on Review tab 2`] = ` +"← □ License │ □ README │ ≡ Review → + +Which license? + +● 1. MIT + Permissive license + 2. Enter a custom value + +Enter to select · ←/→ to switch questions · Esc to cancel +" +`; + exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 1`] = ` "Which features? (Select all that apply) diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap index 073c106ceb..6dab42cb2b 100644 --- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -140,6 +140,33 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; +exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` +"Overview + +Add user authentication to the CLI application. + +Implementation Steps + + 1. Create src/auth/AuthService.ts with login/logout methods + 2. Add session storage in src/storage/SessionStore.ts + 3. Update src/commands/index.ts to check auth status + 4. Add tests in src/auth/__tests__/ + +Files to Modify + + - src/index.ts - Add auth middleware + - src/config.ts - Add auth configuration options + +● 1. Yes, automatically accept edits + Approves plan and allows tools to run automatically + 2. Yes, manually accept edits + Approves plan but requires confirmation for each tool + 3. Type your feedback... + +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel +" +`; + exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview diff --git a/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap index 18f5f93a9f..88cc58f86e 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap @@ -24,6 +24,11 @@ exports[`DenseToolMessage > flattens newlines in string results 1`] = ` " `; +exports[`DenseToolMessage > flattens newlines in string results 2`] = ` +" ✓ test-tool Test description → Line 1 Line 2 +" +`; + exports[`DenseToolMessage > renders correctly for Edit tool using confirmationDetails 1`] = ` " ? Edit styles.scss → Confirming " diff --git a/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap index fed8b32bd0..2f3ac55790 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap @@ -151,6 +151,15 @@ exports[` > with useAlterna " `; +exports[` > with useAlternateBuffer = true > should handle diff with only header and no changes 2`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No changes detected. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + exports[` > with useAlternateBuffer = true > should handle empty diff content 1`] = ` "No diff content. "