mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9e2c87131 | |||
| 3b0906f319 | |||
| cd7d91f35e | |||
| 6020551d1f | |||
| 8e9961a791 | |||
| e046cbd8e0 | |||
| b810e57ef1 | |||
| ecc9e50a1f | |||
| fd321abd3d | |||
| 2bb161b36f | |||
| 565851f2d5 | |||
| 593c33f927 | |||
| f85299717a | |||
| 0afe5117a4 | |||
| f853d2f9da | |||
| 0f37dd1d78 | |||
| 87e9491e78 | |||
| 2a8918c72f | |||
| 6975224e45 | |||
| d3a7fc39e3 | |||
| 3b317f9198 | |||
| 6a80311a37 | |||
| 6fb7bcf868 | |||
| 56656dfbc9 | |||
| 93322430a0 | |||
| 25c5d44678 | |||
| 2dfe237738 | |||
| f77e4716fa | |||
| 3e0b8aa958 | |||
| b268e93a1d | |||
| 218adcd6c3 |
@@ -988,6 +988,7 @@ 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,
|
||||
|
||||
@@ -2108,6 +2108,16 @@ 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',
|
||||
|
||||
@@ -77,6 +77,8 @@ 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,
|
||||
@@ -108,6 +110,8 @@ describe('runNonInteractive', () => {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
getChat: Mock;
|
||||
getCurrentSequenceModel: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
models: {},
|
||||
@@ -163,6 +167,8 @@ describe('runNonInteractive', () => {
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
getChat: vi.fn(() => ({ recordCompletedToolCalls: vi.fn() })),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -268,6 +274,54 @@ 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[] = [
|
||||
@@ -558,7 +612,7 @@ describe('runNonInteractive', () => {
|
||||
input: 'Initial fail',
|
||||
prompt_id: 'prompt-id-4',
|
||||
}),
|
||||
).rejects.toThrow(apiError);
|
||||
).rejects.toThrow('API connection failed');
|
||||
});
|
||||
|
||||
it('should not exit if a tool is not found, and should send error back to model', async () => {
|
||||
@@ -822,6 +876,79 @@ 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[] = [
|
||||
@@ -1068,10 +1195,11 @@ 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 new Error('Cancelled');
|
||||
throw cancellationSentinel;
|
||||
});
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
@@ -1087,7 +1215,7 @@ describe('runNonInteractive', () => {
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timeout);
|
||||
setTimeout(() => {
|
||||
reject(new Error('Aborted')); // This will be caught by nonInteractiveCli and passed to handleError
|
||||
reject(new Error('Aborted'));
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
@@ -1120,20 +1248,10 @@ describe('runNonInteractive', () => {
|
||||
keypressHandler('\u0003', { ctrl: true, name: 'c' });
|
||||
}
|
||||
|
||||
// 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');
|
||||
// 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);
|
||||
|
||||
expect(
|
||||
processStderrSpy.mock.calls.some(
|
||||
@@ -1160,6 +1278,78 @@ 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',
|
||||
@@ -1329,6 +1519,9 @@ 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);
|
||||
@@ -1777,9 +1970,7 @@ 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');
|
||||
@@ -2000,7 +2191,6 @@ describe('runNonInteractive', () => {
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Agent execution stopped: Stopped by hook\n',
|
||||
);
|
||||
// Should exit without calling sendMessageStream again
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -2031,9 +2221,9 @@ describe('runNonInteractive', () => {
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[WARNING] Agent execution blocked: Blocked by hook\n',
|
||||
);
|
||||
// sendMessageStream is called once, recursion is internal to it and transparent to the caller
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
// Stream continues after blocked event — content should be output
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2174,6 +2364,40 @@ 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,
|
||||
|
||||
@@ -6,33 +6,40 @@
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ResumedSessionData,
|
||||
UserFeedbackPayload,
|
||||
AgentEvent,
|
||||
ContentPart,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
convertSessionToClientHistory,
|
||||
GeminiEventType,
|
||||
FatalError,
|
||||
FatalAuthenticationError,
|
||||
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 { Content, Part } from '@google/genai';
|
||||
import type { Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
@@ -151,8 +158,6 @@ export async function runNonInteractive({
|
||||
}, 200);
|
||||
|
||||
abortController.abort();
|
||||
// Note: Don't exit here - let the abort flow through the system
|
||||
// and trigger handleCancellationError() which will exit with proper code
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,6 +188,8 @@ export async function runNonInteractive({
|
||||
};
|
||||
|
||||
let errorToHandle: unknown | undefined;
|
||||
let terminalProcessExitHandled = false;
|
||||
let abortSession = () => {};
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
|
||||
@@ -247,9 +254,6 @@ export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
// If a slash command is found and returns a prompt, use it.
|
||||
// Otherwise, slashCommandResult falls through to the default prompt
|
||||
// handling.
|
||||
if (slashCommandResult) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = slashCommandResult as Part[];
|
||||
@@ -267,8 +271,6 @@ export async function runNonInteractive({
|
||||
escapePastedAtSymbols: false,
|
||||
});
|
||||
if (error || !processedQuery) {
|
||||
// An error occurred during @include processing (e.g., file not found).
|
||||
// The error message is already logged by handleAtCommand.
|
||||
throw new FatalInputError(
|
||||
error || 'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
@@ -287,235 +289,334 @@ export async function runNonInteractive({
|
||||
});
|
||||
}
|
||||
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
||||
// Create LegacyAgentSession — owns the agentic loop
|
||||
const session = new LegacyAgentSession({
|
||||
client: geminiClient,
|
||||
scheduler,
|
||||
config,
|
||||
promptId: prompt_id,
|
||||
});
|
||||
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
config.getMaxSessionTurns() >= 0 &&
|
||||
turnCount > config.getMaxSessionTurns()
|
||||
) {
|
||||
handleMaxTurnsExceededError(config);
|
||||
}
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
// Wire Ctrl+C to session abort
|
||||
abortSession = () => {
|
||||
void session.abort();
|
||||
};
|
||||
abortController.signal.addEventListener('abort', abortSession);
|
||||
if (abortController.signal.aborted) {
|
||||
return handleCancellationError(config);
|
||||
}
|
||||
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
undefined,
|
||||
false,
|
||||
turnCount === 1 ? input : undefined,
|
||||
// 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.',
|
||||
);
|
||||
}
|
||||
|
||||
let responseText = '';
|
||||
for await (const event of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
handleCancellationError(config);
|
||||
}
|
||||
const getTextContent = (parts?: ContentPart[]): string | undefined => {
|
||||
const text = parts
|
||||
?.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.join('');
|
||||
return text ? text : undefined;
|
||||
};
|
||||
|
||||
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,
|
||||
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,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
} else {
|
||||
if (event.value) {
|
||||
textOutput.write(output);
|
||||
}
|
||||
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 (event.type === GeminiEventType.ToolCallRequest) {
|
||||
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 = '';
|
||||
}
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_USE,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: event.value.name,
|
||||
tool_id: event.value.callId,
|
||||
parameters: event.value.args,
|
||||
tool_name: event.name,
|
||||
tool_id: event.requestId,
|
||||
parameters: event.args,
|
||||
});
|
||||
}
|
||||
toolCallRequests.push(event.value);
|
||||
} else if (event.type === GeminiEventType.LoopDetected) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: 'Loop detected, stopping execution',
|
||||
});
|
||||
}
|
||||
} else if (event.type === GeminiEventType.MaxSessionTurns) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: 'Maximum session turns exceeded',
|
||||
});
|
||||
}
|
||||
} else if (event.type === GeminiEventType.Error) {
|
||||
throw event.value.error;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionStopped) {
|
||||
const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
}
|
||||
// Emit final result event for streaming JSON if needed
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(
|
||||
metrics,
|
||||
durationMs,
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${blockMessage}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
textOutput.ensureTrailingNewline();
|
||||
const completedToolCalls = await scheduler.schedule(
|
||||
toolCallRequests,
|
||||
abortController.signal,
|
||||
);
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const completedToolCall of completedToolCalls) {
|
||||
const toolResponse = completedToolCall.response;
|
||||
const requestInfo = completedToolCall.request;
|
||||
|
||||
case 'tool_response': {
|
||||
textOutput.ensureTrailingNewline();
|
||||
if (streamFormatter) {
|
||||
const displayText = getTextContent(event.displayContent);
|
||||
const errorMsg = getTextContent(event.content) ?? 'Tool error';
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_id: requestInfo.callId,
|
||||
status:
|
||||
completedToolCall.status === 'error' ? 'error' : 'success',
|
||||
output:
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
error: toolResponse.error
|
||||
tool_id: event.requestId,
|
||||
status: event.isError ? 'error' : 'success',
|
||||
output: displayText,
|
||||
error: event.isError
|
||||
? {
|
||||
type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
message: toolResponse.error.message,
|
||||
type:
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: 'TOOL_EXECUTION_ERROR',
|
||||
message: errorMsg,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (event.isError) {
|
||||
const displayText = getTextContent(event.displayContent);
|
||||
const errorMsg = getTextContent(event.content) ?? 'Tool error';
|
||||
|
||||
if (toolResponse.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(
|
||||
requestInfo.name,
|
||||
toolResponse.error,
|
||||
event.name,
|
||||
new Error(errorMsg),
|
||||
config,
|
||||
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: undefined,
|
||||
displayText,
|
||||
);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
toolResponseParts.push(...toolResponse.responseParts);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (event.fatal) {
|
||||
throw reconstructFatalError(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Record tool calls with full metadata before sending responses to Gemini
|
||||
try {
|
||||
const currentModel =
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel();
|
||||
geminiClient
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(currentModel, completedToolCalls);
|
||||
const errorCode = event._meta?.['code'];
|
||||
|
||||
await recordToolCallInteractions(config, completedToolCalls);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error recording completed tool call information: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if any tool requested to stop execution immediately
|
||||
const stopExecutionTool = completedToolCalls.find(
|
||||
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
|
||||
);
|
||||
|
||||
if (stopExecutionTool && stopExecutionTool.response.error) {
|
||||
const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`;
|
||||
if (errorCode === 'AGENT_EXECUTION_BLOCKED') {
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const severity =
|
||||
event.status === 'RESOURCE_EXHAUSTED' ? 'error' : 'warning';
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
|
||||
// Emit final result event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(
|
||||
metrics,
|
||||
durationMs,
|
||||
),
|
||||
severity,
|
||||
message: event.message,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
break;
|
||||
}
|
||||
case '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';
|
||||
|
||||
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
|
||||
if (isConfiguredTurnLimit) {
|
||||
runTerminalExitHandler(() =>
|
||||
handleMaxTurnsExceededError(config),
|
||||
);
|
||||
} else if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: 'Maximum session turns exceeded',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stopMessage =
|
||||
typeof event.data?.['message'] === 'string'
|
||||
? event.data['message']
|
||||
: '';
|
||||
if (stopMessage && config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
|
||||
}
|
||||
|
||||
emitFinalSuccessResult();
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
return;
|
||||
case '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;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -523,12 +624,16 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
buildUserSteeringHintPrompt,
|
||||
logBillingEvent,
|
||||
ApiKeyUpdatedEvent,
|
||||
LegacyAgentProtocol,
|
||||
type InjectionSource,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
@@ -110,6 +111,7 @@ 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';
|
||||
@@ -1091,6 +1093,46 @@ 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,
|
||||
@@ -1110,27 +1152,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
backgroundTasks,
|
||||
dismissBackgroundTask,
|
||||
retryStatus,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
historyManager.history,
|
||||
historyManager.addItem,
|
||||
config,
|
||||
settings,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
onCancelSubmit,
|
||||
setEmbeddedShellFocused,
|
||||
terminalWidth,
|
||||
terminalHeight,
|
||||
embeddedShellFocused,
|
||||
consumePendingHints,
|
||||
);
|
||||
} = activeStream;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
@@ -1703,7 +1725,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
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.
|
||||
cancelOngoingRequest?.();
|
||||
void cancelOngoingRequest?.();
|
||||
|
||||
handleCtrlCPress();
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
};
|
||||
@@ -5,20 +5,22 @@
|
||||
*/
|
||||
|
||||
import { useInactivityTimer } from './useInactivityTimer.js';
|
||||
import { useTurnActivityMonitor } from './useTurnActivityMonitor.js';
|
||||
import {
|
||||
useTurnActivityMonitor,
|
||||
type MinimalTrackedToolCall,
|
||||
} 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: TrackedToolCall[];
|
||||
pendingToolCalls: MinimalTrackedToolCall[];
|
||||
embeddedShellFocused: boolean;
|
||||
isInteractiveShellEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@ export type CancelAllFn = (signal: AbortSignal) => void;
|
||||
* The shape expected by useGeminiStream.
|
||||
* It matches the Core ToolCall structure + the UI metadata flag.
|
||||
*/
|
||||
export type TrackedToolCall = ToolCall & {
|
||||
responseSubmittedToGemini?: boolean;
|
||||
};
|
||||
export type Tracked<T> = T extends unknown
|
||||
? T & { responseSubmittedToGemini?: boolean }
|
||||
: never;
|
||||
export type TrackedToolCall = Tracked<ToolCall>;
|
||||
|
||||
// Narrowed types for specific statuses (used by useGeminiStream)
|
||||
export type TrackedScheduledToolCall = Extract<
|
||||
@@ -75,6 +76,7 @@ export function useToolScheduler(
|
||||
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
|
||||
CancelAllFn,
|
||||
number,
|
||||
Scheduler,
|
||||
] {
|
||||
// State stores tool calls organized by their originating schedulerId
|
||||
const [toolCallsMap, setToolCallsMap] = useState<
|
||||
@@ -257,6 +259,7 @@ export function useToolScheduler(
|
||||
setToolCallsForDisplay,
|
||||
cancelAll,
|
||||
lastToolOutputTime,
|
||||
scheduler,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,16 @@
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { hasRedirection } from '@google/gemini-cli-core';
|
||||
import { type TrackedToolCall } from './useToolScheduler.js';
|
||||
import {
|
||||
hasRedirection,
|
||||
type CoreToolCallStatus,
|
||||
type ToolCallRequestInfo,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface MinimalTrackedToolCall {
|
||||
status: CoreToolCallStatus;
|
||||
request: ToolCallRequestInfo;
|
||||
}
|
||||
|
||||
export interface TurnActivityStatus {
|
||||
operationStartTime: number;
|
||||
@@ -21,7 +29,7 @@ export interface TurnActivityStatus {
|
||||
export const useTurnActivityMonitor = (
|
||||
streamingState: StreamingState,
|
||||
activePtyId: number | string | null | undefined,
|
||||
pendingToolCalls: TrackedToolCall[] = [],
|
||||
pendingToolCalls: MinimalTrackedToolCall[] = [],
|
||||
): TurnActivityStatus => {
|
||||
const [operationStartTime, setOperationStartTime] = useState(0);
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ export function getToolGroupBorderAppearance(
|
||||
item:
|
||||
| HistoryItem
|
||||
| HistoryItemWithoutId
|
||||
| { type: 'tool_group'; tools: TrackedToolCall[] },
|
||||
| {
|
||||
type: 'tool_group';
|
||||
tools: Array<IndividualToolCallDisplay | TrackedToolCall>;
|
||||
},
|
||||
activeShellPtyId: number | null | undefined,
|
||||
embeddedShellFocused: boolean | undefined,
|
||||
allPendingItems: HistoryItemWithoutId[] = [],
|
||||
@@ -41,7 +44,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: Array<IndividualToolCallDisplay | TrackedToolCall> =
|
||||
const toolsToInspect =
|
||||
item.tools.length > 0
|
||||
? item.tools
|
||||
: allPendingItems
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
isFatalToolError,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
getErrorMessage,
|
||||
getErrorType,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runSyncCleanup } from './cleanup.js';
|
||||
|
||||
|
||||
@@ -7,7 +7,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AgentSession } from './agent-session.js';
|
||||
import { MockAgentProtocol } from './mock.js';
|
||||
import type { AgentEvent } from './types.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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentSession', () => {
|
||||
it('should passthrough simple methods', async () => {
|
||||
@@ -51,7 +63,7 @@ describe('AgentSession', () => {
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
for await (const event of session.sendStream({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
...makeMessageSend('hi'),
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
@@ -139,7 +151,7 @@ describe('AgentSession', () => {
|
||||
|
||||
const events: AgentEvent[] = [];
|
||||
for await (const event of session.sendStream({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
...makeMessageSend('hi'),
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
@@ -178,7 +190,7 @@ describe('AgentSession', () => {
|
||||
|
||||
protocol.pushResponse([{ type: 'message' }]);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'request' }],
|
||||
...makeMessageSend('request'),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
@@ -242,7 +254,7 @@ describe('AgentSession', () => {
|
||||
},
|
||||
]);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'request' }],
|
||||
...makeMessageSend('request'),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
@@ -303,7 +315,7 @@ describe('AgentSession', () => {
|
||||
},
|
||||
]);
|
||||
const { streamId: streamId1 } = await session.send({
|
||||
message: [{ type: 'text', text: 'first request' }],
|
||||
...makeMessageSend('first request'),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
@@ -315,7 +327,7 @@ describe('AgentSession', () => {
|
||||
},
|
||||
]);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'second request' }],
|
||||
...makeMessageSend('second request'),
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
|
||||
@@ -679,6 +679,7 @@ 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,
|
||||
|
||||
@@ -403,7 +403,7 @@ export function mapError(
|
||||
}
|
||||
|
||||
if (isStructuredError(error)) {
|
||||
const structuredMeta = { ...meta, rawError: error };
|
||||
const structuredMeta = { ...meta, rawError: error, status: error.status };
|
||||
return {
|
||||
status: mapHttpToGrpcStatus(error.status),
|
||||
message: error.message,
|
||||
|
||||
@@ -10,13 +10,16 @@ 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 } from './types.js';
|
||||
import type { AgentEvent, AgentSend } 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
|
||||
@@ -24,7 +27,7 @@ import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
|
||||
function createMockDeps(
|
||||
overrides?: Partial<LegacyAgentSessionDeps>,
|
||||
): LegacyAgentSessionDeps {
|
||||
): Required<LegacyAgentSessionDeps> {
|
||||
const mockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getChat: vi.fn().mockReturnValue({
|
||||
@@ -40,18 +43,22 @@ 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 LegacyAgentSessionDeps['client'],
|
||||
|
||||
scheduler: mockScheduler as unknown as LegacyAgentSessionDeps['scheduler'],
|
||||
|
||||
config: mockConfig as unknown as LegacyAgentSessionDeps['config'],
|
||||
client: mockClient as unknown as GeminiClient,
|
||||
scheduler: mockScheduler as unknown as Scheduler,
|
||||
config: mockConfig as unknown as Config,
|
||||
promptId: 'test-prompt',
|
||||
streamId: 'test-stream',
|
||||
getPreferredEditor: vi.fn().mockReturnValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
} as Required<LegacyAgentSessionDeps>;
|
||||
}
|
||||
|
||||
async function* makeStream(
|
||||
@@ -72,6 +79,18 @@ 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,
|
||||
@@ -117,7 +136,7 @@ async function collectEvents(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('LegacyAgentSession', () => {
|
||||
let deps: LegacyAgentSessionDeps;
|
||||
let deps: Required<LegacyAgentSessionDeps>;
|
||||
|
||||
beforeEach(() => {
|
||||
deps = createMockDeps();
|
||||
@@ -140,9 +159,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const result = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const result = await session.send(makeMessageSend('hi'));
|
||||
|
||||
expect(result.streamId).toBe('test-stream');
|
||||
});
|
||||
@@ -162,7 +179,10 @@ describe('LegacyAgentSession', () => {
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
displayContent: 'raw input',
|
||||
},
|
||||
_meta: { source: 'user-test' },
|
||||
});
|
||||
|
||||
@@ -170,8 +190,19 @@ describe('LegacyAgentSession', () => {
|
||||
(e): e is AgentEvent<'message'> =>
|
||||
e.type === 'message' && e.role === 'user' && e.streamId === streamId,
|
||||
);
|
||||
expect(userMessage?.content).toEqual([{ type: 'text', text: 'hi' }]);
|
||||
expect(userMessage?.content).toEqual([
|
||||
{ type: 'text', text: 'raw input' },
|
||||
]);
|
||||
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 });
|
||||
});
|
||||
@@ -195,9 +226,7 @@ describe('LegacyAgentSession', () => {
|
||||
liveEvents.push(event);
|
||||
});
|
||||
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('hi'));
|
||||
|
||||
expect(streamId).toBe('test-stream');
|
||||
expect(liveEvents.some((event) => event.type === 'agent_start')).toBe(
|
||||
@@ -235,14 +264,12 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'first' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('first'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect(
|
||||
session.send({ message: [{ type: 'text', text: 'second' }] }),
|
||||
).rejects.toThrow('cannot be called while a stream is active');
|
||||
await expect(session.send(makeMessageSend('second'))).rejects.toThrow(
|
||||
'cannot be called while a stream is active',
|
||||
);
|
||||
|
||||
resolveHang?.();
|
||||
await collectEvents(session, { streamId: streamId ?? undefined });
|
||||
@@ -273,16 +300,12 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const first = await session.send({
|
||||
message: [{ type: 'text', text: 'first' }],
|
||||
});
|
||||
const first = await session.send(makeMessageSend('first'));
|
||||
const firstEvents = await collectEvents(session, {
|
||||
streamId: first.streamId ?? undefined,
|
||||
});
|
||||
|
||||
const second = await session.send({
|
||||
message: [{ type: 'text', text: 'second' }],
|
||||
});
|
||||
const second = await session.send(makeMessageSend('second'));
|
||||
const secondEvents = await collectEvents(session, {
|
||||
streamId: second.streamId ?? undefined,
|
||||
});
|
||||
@@ -330,7 +353,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const types = events.map((e) => e.type);
|
||||
@@ -387,7 +410,7 @@ describe('LegacyAgentSession', () => {
|
||||
]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'read a file' }] });
|
||||
await session.send(makeMessageSend('read a file'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const types = events.map((e) => e.type);
|
||||
@@ -455,9 +478,7 @@ describe('LegacyAgentSession', () => {
|
||||
scheduleMock.mockResolvedValueOnce([errorToolCall]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'write file' }],
|
||||
});
|
||||
await session.send(makeMessageSend('write file'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const toolResp = events.find(
|
||||
@@ -506,9 +527,7 @@ describe('LegacyAgentSession', () => {
|
||||
scheduleMock.mockResolvedValueOnce([stopToolCall]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'do something' }],
|
||||
});
|
||||
await session.send(makeMessageSend('do something'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
@@ -552,9 +571,7 @@ describe('LegacyAgentSession', () => {
|
||||
scheduleMock.mockResolvedValueOnce([fatalToolCall]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'write file' }],
|
||||
});
|
||||
await session.send(makeMessageSend('write file'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const toolResp = events.find(
|
||||
@@ -592,7 +609,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
@@ -621,7 +638,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const blocked = events.find(
|
||||
@@ -663,7 +680,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -690,7 +707,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const warning = events.find(
|
||||
@@ -738,7 +755,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const streamEnd = events.find(
|
||||
@@ -762,7 +779,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const errorEvents = events.filter(
|
||||
@@ -799,9 +816,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('hi'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await session.abort();
|
||||
@@ -847,7 +862,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
|
||||
// Give the loop time to start processing
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
@@ -891,9 +906,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('hi'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await session.abort();
|
||||
@@ -935,7 +948,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
await collectEvents(session);
|
||||
|
||||
expect(session.events.length).toBeGreaterThan(0);
|
||||
@@ -964,9 +977,7 @@ describe('LegacyAgentSession', () => {
|
||||
liveEvents.push(event);
|
||||
});
|
||||
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
const { streamId } = await session.send(makeMessageSend('hi'));
|
||||
await collectEvents(session, { streamId: streamId ?? undefined });
|
||||
unsubscribe();
|
||||
|
||||
@@ -1002,9 +1013,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const first = await session.send({
|
||||
message: [{ type: 'text', text: 'first request' }],
|
||||
});
|
||||
const first = await session.send(makeMessageSend('first request'));
|
||||
await collectEvents(session, { streamId: first.streamId ?? undefined });
|
||||
|
||||
const liveEvents: AgentEvent[] = [];
|
||||
@@ -1012,9 +1021,7 @@ describe('LegacyAgentSession', () => {
|
||||
liveEvents.push(event);
|
||||
});
|
||||
|
||||
const second = await session.send({
|
||||
message: [{ type: 'text', text: 'second request' }],
|
||||
});
|
||||
const second = await session.send(makeMessageSend('second request'));
|
||||
await collectEvents(session, { streamId: second.streamId ?? undefined });
|
||||
unsubscribe();
|
||||
|
||||
@@ -1058,14 +1065,10 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const first = await session.send({
|
||||
message: [{ type: 'text', text: 'first request' }],
|
||||
});
|
||||
const first = await session.send(makeMessageSend('first request'));
|
||||
await collectEvents(session, { streamId: first.streamId ?? undefined });
|
||||
|
||||
const second = await session.send({
|
||||
message: [{ type: 'text', text: 'second request' }],
|
||||
});
|
||||
const second = await session.send(makeMessageSend('second request'));
|
||||
await collectEvents(session, { streamId: second.streamId ?? undefined });
|
||||
|
||||
const firstStreamEvents = await collectEvents(session, {
|
||||
@@ -1120,14 +1123,10 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
const first = await session.send({
|
||||
message: [{ type: 'text', text: 'first request' }],
|
||||
});
|
||||
const first = await session.send(makeMessageSend('first request'));
|
||||
await collectEvents(session, { streamId: first.streamId ?? undefined });
|
||||
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'second request' }],
|
||||
});
|
||||
await session.send(makeMessageSend('second request'));
|
||||
await collectEvents(session);
|
||||
|
||||
const firstAgentMessage = session.events.find(
|
||||
@@ -1175,7 +1174,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
@@ -1196,7 +1195,7 @@ describe('LegacyAgentSession', () => {
|
||||
);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
expect(events[events.length - 1]?.type).toBe('agent_end');
|
||||
@@ -1244,7 +1243,7 @@ describe('LegacyAgentSession', () => {
|
||||
]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'do it' }] });
|
||||
await session.send(makeMessageSend('do it'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
// Only one agent_end at the very end
|
||||
@@ -1291,7 +1290,7 @@ describe('LegacyAgentSession', () => {
|
||||
]);
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'go' }] });
|
||||
await session.send(makeMessageSend('go'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
// Should have at least one usage event from the intermediate Finished
|
||||
@@ -1314,7 +1313,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -1342,7 +1341,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -1365,7 +1364,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -1385,7 +1384,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
@@ -1405,7 +1404,7 @@ describe('LegacyAgentSession', () => {
|
||||
});
|
||||
|
||||
const session = new LegacyAgentSession(deps);
|
||||
await session.send({ message: [{ type: 'text', text: 'hi' }] });
|
||||
await session.send(makeMessageSend('hi'));
|
||||
const events = await collectEvents(session);
|
||||
|
||||
const err = events.find(
|
||||
|
||||
@@ -14,10 +14,11 @@ 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 type { Scheduler } from '../scheduler/scheduler.js';
|
||||
import { 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,
|
||||
@@ -45,14 +46,15 @@ function isAbortLikeError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export interface LegacyAgentSessionDeps {
|
||||
client: GeminiClient;
|
||||
scheduler: Scheduler;
|
||||
config: Config;
|
||||
promptId: string;
|
||||
client?: GeminiClient;
|
||||
scheduler?: Scheduler;
|
||||
promptId?: string;
|
||||
streamId?: string;
|
||||
getPreferredEditor?: () => EditorType | undefined;
|
||||
}
|
||||
|
||||
class LegacyAgentProtocol implements AgentProtocol {
|
||||
export class LegacyAgentProtocol implements AgentProtocol {
|
||||
private _events: AgentEvent[] = [];
|
||||
private _subscribers = new Set<(event: AgentEvent) => void>();
|
||||
private _translationState: TranslationState;
|
||||
@@ -69,10 +71,16 @@ 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._promptId = deps.promptId;
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
get events(): readonly AgentEvent[] {
|
||||
@@ -105,12 +113,16 @@ class LegacyAgentProtocol implements AgentProtocol {
|
||||
|
||||
this._beginNewStream();
|
||||
const streamId = this._translationState.streamId;
|
||||
const parts = contentPartsToGeminiParts(message);
|
||||
const userMessage = this._makeUserMessageEvent(message, payload._meta);
|
||||
const parts = contentPartsToGeminiParts(message.content);
|
||||
const userMessage = this._makeUserMessageEvent(
|
||||
message.content,
|
||||
message.displayContent,
|
||||
payload._meta,
|
||||
);
|
||||
|
||||
this._emit([userMessage]);
|
||||
|
||||
this._scheduleRunLoop(parts);
|
||||
this._scheduleRunLoop(parts, message.displayContent);
|
||||
|
||||
return { streamId };
|
||||
}
|
||||
@@ -119,18 +131,24 @@ class LegacyAgentProtocol implements AgentProtocol {
|
||||
this._abortController.abort();
|
||||
}
|
||||
|
||||
private _scheduleRunLoop(initialParts: Part[]): void {
|
||||
private _scheduleRunLoop(
|
||||
initialParts: Part[],
|
||||
displayContent?: string,
|
||||
): 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);
|
||||
void this._runLoopInBackground(initialParts, displayContent);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private async _runLoopInBackground(initialParts: Part[]): Promise<void> {
|
||||
private async _runLoopInBackground(
|
||||
initialParts: Part[],
|
||||
displayContent?: string,
|
||||
): Promise<void> {
|
||||
this._ensureAgentStart();
|
||||
try {
|
||||
await this._runLoop(initialParts);
|
||||
await this._runLoop(initialParts, displayContent);
|
||||
} catch (err: unknown) {
|
||||
if (this._abortController.signal.aborted || isAbortLikeError(err)) {
|
||||
this._ensureAgentEnd('aborted');
|
||||
@@ -141,8 +159,12 @@ class LegacyAgentProtocol implements AgentProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private async _runLoop(initialParts: Part[]): Promise<void> {
|
||||
private async _runLoop(
|
||||
initialParts: Part[],
|
||||
initialDisplayContent?: string,
|
||||
): Promise<void> {
|
||||
let currentParts: Part[] = initialParts;
|
||||
let currentDisplayContent = initialDisplayContent;
|
||||
let turnCount = 0;
|
||||
const maxTurns = this._config.getMaxSessionTurns();
|
||||
|
||||
@@ -162,7 +184,11 @@ 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) {
|
||||
@@ -383,13 +409,17 @@ 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,
|
||||
content: eventContent,
|
||||
...(meta ? { _meta: meta } : {}),
|
||||
} satisfies AgentEvent<'message'>;
|
||||
return event;
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('MockAgentProtocol', () => {
|
||||
const streamPromise = waitForStreamEnd(session);
|
||||
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'hi' }],
|
||||
message: { content: [{ type: 'text', text: 'hi' }] },
|
||||
});
|
||||
expect(streamId).toBeDefined();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AgentEventData,
|
||||
AgentProtocol,
|
||||
AgentSend,
|
||||
ContentPart,
|
||||
Unsubscribe,
|
||||
} from './types.js';
|
||||
|
||||
@@ -133,11 +134,17 @@ 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: payload.message,
|
||||
content: userContent,
|
||||
_meta: payload._meta,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Kind } from '../tools/tools.js';
|
||||
|
||||
export type WithMeta = { _meta?: Record<string, unknown> };
|
||||
|
||||
export type Unsubscribe = () => void;
|
||||
@@ -46,7 +48,10 @@ type RequireExactlyOne<T> = {
|
||||
}[keyof T];
|
||||
|
||||
interface AgentSendPayloads {
|
||||
message: ContentPart[];
|
||||
message: {
|
||||
content: ContentPart[];
|
||||
displayContent?: string;
|
||||
};
|
||||
elicitations: ElicitationResponse[];
|
||||
update: { title?: string; model?: string; config?: Record<string, unknown> };
|
||||
action: { type: string; data: unknown };
|
||||
@@ -177,6 +182,16 @@ 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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,6 +204,18 @@ 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 {
|
||||
@@ -202,6 +229,13 @@ 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 = {
|
||||
|
||||
@@ -684,6 +684,7 @@ export interface ConfigParameters {
|
||||
adminSkillsEnabled?: boolean;
|
||||
experimentalJitContext?: boolean;
|
||||
experimentalMemoryManager?: boolean;
|
||||
useAgentProtocol?: boolean;
|
||||
experimentalAgentHistoryTruncation?: boolean;
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
experimentalAgentHistoryRetainedMessages?: number;
|
||||
@@ -922,6 +923,7 @@ 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;
|
||||
@@ -1136,6 +1138,7 @@ 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 =
|
||||
@@ -2338,6 +2341,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
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 {
|
||||
@@ -42,7 +43,7 @@ export class JsonFormatter {
|
||||
sessionId?: string,
|
||||
): string {
|
||||
const jsonError: JsonError = {
|
||||
type: error.constructor.name,
|
||||
type: getErrorType(error),
|
||||
message: stripAnsi(error.message),
|
||||
...(code && { code }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user