Compare commits

..

1 Commits

Author SHA1 Message Date
Coco Sheng 80ff4bd39c feat: implement alternate buffer toggling and unit tests 2026-03-30 11:40:52 -04:00
31 changed files with 515 additions and 1595 deletions
-1
View File
@@ -988,7 +988,6 @@ export async function loadCliConfig(
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
toolOutputMasking: settings.experimental?.toolOutputMasking,
useAgentProtocol: settings.experimental?.useAgentProtocol,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
-10
View File
@@ -2108,16 +2108,6 @@ const SETTINGS_SCHEMA = {
'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.',
showInDialog: false,
},
useAgentProtocol: {
type: 'boolean',
label: 'Use Agent Protocol',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable the experimental unified agent for interactive mode.',
showInDialog: false,
},
gemmaModelRouter: {
type: 'object',
label: 'Gemma Model Router',
+22 -246
View File
@@ -77,8 +77,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
uiTelemetryService: {
getMetrics: vi.fn(),
},
LegacyAgentSession: original.LegacyAgentSession,
geminiPartsToContentParts: original.geminiPartsToContentParts,
coreEvents: mockCoreEvents,
createWorkingStdio: vi.fn(() => ({
stdout: process.stdout,
@@ -110,8 +108,6 @@ describe('runNonInteractive', () => {
sendMessageStream: Mock;
resumeChat: Mock;
getChatRecordingService: Mock;
getChat: Mock;
getCurrentSequenceModel: Mock;
};
const MOCK_SESSION_METRICS: SessionMetrics = {
models: {},
@@ -167,8 +163,6 @@ describe('runNonInteractive', () => {
recordMessageTokens: vi.fn(),
recordToolCalls: vi.fn(),
})),
getChat: vi.fn(() => ({ recordCompletedToolCalls: vi.fn() })),
getCurrentSequenceModel: vi.fn().mockReturnValue(null),
};
mockConfig = {
@@ -274,54 +268,6 @@ describe('runNonInteractive', () => {
// so we no longer expect shutdownTelemetry to be called directly here
});
it('should stream the specific stream started by send', async () => {
const { LegacyAgentSession } = await import('@google/gemini-cli-core');
const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream');
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Hello again' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Test input',
prompt_id: 'prompt-id-stream',
});
expect(streamSpy).toHaveBeenCalledWith({ streamId: expect.any(String) });
});
it('fails fast if the session acknowledges a message send without a stream', async () => {
const { LegacyAgentSession } = await import('@google/gemini-cli-core');
const sendSpy = vi
.spyOn(LegacyAgentSession.prototype, 'send')
.mockResolvedValue({ streamId: null });
const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream');
await expect(
runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Test input',
prompt_id: 'prompt-id-null-stream',
}),
).rejects.toThrow(
'LegacyAgentSession.send() unexpectedly returned no stream for a message send.',
);
expect(streamSpy).not.toHaveBeenCalled();
sendSpy.mockRestore();
streamSpy.mockRestore();
});
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => {
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl');
const events: ServerGeminiStreamEvent[] = [
@@ -612,7 +558,7 @@ describe('runNonInteractive', () => {
input: 'Initial fail',
prompt_id: 'prompt-id-4',
}),
).rejects.toThrow('API connection failed');
).rejects.toThrow(apiError);
});
it('should not exit if a tool is not found, and should send error back to model', async () => {
@@ -876,79 +822,6 @@ describe('runNonInteractive', () => {
);
});
it('should keep only the final post-tool assistant text in JSON output', async () => {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'testTool',
args: { arg1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-id-json-tool-text',
},
};
mockSchedulerSchedule.mockResolvedValue([
{
status: CoreToolCallStatus.Success,
request: toolCallEvent.value,
tool: {} as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
response: {
responseParts: [{ text: 'Tool executed successfully' }],
callId: 'tool-1',
error: undefined,
errorType: undefined,
contentLength: undefined,
},
},
]);
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(
createStreamFromEvents([
{ type: GeminiEventType.Content, value: 'Let me check that...' },
toolCallEvent,
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
},
]),
)
.mockReturnValueOnce(
createStreamFromEvents([
{ type: GeminiEventType.Content, value: 'Final answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
},
]),
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
MOCK_SESSION_METRICS,
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Use a tool',
prompt_id: 'prompt-id-json-tool-text',
});
expect(processStdoutSpy).toHaveBeenCalledWith(
JSON.stringify(
{
session_id: 'test-session-id',
response: 'Final answer',
stats: MOCK_SESSION_METRICS,
},
null,
2,
),
);
});
it('should write JSON output with stats for empty response commands', async () => {
// Test the scenario where a command completes but produces no content at all
const events: ServerGeminiStreamEvent[] = [
@@ -1195,11 +1068,10 @@ describe('runNonInteractive', () => {
// Spy on handleCancellationError to verify it's called
const errors = await import('./utils/errors.js');
const cancellationSentinel = new Error('Cancelled');
const handleCancellationErrorSpy = vi
.spyOn(errors, 'handleCancellationError')
.mockImplementation(() => {
throw cancellationSentinel;
throw new Error('Cancelled');
});
const events: ServerGeminiStreamEvent[] = [
@@ -1215,7 +1087,7 @@ describe('runNonInteractive', () => {
signal.addEventListener('abort', () => {
clearTimeout(timeout);
setTimeout(() => {
reject(new Error('Aborted'));
reject(new Error('Aborted')); // This will be caught by nonInteractiveCli and passed to handleError
}, 300);
});
});
@@ -1248,10 +1120,20 @@ describe('runNonInteractive', () => {
keypressHandler('\u0003', { ctrl: true, name: 'c' });
}
// The Ctrl+C path should route through handleCancellationError rather than
// surfacing the raw stream abort.
await expect(runPromise).rejects.toBe(cancellationSentinel);
expect(handleCancellationErrorSpy).toHaveBeenCalledTimes(1);
// The promise should reject with 'Aborted' because our mock stream throws it,
// and nonInteractiveCli catches it and calls handleError, which doesn't necessarily throw.
// Wait, if handleError is called, we should check that.
// But here we want to check if Ctrl+C works.
// In our current setup, Ctrl+C aborts the signal. The stream throws 'Aborted'.
// nonInteractiveCli catches 'Aborted' and calls handleError.
// If we want to test that handleCancellationError is called, we need the loop to detect abortion.
// But our stream throws before the loop can detect it.
// Let's just check that the promise rejects with 'Aborted' for now,
// which proves the abortion signal reached the stream.
await expect(runPromise).rejects.toThrow('Aborted');
expect(
processStderrSpy.mock.calls.some(
@@ -1278,78 +1160,6 @@ describe('runNonInteractive', () => {
// but we can also do it manually if needed.
});
it('should honor cancellation that happens before session.send()', async () => {
const originalIsTTY = process.stdin.isTTY;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalSetRawMode = (process.stdin as any).setRawMode;
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
if (!originalSetRawMode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).setRawMode = vi.fn();
}
const stdinOnSpy = vi
.spyOn(process.stdin, 'on')
.mockImplementation(
(event: string | symbol, listener: (...args: unknown[]) => void) => {
if (event === 'keypress') {
listener('\u0003', { ctrl: true, name: 'c' });
}
return process.stdin;
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true);
vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation(
() => process.stdin,
);
const errors = await import('./utils/errors.js');
const cancellationSentinel = new Error('Cancelled before send');
const handleCancellationErrorSpy = vi
.spyOn(errors, 'handleCancellationError')
.mockImplementation(() => {
throw cancellationSentinel;
});
const { LegacyAgentSession } = await import('@google/gemini-cli-core');
const sendSpy = vi.spyOn(LegacyAgentSession.prototype, 'send');
await expect(
runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Cancelled query',
prompt_id: 'prompt-id-pre-send-cancel',
}),
).rejects.toBe(cancellationSentinel);
expect(handleCancellationErrorSpy).toHaveBeenCalledTimes(1);
expect(sendSpy).not.toHaveBeenCalled();
expect(stdinOnSpy).toHaveBeenCalled();
handleCancellationErrorSpy.mockRestore();
sendSpy.mockRestore();
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
if (originalSetRawMode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).setRawMode = originalSetRawMode;
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (process.stdin as any).setRawMode;
}
});
it('should throw FatalInputError if a command requires confirmation', async () => {
const mockCommand = {
name: 'confirm',
@@ -1519,9 +1329,6 @@ describe('runNonInteractive', () => {
name: 'ShellTool',
description: 'A shell tool',
run: vi.fn(),
build: vi.fn().mockReturnValue({
getDescription: () => 'A shell tool',
}),
}),
getFunctionDeclarations: vi.fn().mockReturnValue([{ name: 'ShellTool' }]),
} as unknown as ToolRegistry);
@@ -1970,7 +1777,9 @@ 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');
@@ -2191,6 +2000,7 @@ describe('runNonInteractive', () => {
expect(processStderrSpy).toHaveBeenCalledWith(
'Agent execution stopped: Stopped by hook\n',
);
// Should exit without calling sendMessageStream again
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
@@ -2221,9 +2031,9 @@ describe('runNonInteractive', () => {
expect(processStderrSpy).toHaveBeenCalledWith(
'[WARNING] Agent execution blocked: Blocked by hook\n',
);
// Stream continues after blocked event — content should be output
expect(getWrittenOutput()).toBe('Final answer\n');
// sendMessageStream is called once, recursion is internal to it and transparent to the caller
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
expect(getWrittenOutput()).toBe('Final answer\n');
});
});
@@ -2364,40 +2174,6 @@ describe('runNonInteractive', () => {
);
});
it('should emit warning event for loop_detected 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,
+215 -320
View File
@@ -6,40 +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,
} 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';
@@ -158,6 +151,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
}
};
@@ -188,8 +183,6 @@ export async function runNonInteractive({
};
let errorToHandle: unknown | undefined;
let terminalProcessExitHandled = false;
let abortSession = () => {};
try {
consolePatcher.patch();
@@ -254,6 +247,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[];
@@ -271,6 +267,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.',
);
@@ -289,334 +287,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: () => void | never,
): void | 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':
case 'custom':
// Explicitly ignore these non-interactive events
break;
default:
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) {
@@ -624,16 +523,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);
}
});
+1
View File
@@ -534,6 +534,7 @@ export const mockAppState: AppState = {
};
const mockUIActions: UIActions = {
toggleAlternateBuffer: vi.fn(),
handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(),
handleThemeHighlight: vi.fn(),
+80 -45
View File
@@ -68,8 +68,10 @@ import {
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
startupProfiler,
SessionStartSource,
@@ -82,7 +84,6 @@ import {
buildUserSteeringHintPrompt,
logBillingEvent,
ApiKeyUpdatedEvent,
LegacyAgentProtocol,
type InjectionSource,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
@@ -111,7 +112,6 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useAgentStream } from './hooks/useAgentStream.js';
import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
@@ -215,7 +215,7 @@ export const AppContainer = (props: AppContainerProps) => {
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -1093,46 +1093,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config]);
const streamAgent = useMemo(
() =>
config?.getExperimentalUseAgentProtocol()
? new LegacyAgentProtocol({ config, getPreferredEditor })
: undefined,
[config, getPreferredEditor],
);
const activeStream = streamAgent
? // eslint-disable-next-line react-hooks/rules-of-hooks
useAgentStream({
agent: streamAgent,
addItem: historyManager.addItem,
onCancelSubmit,
isShellFocused: embeddedShellFocused,
logger,
})
: // eslint-disable-next-line react-hooks/rules-of-hooks
useGeminiStream(
config.getGeminiClient(),
historyManager.history,
historyManager.addItem,
config,
settings,
setDebugMessage,
handleSlashCommand,
shellModeActive,
getPreferredEditor,
onAuthError,
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
onCancelSubmit,
setEmbeddedShellFocused,
terminalWidth,
terminalHeight,
embeddedShellFocused,
consumePendingHints,
);
const {
streamingState,
submitQuery,
@@ -1152,7 +1112,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundTasks,
dismissBackgroundTask,
retryStatus,
} = activeStream;
} = useGeminiStream(
config.getGeminiClient(),
historyManager.history,
historyManager.addItem,
config,
settings,
setDebugMessage,
handleSlashCommand,
shellModeActive,
getPreferredEditor,
onAuthError,
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
onCancelSubmit,
setEmbeddedShellFocused,
terminalWidth,
terminalHeight,
embeddedShellFocused,
consumePendingHints,
);
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1572,6 +1552,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
useEffect(() => {
if (isAlternateBuffer) return;
const isLongHistory = historyManager.history.length > 15;
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
showTransientMessage({
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
type: TransientMessageType.Hint
});
setShownBufferToggleHint(true);
}
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
const {
isFolderTrustDialogOpen,
discoveryResults: folderDiscoveryResults,
@@ -1722,10 +1719,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
toggleAlternateBuffer();
return true;
}
if (keyMatchers[Command.QUIT](key)) {
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
// This should happen regardless of the count.
void cancelOngoingRequest?.();
cancelOngoingRequest?.();
handleCtrlCPress();
return true;
@@ -2226,8 +2228,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config, refreshStatic]);
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
const uiState: UIState = useMemo(
() => ({
isAlternateBuffer,
history: historyManager.history,
historyManager,
isThemeDialogOpen,
@@ -2353,6 +2358,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
hintMode:
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
hintBuffer: '',
@@ -2479,6 +2485,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
isAlternateBuffer,
],
);
@@ -2487,6 +2495,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
[setShowPrivacyNotice],
);
const toggleAlternateBuffer = useCallback(() => {
setIsAlternateBuffer(prev => {
const next = !prev;
if (next) {
enterAlternateScreen();
enableMouseEvents();
disableLineWrapping();
} else {
exitAlternateScreen();
disableMouseEvents();
enableLineWrapping();
writeToStdout('\x1b[2J\x1b[H');
}
process.stdout.emit('resize');
// Give a tick for resize to process, then trigger remount to force full redraw
setImmediate(() => {
refreshStatic();
setForceRerenderKey((prev) => prev + 1);
});
return next;
});
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
const uiActions: UIActions = useMemo(
() => ({
handleThemeSelect,
@@ -2498,6 +2531,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleEditorSelect,
exitEditorDialog,
exitPrivacyNotice,
toggleAlternateBuffer,
closeSettingsDialog,
closeModelDialog,
openAgentConfigDialog,
@@ -2636,6 +2670,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
config,
historyManager,
getPreferredEditor,
toggleAlternateBuffer,
],
);
@@ -142,4 +142,38 @@ describe('<StatusRow />', () => {
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
showIsAlternateBufferHint: true,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
});
});
+6 -1
View File
@@ -206,7 +206,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return uiState.currentTip;
}
// 2. Shortcut Hint (Fallback)
// 2. Buffer Toggle Hint
if (uiState.showIsAlternateBufferHint) {
return '[Alt+T] Switch to Full Screen';
}
// 3. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
@@ -205,6 +205,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
'\u2202': 'd', // "∂" delete word forward
'\u2020': 't', // "†" toggle full screen buffer
'\u00E5': 'a', // "å" Option+a for alternate buffer
};
function nonKeyboardEventFilter(
@@ -78,6 +78,7 @@ export interface UIActions {
setShortcutsHelpVisible: (visible: boolean) => void;
setCleanUiDetailsVisible: (visible: boolean) => void;
toggleCleanUiDetailsVisible: () => void;
toggleAlternateBuffer: () => void;
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
@@ -118,6 +118,8 @@ export interface UIState {
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
corgiMode: boolean;
isAlternateBuffer: boolean;
showIsAlternateBufferHint: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
@@ -1,207 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import type { LegacyAgentProtocol } from '@google/gemini-cli-core';
import { renderHookWithProviders } from '../../test-utils/render.js';
// --- MOCKS ---
const mockLegacyAgentProtocol = vi.hoisted(() => ({
send: vi.fn().mockResolvedValue({ streamId: 'test-stream-id' }),
subscribe: vi.fn().mockReturnValue(() => {}),
abort: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
useSessionStats: vi.fn(() => ({
startNewPrompt: vi.fn(),
})),
};
});
// --- END MOCKS ---
import { useAgentStream } from './useAgentStream.js';
import { MessageType, StreamingState } from '../types.js';
describe('useAgentStream', () => {
const mockAddItem = vi.fn();
const mockOnCancelSubmit = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize on mount', async () => {
await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
expect(mockLegacyAgentProtocol.subscribe).toHaveBeenCalled();
});
it('should call agent.send when submitQuery is called', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
await act(async () => {
await result.current.submitQuery('hello');
});
expect(mockLegacyAgentProtocol.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'hello' }] },
});
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.USER, text: 'hello' }),
expect.any(Number),
);
});
it('should update streamingState based on agent_start and agent_end events', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
.calls[0][0];
expect(result.current.streamingState).toBe(StreamingState.Idle);
act(() => {
eventHandler({
type: 'agent_start',
id: '1',
timestamp: '',
streamId: '',
});
});
expect(result.current.streamingState).toBe(StreamingState.Responding);
act(() => {
eventHandler({
type: 'agent_end',
reason: 'completed',
id: '2',
timestamp: '',
streamId: '',
});
});
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should accumulate text content and update pendingHistoryItems', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
.calls[0][0];
act(() => {
eventHandler({
type: 'message',
role: 'agent',
content: [{ type: 'text', text: 'Hello' }],
id: '1',
timestamp: '',
streamId: '',
});
});
expect(result.current.pendingHistoryItems).toHaveLength(1);
expect(result.current.pendingHistoryItems[0]).toMatchObject({
type: 'gemini',
text: 'Hello',
});
act(() => {
eventHandler({
type: 'message',
role: 'agent',
content: [{ type: 'text', text: ' world' }],
id: '2',
timestamp: '',
streamId: '',
});
});
expect(result.current.pendingHistoryItems[0].text).toBe('Hello world');
});
it('should process thought events and update thought state', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
.calls[0][0];
act(() => {
eventHandler({
type: 'message',
role: 'agent',
content: [{ type: 'thought', thought: '**Thinking** about tests' }],
id: '1',
timestamp: '',
streamId: '',
});
});
expect(result.current.thought).toEqual({
subject: 'Thinking',
description: 'about tests',
});
});
it('should call agent.abort when cancelOngoingRequest is called', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
await act(async () => {
await result.current.cancelOngoingRequest();
});
expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled();
expect(mockOnCancelSubmit).toHaveBeenCalledWith(false);
});
});
-505
View File
@@ -1,505 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import {
getErrorMessage,
MessageSenderType,
debugLogger,
geminiPartsToContentParts,
parseThought,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import {
type ApprovalMode,
Kind,
type ThoughtSummary,
type RetryAttemptPayload,
type AgentEvent,
type AgentProtocol,
type Logger,
} from '@google/gemini-cli-core';
import type {
HistoryItemWithoutId,
LoopDetectionConfirmationRequest,
IndividualToolCallDisplay,
HistoryItemToolGroup,
} from '../types.js';
import { StreamingState, MessageType } from '../types.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
import { type BackgroundTask } from './useExecutionLifecycle.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useStateAndRef } from './useStateAndRef.js';
import { type MinimalTrackedToolCall } from './useTurnActivityMonitor.js';
export interface UseAgentStreamOptions {
agent?: AgentProtocol;
addItem: UseHistoryManagerReturn['addItem'];
onCancelSubmit: (shouldRestorePrompt?: boolean) => void;
isShellFocused?: boolean;
logger?: Logger | null;
}
/**
* useAgentStream implements the interactive agent loop using an AgentProtocol.
* It is completely agnostic to the specific agent implementation.
*/
export const useAgentStream = ({
agent,
addItem,
onCancelSubmit,
isShellFocused,
logger,
}: UseAgentStreamOptions) => {
const [initError] = useState<string | null>(null);
const [retryStatus] = useState<RetryAttemptPayload | null>(null);
const [streamingState, setStreamingState] = useState<StreamingState>(
StreamingState.Idle,
);
const [thought, setThought] = useState<ThoughtSummary | null>(null);
const currentStreamIdRef = useRef<string | null>(null);
const userMessageTimestampRef = useRef<number>(0);
const geminiMessageBufferRef = useRef<string>('');
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const [trackedTools, , setTrackedTools] = useStateAndRef<
IndividualToolCallDisplay[]
>([]);
const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
useStateAndRef<Set<string>>(new Set());
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const { startNewPrompt } = useSessionStats();
const activePtyId = undefined;
const backgroundTaskCount = 0;
const isBackgroundTaskVisible = false;
const toggleBackgroundTasks = useCallback(() => {}, []);
const backgroundCurrentExecution = undefined;
const backgroundTasks = useMemo(() => new Map<number, BackgroundTask>(), []);
const dismissBackgroundTask = useCallback(async (_pid: number) => {}, []);
// Use the trackedTools to mock pendingToolCalls for inactivity monitors
const pendingToolCalls = useMemo(
(): MinimalTrackedToolCall[] =>
trackedTools.map((t) => ({
request: {
name: t.originalRequestName || t.name,
args: { command: t.description },
callId: t.callId,
isClientInitiated: t.isClientInitiated ?? false,
prompt_id: '',
},
status: t.status,
})),
[trackedTools],
);
const lastOutputTime = Date.now(); // We could track actual time if needed, simplified for now
// TODO: Support LoopDetection confirmation requests
const [loopDetectionConfirmationRequest] =
useState<LoopDetectionConfirmationRequest | null>(null);
const flushPendingText = useCallback(() => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestampRef.current);
setPendingHistoryItem(null);
geminiMessageBufferRef.current = '';
}
}, [addItem, pendingHistoryItemRef, setPendingHistoryItem]);
const cancelOngoingRequest = useCallback(async () => {
if (agent) {
await agent.abort();
setStreamingState(StreamingState.Idle);
onCancelSubmit(false);
}
}, [agent, onCancelSubmit]);
// TODO: Support native handleApprovalModeChange for Plan Mode
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
debugLogger.debug(`Approval mode changed to ${newApprovalMode} (stub)`);
},
[],
);
const handleEvent = useCallback(
(event: AgentEvent) => {
switch (event.type) {
case 'agent_start':
setStreamingState(StreamingState.Responding);
break;
case 'agent_end':
setStreamingState(StreamingState.Idle);
flushPendingText();
break;
case 'message':
if (event.role === 'agent') {
for (const part of event.content) {
if (part.type === 'text') {
geminiMessageBufferRef.current += part.text;
// Update pending history item with incremental text
const splitPoint = findLastSafeSplitPoint(
geminiMessageBufferRef.current,
);
if (splitPoint === geminiMessageBufferRef.current.length) {
setPendingHistoryItem({
type: 'gemini',
text: geminiMessageBufferRef.current,
});
} else {
const before = geminiMessageBufferRef.current.substring(
0,
splitPoint,
);
const after =
geminiMessageBufferRef.current.substring(splitPoint);
addItem(
{ type: 'gemini', text: before },
userMessageTimestampRef.current,
);
geminiMessageBufferRef.current = after;
setPendingHistoryItem({
type: 'gemini_content',
text: after,
});
}
} else if (part.type === 'thought') {
setThought(parseThought(part.thought));
}
}
}
break;
case 'tool_request': {
flushPendingText();
const legacyState = event._meta?.legacyState;
const displayName = legacyState?.displayName ?? event.name;
const isOutputMarkdown = legacyState?.isOutputMarkdown ?? false;
const desc = legacyState?.description ?? '';
const fallbackKind = Kind.Other;
const newCall: IndividualToolCallDisplay = {
callId: event.requestId,
name: displayName,
originalRequestName: event.name,
description: desc,
status: CoreToolCallStatus.Scheduled,
isClientInitiated: false,
renderOutputAsMarkdown: isOutputMarkdown,
kind: legacyState?.kind ?? fallbackKind,
confirmationDetails: undefined,
resultDisplay: undefined,
};
setTrackedTools((prev) => [...prev, newCall]);
break;
}
case 'tool_update': {
setTrackedTools((prev) =>
prev.map((tc): IndividualToolCallDisplay => {
if (tc.callId !== event.requestId) return tc;
const legacyState = event._meta?.legacyState;
const evtStatus = legacyState?.status;
let status = tc.status;
if (evtStatus === 'executing')
status = CoreToolCallStatus.Executing;
else if (evtStatus === 'error') status = CoreToolCallStatus.Error;
else if (evtStatus === 'success')
status = CoreToolCallStatus.Success;
const liveOutput =
event.displayContent?.[0]?.type === 'text'
? event.displayContent[0].text
: tc.resultDisplay;
const progressMessage =
legacyState?.progressMessage ?? tc.progressMessage;
const progress = legacyState?.progress ?? tc.progress;
const progressTotal =
legacyState?.progressTotal ?? tc.progressTotal;
const ptyId = legacyState?.pid ?? tc.ptyId;
const description = legacyState?.description ?? tc.description;
return {
...tc,
status,
resultDisplay: liveOutput,
progressMessage,
progress,
progressTotal,
ptyId,
description,
};
}),
);
break;
}
case 'tool_response': {
setTrackedTools((prev) =>
prev.map((tc): IndividualToolCallDisplay => {
if (tc.callId !== event.requestId) return tc;
const legacyState = event._meta?.legacyState;
const outputFile = legacyState?.outputFile;
const resultDisplay =
event.displayContent?.[0]?.type === 'text'
? event.displayContent[0].text
: tc.resultDisplay;
return {
...tc,
status: event.isError
? CoreToolCallStatus.Error
: CoreToolCallStatus.Success,
resultDisplay,
outputFile,
};
}),
);
break;
}
case 'error':
addItem(
{ type: MessageType.ERROR, text: event.message },
userMessageTimestampRef.current,
);
break;
default:
break;
}
},
[addItem, flushPendingText, setPendingHistoryItem, setTrackedTools],
);
useEffect(() => {
if (agent) {
return agent.subscribe(handleEvent);
}
return undefined;
}, [agent, handleEvent]);
const submitQuery = useCallback(
async (
query: Array<import('@google/gemini-cli-core').Part> | string,
options?: { isContinuation: boolean },
_prompt_id?: string,
) => {
if (!agent) return;
const timestamp = Date.now();
userMessageTimestampRef.current = timestamp;
geminiMessageBufferRef.current = '';
if (!options?.isContinuation) {
if (typeof query === 'string') {
addItem({ type: MessageType.USER, text: query }, timestamp);
void logger?.logMessage(MessageSenderType.USER, query);
}
startNewPrompt();
}
const parts = geminiPartsToContentParts(
typeof query === 'string' ? [{ text: query }] : query,
);
try {
const { streamId } = await agent.send({
message: { content: parts },
});
currentStreamIdRef.current = streamId;
} catch (err) {
addItem(
{ type: MessageType.ERROR, text: getErrorMessage(err) },
timestamp,
);
}
},
[agent, addItem, logger, startNewPrompt],
);
useEffect(() => {
if (trackedTools.length > 0) {
const isNewBatch = !trackedTools.some((tc) =>
pushedToolCallIdsRef.current.has(tc.callId),
);
if (isNewBatch) {
setPushedToolCallIds(new Set());
setIsFirstToolInGroup(true);
}
} else if (streamingState === StreamingState.Idle) {
setPushedToolCallIds(new Set());
setIsFirstToolInGroup(true);
}
}, [
trackedTools,
pushedToolCallIdsRef,
setPushedToolCallIds,
setIsFirstToolInGroup,
streamingState,
]);
// Push completed tools to history
useEffect(() => {
const toolsToPush: IndividualToolCallDisplay[] = [];
for (let i = 0; i < trackedTools.length; i++) {
const tc = trackedTools[i];
if (pushedToolCallIdsRef.current.has(tc.callId)) continue;
if (
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
) {
toolsToPush.push(tc);
} else {
break;
}
}
if (toolsToPush.length > 0) {
const newPushed = new Set(pushedToolCallIdsRef.current);
for (const tc of toolsToPush) {
newPushed.add(tc.callId);
}
const isLastInBatch =
toolsToPush[toolsToPush.length - 1] ===
trackedTools[trackedTools.length - 1];
const appearance = getToolGroupBorderAppearance(
{ type: 'tool_group', tools: trackedTools },
activePtyId,
!!isShellFocused,
[],
backgroundTasks,
);
const historyItem: HistoryItemToolGroup = {
type: 'tool_group',
tools: toolsToPush,
borderTop: isFirstToolInGroupRef.current,
borderBottom: isLastInBatch,
...appearance,
};
addItem(historyItem);
setPushedToolCallIds(newPushed);
setIsFirstToolInGroup(false);
}
}, [
trackedTools,
pushedToolCallIdsRef,
isFirstToolInGroupRef,
setPushedToolCallIds,
setIsFirstToolInGroup,
addItem,
activePtyId,
isShellFocused,
backgroundTasks,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
const remainingTools = trackedTools.filter(
(tc) => !pushedToolCallIds.has(tc.callId),
);
const items: HistoryItemWithoutId[] = [];
const appearance = getToolGroupBorderAppearance(
{ type: 'tool_group', tools: trackedTools },
activePtyId,
!!isShellFocused,
[],
backgroundTasks,
);
if (remainingTools.length > 0) {
items.push({
type: 'tool_group',
tools: remainingTools,
borderTop: pushedToolCallIds.size === 0,
borderBottom: false,
...appearance,
});
}
const allTerminal =
trackedTools.length > 0 &&
trackedTools.every(
(tc) =>
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled',
);
const allPushed =
trackedTools.length > 0 &&
trackedTools.every((tc) => pushedToolCallIds.has(tc.callId));
const anyVisibleInHistory = pushedToolCallIds.size > 0;
const anyVisibleInPending = remainingTools.length > 0;
if (
trackedTools.length > 0 &&
!(allTerminal && allPushed) &&
(anyVisibleInHistory || anyVisibleInPending)
) {
items.push({
type: 'tool_group' as const,
tools: [],
borderTop: false,
borderBottom: true,
...appearance,
});
}
return items;
}, [
trackedTools,
pushedToolCallIds,
activePtyId,
isShellFocused,
backgroundTasks,
]);
const pendingHistoryItems = useMemo(
() =>
[pendingHistoryItem, ...pendingToolGroupItems].filter(
(i): i is HistoryItemWithoutId => i !== undefined && i !== null,
),
[pendingHistoryItem, pendingToolGroupItems],
);
return {
streamingState,
submitQuery,
initError,
pendingHistoryItems,
thought,
cancelOngoingRequest,
pendingToolCalls,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
retryStatus,
dismissBackgroundTask,
};
};
@@ -11,49 +11,47 @@ import {
isAlternateBufferEnabled,
} from './useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: vi.fn(),
}));
vi.mock('../contexts/UIStateContext.js');
const mockUseConfig = vi.mocked(
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
);
const mockUseUIState = vi.mocked(useUIState);
describe('useAlternateBuffer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false when config.getUseAlternateBuffer returns false', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
it('should return false when uiState.isAlternateBuffer is false', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
});
it('should return true when config.getUseAlternateBuffer returns true', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>);
it('should return true when uiState.isAlternateBuffer is true', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(true);
});
it('should return the immutable config value, not react to settings changes', async () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
it('should react to state changes', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
const { result, rerender } = await renderHook(() => useAlternateBuffer());
// Value should remain true even after rerender
expect(result.current).toBe(true);
expect(result.current).toBe(false);
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
rerender();
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
// This is read from Config so that the UI reads the same value per application session
// This is read from UIState so that the UI can toggle dynamically
export const useAlternateBuffer = (): boolean => {
const config = useConfig();
return isAlternateBufferEnabled(config);
const uiState = useUIState();
return uiState.isAlternateBuffer;
};
@@ -5,22 +5,20 @@
*/
import { useInactivityTimer } from './useInactivityTimer.js';
import {
useTurnActivityMonitor,
type MinimalTrackedToolCall,
} from './useTurnActivityMonitor.js';
import { useTurnActivityMonitor } from './useTurnActivityMonitor.js';
import {
SHELL_FOCUS_HINT_DELAY_MS,
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
SHELL_SILENT_WORKING_TITLE_DELAY_MS,
} from '../constants.js';
import type { StreamingState } from '../types.js';
import { type TrackedToolCall } from './useToolScheduler.js';
interface ShellInactivityStatusProps {
activePtyId: number | string | null | undefined;
lastOutputTime: number;
streamingState: StreamingState;
pendingToolCalls: MinimalTrackedToolCall[];
pendingToolCalls: TrackedToolCall[];
embeddedShellFocused: boolean;
isInteractiveShellEnabled: boolean;
}
@@ -31,10 +31,9 @@ export type CancelAllFn = (signal: AbortSignal) => void;
* The shape expected by useGeminiStream.
* It matches the Core ToolCall structure + the UI metadata flag.
*/
export type Tracked<T> = T extends unknown
? T & { responseSubmittedToGemini?: boolean }
: never;
export type TrackedToolCall = Tracked<ToolCall>;
export type TrackedToolCall = ToolCall & {
responseSubmittedToGemini?: boolean;
};
// Narrowed types for specific statuses (used by useGeminiStream)
export type TrackedScheduledToolCall = Extract<
@@ -76,7 +75,6 @@ export function useToolScheduler(
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
CancelAllFn,
number,
Scheduler,
] {
// State stores tool calls organized by their originating schedulerId
const [toolCallsMap, setToolCallsMap] = useState<
@@ -259,7 +257,6 @@ export function useToolScheduler(
setToolCallsForDisplay,
cancelAll,
lastToolOutputTime,
scheduler,
];
}
@@ -6,16 +6,8 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { StreamingState } from '../types.js';
import {
hasRedirection,
type CoreToolCallStatus,
type ToolCallRequestInfo,
} from '@google/gemini-cli-core';
export interface MinimalTrackedToolCall {
status: CoreToolCallStatus;
request: ToolCallRequestInfo;
}
import { hasRedirection } from '@google/gemini-cli-core';
import { type TrackedToolCall } from './useToolScheduler.js';
export interface TurnActivityStatus {
operationStartTime: number;
@@ -29,7 +21,7 @@ export interface TurnActivityStatus {
export const useTurnActivityMonitor = (
streamingState: StreamingState,
activePtyId: number | string | null | undefined,
pendingToolCalls: MinimalTrackedToolCall[] = [],
pendingToolCalls: TrackedToolCall[] = [],
): TurnActivityStatus => {
const [operationStartTime, setOperationStartTime] = useState(0);
+3
View File
@@ -95,6 +95,7 @@ export enum Command {
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
// Background Shell Controls
BACKGROUND_SHELL_ESCAPE = 'background.escape',
@@ -392,6 +393,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -609,6 +611,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
+2 -5
View File
@@ -29,10 +29,7 @@ export function getToolGroupBorderAppearance(
item:
| HistoryItem
| HistoryItemWithoutId
| {
type: 'tool_group';
tools: Array<IndividualToolCallDisplay | TrackedToolCall>;
},
| { type: 'tool_group'; tools: TrackedToolCall[] },
activeShellPtyId: number | null | undefined,
embeddedShellFocused: boolean | undefined,
allPendingItems: HistoryItemWithoutId[] = [],
@@ -44,7 +41,7 @@ export function getToolGroupBorderAppearance(
// If this item has no tools, it's a closing slice for the current batch.
// We need to look at the last pending item to determine the batch's appearance.
const toolsToInspect =
const toolsToInspect: Array<IndividualToolCallDisplay | TrackedToolCall> =
item.tools.length > 0
? item.tools
: allPendingItems
+1 -1
View File
@@ -18,8 +18,8 @@ import {
isFatalToolError,
debugLogger,
coreEvents,
getErrorType,
getErrorMessage,
getErrorType,
} from '@google/gemini-cli-core';
import { runSyncCleanup } from './cleanup.js';
+7 -19
View File
@@ -7,19 +7,7 @@
import { describe, expect, it } from 'vitest';
import { AgentSession } from './agent-session.js';
import { MockAgentProtocol } from './mock.js';
import type { AgentEvent, AgentSend } from './types.js';
function makeMessageSend(
text: string,
displayContent?: string,
): Extract<AgentSend, { message: unknown }> {
return {
message: {
content: [{ type: 'text', text }],
...(displayContent ? { displayContent } : {}),
},
};
}
import type { AgentEvent } from './types.js';
describe('AgentSession', () => {
it('should passthrough simple methods', async () => {
@@ -63,7 +51,7 @@ describe('AgentSession', () => {
const events: AgentEvent[] = [];
for await (const event of session.sendStream({
...makeMessageSend('hi'),
message: [{ type: 'text', text: 'hi' }],
})) {
events.push(event);
}
@@ -151,7 +139,7 @@ describe('AgentSession', () => {
const events: AgentEvent[] = [];
for await (const event of session.sendStream({
...makeMessageSend('hi'),
message: [{ type: 'text', text: 'hi' }],
})) {
events.push(event);
}
@@ -190,7 +178,7 @@ describe('AgentSession', () => {
protocol.pushResponse([{ type: 'message' }]);
const { streamId } = await session.send({
...makeMessageSend('request'),
message: [{ type: 'text', text: 'request' }],
});
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -254,7 +242,7 @@ describe('AgentSession', () => {
},
]);
await session.send({
...makeMessageSend('request'),
message: [{ type: 'text', text: 'request' }],
});
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -315,7 +303,7 @@ describe('AgentSession', () => {
},
]);
const { streamId: streamId1 } = await session.send({
...makeMessageSend('first request'),
message: [{ type: 'text', text: 'first request' }],
});
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -327,7 +315,7 @@ describe('AgentSession', () => {
},
]);
await session.send({
...makeMessageSend('second request'),
message: [{ type: 'text', text: 'second request' }],
});
await new Promise((resolve) => setTimeout(resolve, 10));
@@ -679,7 +679,6 @@ describe('mapError', () => {
expect(result.status).toBe('RESOURCE_EXHAUSTED');
expect(result.message).toBe('Rate limit');
expect(result.fatal).toBe(true);
expect(result._meta?.['status']).toBe(429);
expect(result._meta?.['rawError']).toEqual({
message: 'Rate limit',
status: 429,
+1 -1
View File
@@ -403,7 +403,7 @@ export function mapError(
}
if (isStructuredError(error)) {
const structuredMeta = { ...meta, rawError: error, status: error.status };
const structuredMeta = { ...meta, rawError: error };
return {
status: mapHttpToGrpcStatus(error.status),
message: error.message,
@@ -10,16 +10,13 @@ import { LegacyAgentSession } from './legacy-agent-session.js';
import type { LegacyAgentSessionDeps } from './legacy-agent-session.js';
import { GeminiEventType } from '../core/turn.js';
import type { ServerGeminiStreamEvent } from '../core/turn.js';
import type { AgentEvent, AgentSend } from './types.js';
import type { AgentEvent } from './types.js';
import { ToolErrorType } from '../tools/tool-error.js';
import type {
CompletedToolCall,
ToolCallRequestInfo,
} from '../scheduler/types.js';
import { CoreToolCallStatus } from '../scheduler/types.js';
import type { GeminiClient } from '../core/client.js';
import type { Scheduler } from '../scheduler/scheduler.js';
import type { Config } from '../config/config.js';
// ---------------------------------------------------------------------------
// Mock helpers
@@ -27,7 +24,7 @@ import type { Config } from '../config/config.js';
function createMockDeps(
overrides?: Partial<LegacyAgentSessionDeps>,
): Required<LegacyAgentSessionDeps> {
): LegacyAgentSessionDeps {
const mockClient = {
sendMessageStream: vi.fn(),
getChat: vi.fn().mockReturnValue({
@@ -43,22 +40,18 @@ function createMockDeps(
const mockConfig = {
getMaxSessionTurns: vi.fn().mockReturnValue(-1),
getModel: vi.fn().mockReturnValue('gemini-2.5-pro'),
getGeminiClient: vi.fn().mockReturnValue(mockClient),
getMessageBus: vi.fn().mockImplementation(() => ({
subscribe: vi.fn(),
unsubscribe: vi.fn(),
})),
};
return {
client: mockClient as unknown as GeminiClient,
scheduler: mockScheduler as unknown as Scheduler,
config: mockConfig as unknown as Config,
client: mockClient as unknown as LegacyAgentSessionDeps['client'],
scheduler: mockScheduler as unknown as LegacyAgentSessionDeps['scheduler'],
config: mockConfig as unknown as LegacyAgentSessionDeps['config'],
promptId: 'test-prompt',
streamId: 'test-stream',
getPreferredEditor: vi.fn().mockReturnValue(undefined),
...overrides,
} as Required<LegacyAgentSessionDeps>;
};
}
async function* makeStream(
@@ -79,18 +72,6 @@ function makeToolRequest(callId: string, name: string): ToolCallRequestInfo {
};
}
function makeMessageSend(
text: string,
displayContent?: string,
): Extract<AgentSend, { message: unknown }> {
return {
message: {
content: [{ type: 'text', text }],
...(displayContent ? { displayContent } : {}),
},
};
}
function makeCompletedToolCall(
callId: string,
name: string,
@@ -136,7 +117,7 @@ async function collectEvents(
// ---------------------------------------------------------------------------
describe('LegacyAgentSession', () => {
let deps: Required<LegacyAgentSessionDeps>;
let deps: LegacyAgentSessionDeps;
beforeEach(() => {
deps = createMockDeps();
@@ -159,7 +140,9 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
const result = await session.send(makeMessageSend('hi'));
const result = await session.send({
message: [{ type: 'text', text: 'hi' }],
});
expect(result.streamId).toBe('test-stream');
});
@@ -179,10 +162,7 @@ describe('LegacyAgentSession', () => {
const session = new LegacyAgentSession(deps);
const { streamId } = await session.send({
message: {
content: [{ type: 'text', text: 'hi' }],
displayContent: 'raw input',
},
message: [{ type: 'text', text: 'hi' }],
_meta: { source: 'user-test' },
});
@@ -190,19 +170,8 @@ describe('LegacyAgentSession', () => {
(e): e is AgentEvent<'message'> =>
e.type === 'message' && e.role === 'user' && e.streamId === streamId,
);
expect(userMessage?.content).toEqual([
{ type: 'text', text: 'raw input' },
]);
expect(userMessage?.content).toEqual([{ type: 'text', text: 'hi' }]);
expect(userMessage?._meta).toEqual({ source: 'user-test' });
await vi.advanceTimersByTimeAsync(0);
expect(sendMock).toHaveBeenCalledWith(
[{ text: 'hi' }],
expect.any(AbortSignal),
'test-prompt',
undefined,
false,
'raw input',
);
await collectEvents(session, { streamId: streamId ?? undefined });
});
@@ -226,7 +195,9 @@ describe('LegacyAgentSession', () => {
liveEvents.push(event);
});
const { streamId } = await session.send(makeMessageSend('hi'));
const { streamId } = await session.send({
message: [{ type: 'text', text: 'hi' }],
});
expect(streamId).toBe('test-stream');
expect(liveEvents.some((event) => event.type === 'agent_start')).toBe(
@@ -264,12 +235,14 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
const { streamId } = await session.send(makeMessageSend('first'));
const { streamId } = await session.send({
message: [{ type: 'text', text: 'first' }],
});
await vi.advanceTimersByTimeAsync(0);
await expect(session.send(makeMessageSend('second'))).rejects.toThrow(
'cannot be called while a stream is active',
);
await expect(
session.send({ message: [{ type: 'text', text: 'second' }] }),
).rejects.toThrow('cannot be called while a stream is active');
resolveHang?.();
await collectEvents(session, { streamId: streamId ?? undefined });
@@ -300,12 +273,16 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
const first = await session.send(makeMessageSend('first'));
const first = await session.send({
message: [{ type: 'text', text: 'first' }],
});
const firstEvents = await collectEvents(session, {
streamId: first.streamId ?? undefined,
});
const second = await session.send(makeMessageSend('second'));
const second = await session.send({
message: [{ type: 'text', text: 'second' }],
});
const secondEvents = await collectEvents(session, {
streamId: second.streamId ?? undefined,
});
@@ -353,7 +330,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const types = events.map((e) => e.type);
@@ -410,7 +387,7 @@ describe('LegacyAgentSession', () => {
]);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('read a file'));
await session.send({ message: [{ type: 'text', text: 'read a file' }] });
const events = await collectEvents(session);
const types = events.map((e) => e.type);
@@ -478,7 +455,9 @@ describe('LegacyAgentSession', () => {
scheduleMock.mockResolvedValueOnce([errorToolCall]);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('write file'));
await session.send({
message: [{ type: 'text', text: 'write file' }],
});
const events = await collectEvents(session);
const toolResp = events.find(
@@ -527,7 +506,9 @@ describe('LegacyAgentSession', () => {
scheduleMock.mockResolvedValueOnce([stopToolCall]);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('do something'));
await session.send({
message: [{ type: 'text', text: 'do something' }],
});
const events = await collectEvents(session);
const streamEnd = events.find(
@@ -571,7 +552,9 @@ describe('LegacyAgentSession', () => {
scheduleMock.mockResolvedValueOnce([fatalToolCall]);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('write file'));
await session.send({
message: [{ type: 'text', text: 'write file' }],
});
const events = await collectEvents(session);
const toolResp = events.find(
@@ -609,7 +592,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const streamEnd = events.find(
@@ -638,7 +621,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const blocked = events.find(
@@ -680,7 +663,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const err = events.find(
@@ -707,7 +690,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const warning = events.find(
@@ -755,7 +738,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const streamEnd = events.find(
@@ -779,7 +762,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const errorEvents = events.filter(
@@ -816,7 +799,9 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
const { streamId } = await session.send(makeMessageSend('hi'));
const { streamId } = await session.send({
message: [{ type: 'text', text: 'hi' }],
});
await vi.advanceTimersByTimeAsync(0);
await session.abort();
@@ -862,7 +847,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
// Give the loop time to start processing
await new Promise((r) => setTimeout(r, 50));
@@ -906,7 +891,9 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
const { streamId } = await session.send(makeMessageSend('hi'));
const { streamId } = await session.send({
message: [{ type: 'text', text: 'hi' }],
});
await new Promise((resolve) => setTimeout(resolve, 25));
await session.abort();
@@ -948,7 +935,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
await collectEvents(session);
expect(session.events.length).toBeGreaterThan(0);
@@ -977,7 +964,9 @@ describe('LegacyAgentSession', () => {
liveEvents.push(event);
});
const { streamId } = await session.send(makeMessageSend('hi'));
const { streamId } = await session.send({
message: [{ type: 'text', text: 'hi' }],
});
await collectEvents(session, { streamId: streamId ?? undefined });
unsubscribe();
@@ -1013,7 +1002,9 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
const first = await session.send(makeMessageSend('first request'));
const first = await session.send({
message: [{ type: 'text', text: 'first request' }],
});
await collectEvents(session, { streamId: first.streamId ?? undefined });
const liveEvents: AgentEvent[] = [];
@@ -1021,7 +1012,9 @@ describe('LegacyAgentSession', () => {
liveEvents.push(event);
});
const second = await session.send(makeMessageSend('second request'));
const second = await session.send({
message: [{ type: 'text', text: 'second request' }],
});
await collectEvents(session, { streamId: second.streamId ?? undefined });
unsubscribe();
@@ -1065,10 +1058,14 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
const first = await session.send(makeMessageSend('first request'));
const first = await session.send({
message: [{ type: 'text', text: 'first request' }],
});
await collectEvents(session, { streamId: first.streamId ?? undefined });
const second = await session.send(makeMessageSend('second request'));
const second = await session.send({
message: [{ type: 'text', text: 'second request' }],
});
await collectEvents(session, { streamId: second.streamId ?? undefined });
const firstStreamEvents = await collectEvents(session, {
@@ -1123,10 +1120,14 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
const first = await session.send(makeMessageSend('first request'));
const first = await session.send({
message: [{ type: 'text', text: 'first request' }],
});
await collectEvents(session, { streamId: first.streamId ?? undefined });
await session.send(makeMessageSend('second request'));
await session.send({
message: [{ type: 'text', text: 'second request' }],
});
await collectEvents(session);
const firstAgentMessage = session.events.find(
@@ -1174,7 +1175,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
expect(events.length).toBeGreaterThan(0);
@@ -1195,7 +1196,7 @@ describe('LegacyAgentSession', () => {
);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
expect(events[events.length - 1]?.type).toBe('agent_end');
@@ -1243,7 +1244,7 @@ describe('LegacyAgentSession', () => {
]);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('do it'));
await session.send({ message: [{ type: 'text', text: 'do it' }] });
const events = await collectEvents(session);
// Only one agent_end at the very end
@@ -1290,7 +1291,7 @@ describe('LegacyAgentSession', () => {
]);
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('go'));
await session.send({ message: [{ type: 'text', text: 'go' }] });
const events = await collectEvents(session);
// Should have at least one usage event from the intermediate Finished
@@ -1313,7 +1314,7 @@ describe('LegacyAgentSession', () => {
});
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const err = events.find(
@@ -1341,7 +1342,7 @@ describe('LegacyAgentSession', () => {
});
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const err = events.find(
@@ -1364,7 +1365,7 @@ describe('LegacyAgentSession', () => {
});
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const err = events.find(
@@ -1384,7 +1385,7 @@ describe('LegacyAgentSession', () => {
});
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const err = events.find(
@@ -1404,7 +1405,7 @@ describe('LegacyAgentSession', () => {
});
const session = new LegacyAgentSession(deps);
await session.send(makeMessageSend('hi'));
await session.send({ message: [{ type: 'text', text: 'hi' }] });
const events = await collectEvents(session);
const err = events.find(
+17 -47
View File
@@ -14,11 +14,10 @@ import type { Part } from '@google/genai';
import type { GeminiClient } from '../core/client.js';
import type { Config } from '../config/config.js';
import type { ToolCallRequestInfo } from '../scheduler/types.js';
import { Scheduler } from '../scheduler/scheduler.js';
import type { Scheduler } from '../scheduler/scheduler.js';
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
import { ToolErrorType, isFatalToolError } from '../tools/tool-error.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { EditorType } from '../utils/editor.js';
import {
buildToolResponseData,
contentPartsToGeminiParts,
@@ -46,15 +45,14 @@ function isAbortLikeError(err: unknown): boolean {
}
export interface LegacyAgentSessionDeps {
client: GeminiClient;
scheduler: Scheduler;
config: Config;
client?: GeminiClient;
scheduler?: Scheduler;
promptId?: string;
promptId: string;
streamId?: string;
getPreferredEditor?: () => EditorType | undefined;
}
export class LegacyAgentProtocol implements AgentProtocol {
class LegacyAgentProtocol implements AgentProtocol {
private _events: AgentEvent[] = [];
private _subscribers = new Set<(event: AgentEvent) => void>();
private _translationState: TranslationState;
@@ -71,16 +69,10 @@ export class LegacyAgentProtocol implements AgentProtocol {
constructor(deps: LegacyAgentSessionDeps) {
this._translationState = createTranslationState(deps.streamId);
this._nextStreamIdOverride = deps.streamId;
this._client = deps.client;
this._scheduler = deps.scheduler;
this._config = deps.config;
this._client = deps.client ?? deps.config.getGeminiClient();
this._promptId = deps.promptId ?? deps.config.promptId ?? '';
this._scheduler =
deps.scheduler ??
new Scheduler({
context: deps.config,
schedulerId: 'legacy-agent-scheduler',
getPreferredEditor: deps.getPreferredEditor ?? (() => undefined),
});
this._promptId = deps.promptId;
}
get events(): readonly AgentEvent[] {
@@ -113,16 +105,12 @@ export class LegacyAgentProtocol implements AgentProtocol {
this._beginNewStream();
const streamId = this._translationState.streamId;
const parts = contentPartsToGeminiParts(message.content);
const userMessage = this._makeUserMessageEvent(
message.content,
message.displayContent,
payload._meta,
);
const parts = contentPartsToGeminiParts(message);
const userMessage = this._makeUserMessageEvent(message, payload._meta);
this._emit([userMessage]);
this._scheduleRunLoop(parts, message.displayContent);
this._scheduleRunLoop(parts);
return { streamId };
}
@@ -131,24 +119,18 @@ export class LegacyAgentProtocol implements AgentProtocol {
this._abortController.abort();
}
private _scheduleRunLoop(
initialParts: Part[],
displayContent?: string,
): void {
private _scheduleRunLoop(initialParts: Part[]): void {
// Use a macrotask so send() resolves with the streamId before agent_start
// is emitted and consumers can attach to the stream without racing startup.
setTimeout(() => {
void this._runLoopInBackground(initialParts, displayContent);
void this._runLoopInBackground(initialParts);
}, 0);
}
private async _runLoopInBackground(
initialParts: Part[],
displayContent?: string,
): Promise<void> {
private async _runLoopInBackground(initialParts: Part[]): Promise<void> {
this._ensureAgentStart();
try {
await this._runLoop(initialParts, displayContent);
await this._runLoop(initialParts);
} catch (err: unknown) {
if (this._abortController.signal.aborted || isAbortLikeError(err)) {
this._ensureAgentEnd('aborted');
@@ -159,12 +141,8 @@ export class LegacyAgentProtocol implements AgentProtocol {
}
}
private async _runLoop(
initialParts: Part[],
initialDisplayContent?: string,
): Promise<void> {
private async _runLoop(initialParts: Part[]): Promise<void> {
let currentParts: Part[] = initialParts;
let currentDisplayContent = initialDisplayContent;
let turnCount = 0;
const maxTurns = this._config.getMaxSessionTurns();
@@ -184,11 +162,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
currentParts,
this._abortController.signal,
this._promptId,
undefined,
false,
currentDisplayContent,
);
currentDisplayContent = undefined;
for await (const event of responseStream) {
if (this._abortController.signal.aborted) {
@@ -409,17 +383,13 @@ export class LegacyAgentProtocol implements AgentProtocol {
private _makeUserMessageEvent(
content: ContentPart[],
displayContent?: string,
meta?: Record<string, unknown>,
): AgentEvent<'message'> {
const eventContent: ContentPart[] = displayContent
? [{ type: 'text', text: displayContent }]
: content;
const event = {
...this._nextEventFields(),
type: 'message',
role: 'user',
content: eventContent,
content,
...(meta ? { _meta: meta } : {}),
} satisfies AgentEvent<'message'>;
return event;
+1 -1
View File
@@ -34,7 +34,7 @@ describe('MockAgentProtocol', () => {
const streamPromise = waitForStreamEnd(session);
const { streamId } = await session.send({
message: { content: [{ type: 'text', text: 'hi' }] },
message: [{ type: 'text', text: 'hi' }],
});
expect(streamId).toBeDefined();
+1 -8
View File
@@ -10,7 +10,6 @@ import type {
AgentEventData,
AgentProtocol,
AgentSend,
ContentPart,
Unsubscribe,
} from './types.js';
@@ -134,17 +133,11 @@ export class MockAgentProtocol implements AgentProtocol {
// 1. User/Update event (BEFORE agent_start)
if ('message' in payload && payload.message) {
const message = Array.isArray(payload.message)
? { content: payload.message, displayContent: undefined }
: payload.message;
const userContent: ContentPart[] = message.displayContent
? [{ type: 'text', text: message.displayContent }]
: message.content;
eventsToEmit.push(
normalize({
type: 'message',
role: 'user',
content: userContent,
content: payload.message,
_meta: payload._meta,
}),
);
+1 -35
View File
@@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Kind } from '../tools/tools.js';
export type WithMeta = { _meta?: Record<string, unknown> };
export type Unsubscribe = () => void;
@@ -48,10 +46,7 @@ type RequireExactlyOne<T> = {
}[keyof T];
interface AgentSendPayloads {
message: {
content: ContentPart[];
displayContent?: string;
};
message: ContentPart[];
elicitations: ElicitationResponse[];
update: { title?: string; model?: string; config?: Record<string, unknown> };
action: { type: string; data: unknown };
@@ -182,16 +177,6 @@ export interface ToolRequest {
name: string;
/** The arguments for the tool. */
args: Record<string, unknown>;
/** UI specific metadata */
_meta?: {
legacyState?: {
displayName?: string;
isOutputMarkdown?: boolean;
description?: string;
kind?: Kind;
};
[key: string]: unknown;
};
}
/**
@@ -204,18 +189,6 @@ export interface ToolUpdate {
displayContent?: ContentPart[];
content?: ContentPart[];
data?: Record<string, unknown>;
/** UI specific metadata */
_meta?: {
legacyState?: {
status?: string;
progressMessage?: string;
progress?: number;
progressTotal?: number;
pid?: number;
description?: string;
};
[key: string]: unknown;
};
}
export interface ToolResponse {
@@ -229,13 +202,6 @@ export interface ToolResponse {
data?: Record<string, unknown>;
/** When true, the tool call encountered an error that will be sent to the model. */
isError?: boolean;
/** UI specific metadata */
_meta?: {
legacyState?: {
outputFile?: string;
};
[key: string]: unknown;
};
}
export type ElicitationRequest = {
-9
View File
@@ -684,7 +684,6 @@ export interface ConfigParameters {
adminSkillsEnabled?: boolean;
experimentalJitContext?: boolean;
experimentalMemoryManager?: boolean;
useAgentProtocol?: boolean;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number;
@@ -923,7 +922,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryManager: boolean;
private readonly useAgentProtocol: boolean;
private readonly experimentalAgentHistoryTruncation: boolean;
private readonly experimentalAgentHistoryTruncationThreshold: number;
private readonly experimentalAgentHistoryRetainedMessages: number;
@@ -1138,7 +1136,6 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? true;
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
this.useAgentProtocol = params.useAgentProtocol ?? false;
this.experimentalAgentHistoryTruncation =
params.experimentalAgentHistoryTruncation ?? false;
this.experimentalAgentHistoryTruncationThreshold =
@@ -2341,12 +2338,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalMemoryManager;
}
getExperimentalUseAgentProtocol(): boolean {
return (
this.useAgentProtocol ||
process.env['GEMINI_CLI_USE_AGENT_PROTOCOL'] === 'true'
);
}
isExperimentalAgentHistoryTruncationEnabled(): boolean {
return this.experimentalAgentHistoryTruncation;
}
+1 -2
View File
@@ -6,7 +6,6 @@
import stripAnsi from 'strip-ansi';
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
import { getErrorType } from '../utils/errors.js';
import type { JsonError, JsonOutput } from './types.js';
export class JsonFormatter {
@@ -43,7 +42,7 @@ export class JsonFormatter {
sessionId?: string,
): string {
const jsonError: JsonError = {
type: getErrorType(error),
type: error.constructor.name,
message: stripAnsi(error.message),
...(code && { code }),
};