fix: restore nonInteractiveCli.ts and update AgentSession logic

- Reverted packages/cli/src/nonInteractiveCli.ts to the main branch state, restoring the fallback while(true) loop behavior.
- Added debugLogger.error call for unknown event types in the AgentSession event loop.
This commit is contained in:
Michael Bleigh
2026-04-03 13:32:56 -07:00
parent 8a00e41ba8
commit 9b40e96f80
6 changed files with 293 additions and 322 deletions
+215 -322
View File
@@ -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);
}
});
@@ -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;
}
@@ -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)
@@ -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
@@ -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
"
@@ -151,6 +151,15 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
"
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should handle diff with only header and no changes 2`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No changes detected. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should handle empty diff content 1`] = `
"No diff content.
"