WIP - draft for agent factory

This commit is contained in:
Abhi
2026-02-19 16:12:53 -05:00
parent a468407098
commit b23bcc7ae5
18 changed files with 1598 additions and 417 deletions
+116
View File
@@ -82,6 +82,19 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
stdout: process.stdout,
stderr: process.stderr,
})),
AgentSession: vi.fn(),
AgentTerminateMode: {
ERROR: 'ERROR',
TIMEOUT: 'TIMEOUT',
GOAL: 'GOAL',
MAX_TURNS: 'MAX_TURNS',
ABORTED: 'ABORTED',
},
CoreToolCallStatus: {
Success: 'success',
Error: 'error',
Cancelled: 'cancelled',
},
};
});
@@ -190,6 +203,7 @@ describe('runNonInteractive', () => {
isTrustedFolder: vi.fn().mockReturnValue(false),
getRawOutput: vi.fn().mockReturnValue(false),
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
isAgentsEnabled: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockSettings = {
@@ -2231,4 +2245,106 @@ describe('runNonInteractive', () => {
expect(output).toContain('"status":"success"');
});
});
describe('runNonInteractive (AgentSession)', () => {
let mockAgentSession: { prompt: Mock; resume: Mock };
beforeEach(async () => {
vi.mocked(mockConfig.isAgentsEnabled).mockReturnValue(true);
// Get the mocked AgentSession class to spy on instances
const core = await import('@google/gemini-cli-core');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MockAgentSessionClass = core.AgentSession as any;
// Mock the prompt method logic
mockAgentSession = {
prompt: vi.fn(),
resume: vi.fn(),
};
// When new AgentSession() is called, return our mock instance
MockAgentSessionClass.mockImplementation(() => mockAgentSession);
});
it('should process input and write text output from agent events', async () => {
const events = [
{ type: GeminiEventType.Content, value: 'Hello' },
{ type: GeminiEventType.Content, value: ' World' },
{
type: 'agent_finish',
value: {
reason: 'GOAL',
sessionId: 'test-session-id',
totalTurns: 1,
},
},
];
async function* eventGenerator() {
for (const event of events) {
yield event;
}
}
mockAgentSession.prompt.mockReturnValue(eventGenerator());
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Test input',
prompt_id: 'prompt-id-agent',
});
expect(mockAgentSession.prompt).toHaveBeenCalledWith(
[{ text: 'Test input' }],
expect.any(AbortSignal),
);
expect(getWrittenOutput()).toBe('Hello World\n');
});
it('should write JSON output with stats from agent events', async () => {
const events = [
{ type: GeminiEventType.Content, value: 'JSON Response' },
{
type: 'agent_finish',
value: {
reason: 'GOAL',
sessionId: 'test-session-id',
totalTurns: 1,
},
},
];
async function* eventGenerator() {
for (const event of events) {
yield event;
}
}
mockAgentSession.prompt.mockReturnValue(eventGenerator());
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
MOCK_SESSION_METRICS,
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Test input',
prompt_id: 'prompt-id-agent-json',
});
expect(processStdoutSpy).toHaveBeenCalledWith(
JSON.stringify(
{
session_id: 'test-session-id',
response: 'JSON Response',
stats: MOCK_SESSION_METRICS,
},
null,
2,
),
);
});
});
});
+470 -272
View File
@@ -24,18 +24,20 @@ import {
debugLogger,
coreEvents,
CoreEvent,
createWorkingStdio,
recordToolCallInteractions,
ToolErrorType,
Scheduler,
ROOT_SCHEDULER_ID,
convertSessionToClientHistory,
createWorkingStdio,
AgentSession,
AgentTerminateMode,
} from '@google/gemini-cli-core';
import type { Content, Part } from '@google/genai';
import readline from 'node:readline';
import stripAnsi from 'strip-ansi';
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
@@ -55,6 +57,16 @@ interface RunNonInteractiveParams {
resumedSessionData?: ResumedSessionData;
}
interface LoopContext {
consolePatcher: ConsolePatcher;
textOutput: TextOutput;
abortController: AbortController;
streamFormatter: StreamJsonFormatter | null;
startTime: number;
setupStdinCancellation: () => void;
cleanupStdinCancellation: () => void;
}
export async function runNonInteractive({
config,
settings,
@@ -209,36 +221,20 @@ export async function runNonInteractive({
}
});
const geminiClient = config.getGeminiClient();
const scheduler = new Scheduler({
config,
messageBus: config.getMessageBus(),
getPreferredEditor: () => undefined,
schedulerId: ROOT_SCHEDULER_ID,
});
// Initialize chat. Resume if resume data is passed.
if (resumedSessionData) {
await geminiClient.resumeChat(
convertSessionToHistoryFormats(
resumedSessionData.conversation.messages,
).clientHistory,
resumedSessionData,
);
}
// Emit init event for streaming JSON
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.INIT,
timestamp: new Date().toISOString(),
session_id: config.getSessionId(),
model: config.getModel(),
});
}
const loopContext: LoopContext = {
consolePatcher,
textOutput,
abortController,
streamFormatter,
startTime,
setupStdinCancellation,
cleanupStdinCancellation,
};
// --- Input Processing (Lifted) ---
let query: Part[] | undefined;
// 1. Slash Commands
if (isSlashCommand(input)) {
const slashCommandResult = await handleSlashCommand(
input,
@@ -247,14 +243,13 @@ export async function runNonInteractive({
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[];
}
}
// 2. @ Commands (if query not set by slash command)
if (!query) {
const { processedQuery, error } = await handleAtCommand({
query: input,
@@ -266,8 +261,6 @@ export async function runNonInteractive({
});
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.',
);
@@ -276,246 +269,18 @@ export async function runNonInteractive({
query = processedQuery as Part[];
}
// Emit user message event for streaming JSON
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.MESSAGE,
timestamp: new Date().toISOString(),
role: 'user',
content: input,
});
}
let currentMessages: Content[] = [{ role: 'user', parts: query }];
let turnCount = 0;
while (true) {
turnCount++;
if (
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
handleMaxTurnsExceededError(config);
}
const toolCallRequests: ToolCallRequestInfo[] = [];
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
undefined,
false,
turnCount === 1 ? input : undefined,
if (config.isAgentsEnabled()) {
await runAgentSessionFlow(
loopContext,
{ config, settings, input, prompt_id, resumedSessionData, query }, // API change: pass query
handleUserFeedback,
);
} else {
await runLegacyManualLoop(
loopContext,
{ config, settings, input, prompt_id, resumedSessionData, query }, // API change: pass query
handleUserFeedback,
);
let responseText = '';
for await (const event of responseStream) {
if (abortController.signal.aborted) {
handleCancellationError(config);
}
if (event.type === GeminiEventType.Content) {
const isRaw =
config.getRawOutput() || config.getAcceptRawOutputRisk();
const output = isRaw ? event.value : stripAnsi(event.value);
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.MESSAGE,
timestamp: new Date().toISOString(),
role: 'assistant',
content: output,
delta: true,
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
responseText += output;
} else {
if (event.value) {
textOutput.write(output);
}
}
} else if (event.type === GeminiEventType.ToolCallRequest) {
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,
});
}
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`);
}
}
}
if (toolCallRequests.length > 0) {
textOutput.ensureTrailingNewline();
const completedToolCalls = await scheduler.schedule(
toolCallRequests,
abortController.signal,
);
const toolResponseParts: Part[] = [];
for (const completedToolCall of completedToolCalls) {
const toolResponse = completedToolCall.response;
const requestInfo = completedToolCall.request;
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.TOOL_RESULT,
timestamp: new Date().toISOString(),
tool_id: requestInfo.callId,
status:
completedToolCall.status === 'error' ? 'error' : 'success',
output:
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: undefined,
error: toolResponse.error
? {
type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
message: toolResponse.error.message,
}
: undefined,
});
}
if (toolResponse.error) {
handleToolError(
requestInfo.name,
toolResponse.error,
config,
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: undefined,
);
}
if (toolResponse.responseParts) {
toolResponseParts.push(...toolResponse.responseParts);
}
}
// Record tool calls with full metadata before sending responses to Gemini
try {
const currentModel =
geminiClient.getCurrentSequenceModel() ?? config.getModel();
geminiClient
.getChat()
.recordCompletedToolCalls(currentModel, completedToolCalls);
await recordToolCallInteractions(config, completedToolCalls);
} catch (error) {
debugLogger.error(
`Error recording completed tool call information: ${error}`,
);
}
// Check if any tool requested to stop execution immediately
const stopExecutionTool = completedToolCalls.find(
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
);
if (stopExecutionTool && stopExecutionTool.response.error) {
const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`;
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`${stopMessage}\n`);
}
// Emit final result event for streaming JSON
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
stats: streamFormatter.convertToStreamStats(
metrics,
durationMs,
),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
textOutput.write(
formatter.format(config.getSessionId(), responseText, stats),
);
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
}
return;
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
// Emit final result event for streaming JSON
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
textOutput.write(
formatter.format(config.getSessionId(), responseText, stats),
);
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
}
return;
}
}
} catch (error) {
errorToHandle = error;
@@ -532,3 +297,436 @@ export async function runNonInteractive({
}
});
}
async function runAgentSessionFlow(
{ textOutput, abortController, streamFormatter, startTime }: LoopContext,
{
config,
settings: _settings,
input,
prompt_id: _prompt_id,
resumedSessionData,
query,
}: RunNonInteractiveParams & { query: Part[] },
_handleUserFeedback: (payload: UserFeedbackPayload) => void,
) {
const session = new AgentSession(
config.getSessionId(),
{
name: 'cli-agent',
maxTurns: config.getMaxSessionTurns(),
},
config,
);
if (resumedSessionData) {
await session.resume(resumedSessionData);
}
let finalResponseText = '';
// Handle initialization for stream JSON format
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.INIT,
timestamp: new Date().toISOString(),
session_id: config.getSessionId(),
model: config.getModel(),
});
}
// NOTE: Input processing (Slash commands, @ commands) is now handled in `runNonInteractive`
// and passed in via `query`.
// Emit user message event for streaming JSON
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.MESSAGE,
timestamp: new Date().toISOString(),
role: 'user',
content: input,
});
}
// Start Agent Loop
const stream = session.prompt(query, abortController.signal);
for await (const event of stream) {
if (event.type === GeminiEventType.Content) {
const isRaw = config.getRawOutput() || config.getAcceptRawOutputRisk();
const output = isRaw ? event.value : stripAnsi(event.value);
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.MESSAGE,
timestamp: new Date().toISOString(),
role: 'assistant',
content: output,
delta: true,
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
finalResponseText += output;
} else {
if (event.value) {
textOutput.write(output);
}
}
} else if (event.type === GeminiEventType.ToolCallRequest) {
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,
});
}
} else if (event.type === 'tool_suite_finish') {
// Replicates the "TOOL_RESULT" emission from legacy loop
// The legacy loop emits this *after* execution.
// AgentSession emits 'tool_suite_finish' after execution.
for (const response of event.value.responses) {
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.TOOL_RESULT,
timestamp: new Date().toISOString(),
tool_id: response.callId,
status: response.error ? 'error' : 'success',
output:
typeof response.resultDisplay === 'string'
? response.resultDisplay
: undefined,
error: response.error
? {
type: response.errorType || 'TOOL_EXECUTION_ERROR',
message: response.error.message,
}
: undefined,
});
}
// Handle explicit error printing for TEXT mode
if (response.error && config.getOutputFormat() === OutputFormat.TEXT) {
handleToolError(
response.callId, // Using callId as name fallback since name is not available in response
new Error(response.error.message),
config,
response.errorType || 'TOOL_EXECUTION_ERROR',
);
}
}
} else if (event.type === 'agent_finish') {
const { reason, message, error: _error } = event.value;
if (reason === AgentTerminateMode.MAX_TURNS) {
handleMaxTurnsExceededError(config);
} else if (
reason === AgentTerminateMode.ERROR ||
reason === AgentTerminateMode.ABORTED
) {
if (config.getOutputFormat() === OutputFormat.TEXT && message) {
process.stderr.write(`Agent execution stopped: ${message}\n`);
}
}
// Emit Final JSON
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: reason === AgentTerminateMode.ERROR ? 'error' : '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(), finalResponseText, stats),
);
} else {
textOutput.ensureTrailingNewline();
}
}
}
}
async function runLegacyManualLoop(
{
textOutput,
abortController,
streamFormatter,
startTime,
cleanupStdinCancellation: _cleanupStdinCancellation,
}: LoopContext,
{
config,
settings: _settings,
input,
prompt_id,
resumedSessionData,
query,
}: RunNonInteractiveParams & { query: Part[] },
_handleUserFeedback: (payload: UserFeedbackPayload) => void,
) {
const geminiClient = config.getGeminiClient();
const scheduler = new Scheduler({
config,
messageBus: config.getMessageBus(),
getPreferredEditor: () => undefined,
schedulerId: ROOT_SCHEDULER_ID,
});
// Initialize chat. Resume if resume data is passed.
if (resumedSessionData) {
await geminiClient.resumeChat(
convertSessionToClientHistory(resumedSessionData.conversation.messages),
resumedSessionData,
);
}
// Emit init event for streaming JSON
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.INIT,
timestamp: new Date().toISOString(),
session_id: config.getSessionId(),
model: config.getModel(),
});
}
// NOTE: Input processing now handled upstream in `runNonInteractive`
// Emit user message event for streaming JSON
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.MESSAGE,
timestamp: new Date().toISOString(),
role: 'user',
content: input,
});
}
let currentMessages: Content[] = [{ role: 'user', parts: query }];
let turnCount = 0;
while (true) {
turnCount++;
if (
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
handleMaxTurnsExceededError(config);
}
const toolCallRequests: ToolCallRequestInfo[] = [];
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
undefined,
false,
turnCount === 1 ? input : undefined,
);
let responseText = '';
for await (const event of responseStream) {
if (abortController.signal.aborted) {
handleCancellationError(config);
}
if (event.type === GeminiEventType.Content) {
const isRaw = config.getRawOutput() || config.getAcceptRawOutputRisk();
const output = isRaw ? event.value : stripAnsi(event.value);
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.MESSAGE,
timestamp: new Date().toISOString(),
role: 'assistant',
content: output,
delta: true,
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
responseText += output;
} else {
if (event.value) {
textOutput.write(output);
}
}
} else if (event.type === GeminiEventType.ToolCallRequest) {
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,
});
}
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`);
}
}
}
if (toolCallRequests.length > 0) {
textOutput.ensureTrailingNewline();
const completedToolCalls = await scheduler.schedule(
toolCallRequests,
abortController.signal,
);
const toolResponseParts: Part[] = [];
for (const completedToolCall of completedToolCalls) {
const toolResponse = completedToolCall.response;
const requestInfo = completedToolCall.request;
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.TOOL_RESULT,
timestamp: new Date().toISOString(),
tool_id: requestInfo.callId,
status: completedToolCall.status === 'error' ? 'error' : 'success',
output:
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: undefined,
error: toolResponse.error
? {
type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
message: toolResponse.error.message,
}
: undefined,
});
}
if (toolResponse.error) {
handleToolError(
requestInfo.name,
toolResponse.error,
config,
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: undefined,
);
}
if (toolResponse.responseParts) {
toolResponseParts.push(...toolResponse.responseParts);
}
}
// Record tool calls with full metadata before sending responses to Gemini
try {
const currentModel =
geminiClient.getCurrentSequenceModel() ?? config.getModel();
geminiClient
.getChat()
.recordCompletedToolCalls(currentModel, completedToolCalls);
await recordToolCallInteractions(config, completedToolCalls);
} catch (error) {
debugLogger.error(
`Error recording completed tool call information: ${error}`,
);
}
// Check if any tool requested to stop execution immediately
const stopExecutionTool = completedToolCalls.find(
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
);
if (stopExecutionTool && stopExecutionTool.response.error) {
const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`;
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`${stopMessage}\n`);
}
// Emit final result event for streaming JSON
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
textOutput.write(
formatter.format(config.getSessionId(), responseText, stats),
);
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
}
return;
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
// Emit final result event for streaming JSON
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
textOutput.write(
formatter.format(config.getSessionId(), responseText, stats),
);
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
}
return;
}
}
}
@@ -23,6 +23,7 @@ import {
RewindEvent,
type ChatRecordingService,
type GeminiClient,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
/**
@@ -54,9 +55,8 @@ async function rewindConversation(
}
// Convert to UI and Client formats
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
conversation.messages,
);
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
const clientHistory = convertSessionToClientHistory(conversation.messages);
client.setHistory(clientHistory as Content[]);
@@ -26,7 +26,6 @@ AppHeader(full)
│ Line 18 │
│ Line 19 │
│ Line 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines
"
@@ -20,7 +20,10 @@ import {
type MessageRecord,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { coreEvents } from '@google/gemini-cli-core';
import {
coreEvents,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
// Mock modules
vi.mock('fs/promises');
@@ -157,7 +160,7 @@ describe('convertSessionToHistoryFormats', () => {
it('should convert empty messages array', () => {
const result = convertSessionToHistoryFormats([]);
expect(result.uiHistory).toEqual([]);
expect(result.clientHistory).toEqual([]);
expect(convertSessionToClientHistory([])).toEqual([]);
});
it('should convert basic user and model messages', () => {
@@ -175,12 +178,13 @@ describe('convertSessionToHistoryFormats', () => {
text: 'Hi there',
});
expect(result.clientHistory).toHaveLength(2);
expect(result.clientHistory[0]).toEqual({
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(2);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Hello' }],
});
expect(result.clientHistory[1]).toEqual({
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [{ text: 'Hi there' }],
});
@@ -203,8 +207,9 @@ describe('convertSessionToHistoryFormats', () => {
text: 'User input',
});
expect(result.clientHistory).toHaveLength(1);
expect(result.clientHistory[0]).toEqual({
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(1);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Expanded content' }],
});
@@ -225,7 +230,7 @@ describe('convertSessionToHistoryFormats', () => {
text: 'Help text',
});
expect(result.clientHistory).toHaveLength(0);
expect(convertSessionToClientHistory(messages)).toHaveLength(0);
});
it('should handle tool calls and responses', () => {
@@ -264,12 +269,13 @@ describe('convertSessionToHistoryFormats', () => {
],
});
expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response)
expect(result.clientHistory[0]).toEqual({
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'What time is it?' }],
});
expect(result.clientHistory[1]).toEqual({
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [
{
@@ -281,7 +287,7 @@ describe('convertSessionToHistoryFormats', () => {
},
],
});
expect(result.clientHistory[2]).toEqual({
expect(clientHistory[2]).toEqual({
role: 'user',
parts: [
{
@@ -13,7 +13,10 @@ import type {
ConversationRecord,
ResumedSessionData,
} from '@google/gemini-cli-core';
import { coreEvents } from '@google/gemini-cli-core';
import {
coreEvents,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
@@ -77,7 +80,7 @@ export const useSessionBrowser = (
);
await onLoadHistory(
historyData.uiHistory,
historyData.clientHistory,
convertSessionToClientHistory(conversation.messages),
resumedSessionData,
);
} catch (error) {
@@ -9,6 +9,7 @@ import {
coreEvents,
type Config,
type ResumedSessionData,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import type { HistoryItemWithoutId } from '../types.js';
@@ -113,7 +114,7 @@ export function useSessionResume({
);
void loadHistoryForResume(
historyData.uiHistory,
historyData.clientHistory,
convertSessionToClientHistory(resumedSessionData.conversation.messages),
resumedSessionData,
);
}
+1 -113
View File
@@ -16,7 +16,6 @@ import {
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
import type { Part } from '@google/genai';
import { MessageType, type HistoryItemWithoutId } from '../ui/types.js';
/**
@@ -518,13 +517,12 @@ export class SessionSelector {
}
/**
* Converts session/conversation data into UI history and Gemini client history formats.
* Converts session/conversation data into UI history format.
*/
export function convertSessionToHistoryFormats(
messages: ConversationRecord['messages'],
): {
uiHistory: HistoryItemWithoutId[];
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
} {
const uiHistory: HistoryItemWithoutId[] = [];
@@ -591,117 +589,7 @@ export function convertSessionToHistoryFormats(
}
}
// Convert to Gemini client history format
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
for (const msg of messages) {
// Skip system/error messages and user slash commands
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
continue;
}
if (msg.type === 'user') {
// Skip user slash commands
const contentString = partListUnionToString(msg.content);
if (
contentString.trim().startsWith('/') ||
contentString.trim().startsWith('?')
) {
continue;
}
// Add regular user message
clientHistory.push({
role: 'user',
parts: Array.isArray(msg.content)
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(msg.content as Part[])
: [{ text: contentString }],
});
} else if (msg.type === 'gemini') {
// Handle Gemini messages with potential tool calls
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
if (hasToolCalls) {
// Create model message with function calls
const modelParts: Part[] = [];
// Add text content if present
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
modelParts.push({ text: contentString });
}
// Add function calls
for (const toolCall of msg.toolCalls!) {
modelParts.push({
functionCall: {
name: toolCall.name,
args: toolCall.args,
...(toolCall.id && { id: toolCall.id }),
},
});
}
clientHistory.push({
role: 'model',
parts: modelParts,
});
// Create single function response message with all tool call responses
const functionResponseParts: Part[] = [];
for (const toolCall of msg.toolCalls!) {
if (toolCall.result) {
// Convert PartListUnion result to function response format
let responseData: Part;
if (typeof toolCall.result === 'string') {
responseData = {
functionResponse: {
id: toolCall.id,
name: toolCall.name,
response: {
output: toolCall.result,
},
},
};
} else if (Array.isArray(toolCall.result)) {
// toolCall.result is an array containing properly formatted
// function responses
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
functionResponseParts.push(...(toolCall.result as Part[]));
continue;
} else {
// Fallback for non-array results
responseData = toolCall.result;
}
functionResponseParts.push(responseData);
}
}
// Only add user message if we have function responses
if (functionResponseParts.length > 0) {
clientHistory.push({
role: 'user',
parts: functionResponseParts,
});
}
} else {
// Regular Gemini message without tool calls
const contentString = partListUnionToString(msg.content);
if (msg.content && contentString.trim()) {
clientHistory.push({
role: 'model',
parts: [{ text: contentString }],
});
}
}
}
}
return {
uiHistory,
clientHistory,
};
}
@@ -26,6 +26,7 @@ import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
@@ -42,6 +43,15 @@ vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
convertSessionToClientHistory: vi.fn(),
};
});
describe('GeminiAgent Session Resume', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
@@ -142,9 +152,11 @@ describe('GeminiAgent Session Resume', () => {
{ role: 'model', parts: [{ text: 'Hi there' }] },
];
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
clientHistory: mockClientHistory,
uiHistory: [],
});
(convertSessionToClientHistory as unknown as Mock).mockReturnValue(
mockClientHistory,
);
const response = await agent.loadSession({
sessionId,
@@ -37,6 +37,7 @@ import {
partListUnionToString,
LlmRole,
ApprovalMode,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -53,10 +54,7 @@ import { randomUUID } from 'node:crypto';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
import { SessionSelector } from '../utils/sessionUtils.js';
export async function runZedIntegration(
config: Config,
@@ -258,9 +256,7 @@ export class GeminiAgent {
config.setFileSystemService(acpFileSystemService);
}
const { clientHistory } = convertSessionToHistoryFormats(
sessionData.messages,
);
const clientHistory = convertSessionToClientHistory(sessionData.messages);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();