refactor: break submitQuery into smaller functions (#350)

This commit is contained in:
Brandon Keiji 2025-05-14 22:14:15 +00:00 committed by GitHub
parent 1245fe4885
commit 521708e294
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 566 additions and 417 deletions

View File

@ -9,6 +9,12 @@ import { useInput } from 'ink';
import {
GeminiClient,
GeminiEventType as ServerGeminiEventType,
ServerGeminiStreamEvent as GeminiEvent,
ServerGeminiContentEvent as ContentEvent,
ServerGeminiToolCallRequestEvent as ToolCallRequestEvent,
ServerGeminiToolCallResponseEvent as ToolCallResponseEvent,
ServerGeminiToolCallConfirmationEvent as ToolCallConfirmationEvent,
ServerGeminiErrorEvent as ErrorEvent,
getErrorMessage,
isNodeError,
Config,
@ -26,6 +32,7 @@ import {
IndividualToolCallDisplay,
ToolCallStatus,
HistoryItemWithoutId,
HistoryItemToolGroup,
MessageType,
} from '../types.js';
import { isAtCommand } from '../utils/commandUtils.js';
@ -35,6 +42,17 @@ import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { useStateAndRef } from './useStateAndRef.js';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
enum StreamProcessingStatus {
Completed,
PausedForConfirmation,
UserCancelled,
Error,
}
/**
* Hook to manage the Gemini stream, handle user input, process commands,
* and interact with the Gemini API and history manager.
*/
export const useGeminiStream = (
addItem: UseHistoryManagerReturn['addItem'],
_clearItems: UseHistoryManagerReturn['clearItems'],
@ -82,240 +100,490 @@ export const useGeminiStream = (
}
});
const prepareQueryForGemini = async (
query: PartListUnion,
userMessageTimestamp: number,
signal: AbortSignal,
): Promise<{ queryToSend: PartListUnion | null; shouldProceed: boolean }> => {
if (typeof query === 'string' && query.trim().length === 0) {
return { queryToSend: null, shouldProceed: false };
}
let localQueryToSendToGemini: PartListUnion | null = null;
if (typeof query === 'string') {
const trimmedQuery = query.trim();
onDebugMessage(`User query: '${trimmedQuery}'`);
// Handle UI-only commands first
if (handleSlashCommand(trimmedQuery)) {
return { queryToSend: null, shouldProceed: false };
}
if (handleShellCommand(trimmedQuery)) {
return { queryToSend: null, shouldProceed: false };
}
// Handle @-commands (which might involve tool calls)
if (isAtCommand(trimmedQuery)) {
const atCommandResult = await handleAtCommand({
query: trimmedQuery,
config,
addItem,
onDebugMessage,
messageId: userMessageTimestamp,
signal,
});
if (!atCommandResult.shouldProceed) {
return { queryToSend: null, shouldProceed: false };
}
localQueryToSendToGemini = atCommandResult.processedQuery;
} else {
// Normal query for Gemini
addItem(
{ type: MessageType.USER, text: trimmedQuery },
userMessageTimestamp,
);
localQueryToSendToGemini = trimmedQuery;
}
} else {
// It's a function response (PartListUnion that isn't a string)
localQueryToSendToGemini = query;
}
if (localQueryToSendToGemini === null) {
onDebugMessage(
'Query processing resulted in null, not sending to Gemini.',
);
return { queryToSend: null, shouldProceed: false };
}
return { queryToSend: localQueryToSendToGemini, shouldProceed: true };
};
const ensureChatSession = async (): Promise<{
client: GeminiClient | null;
chat: Chat | null;
}> => {
const currentClient = geminiClientRef.current;
if (!currentClient) {
const errorMsg = 'Gemini client is not available.';
setInitError(errorMsg);
addItem({ type: MessageType.ERROR, text: errorMsg }, Date.now());
return { client: null, chat: null };
}
if (!chatSessionRef.current) {
try {
chatSessionRef.current = await currentClient.startChat();
} catch (err: unknown) {
const errorMsg = `Failed to start chat: ${getErrorMessage(err)}`;
setInitError(errorMsg);
addItem({ type: MessageType.ERROR, text: errorMsg }, Date.now());
setStreamingState(StreamingState.Idle);
return { client: currentClient, chat: null };
}
}
return { client: currentClient, chat: chatSessionRef.current };
};
// --- UI Helper Functions (used by event handlers) ---
const updateFunctionResponseUI = (
toolResponse: ToolCallResponseInfo,
status: ToolCallStatus,
) => {
setPendingHistoryItem((item) =>
item?.type === 'tool_group'
? {
...item,
tools: item.tools.map((tool) => {
if (tool.callId === toolResponse.callId) {
return {
...tool,
status,
resultDisplay: toolResponse.resultDisplay,
};
} else {
return tool;
}
}),
}
: null,
);
};
const updateConfirmingFunctionStatusUI = (
callId: string,
confirmationDetails: ToolCallConfirmationDetails | undefined,
) => {
if (pendingHistoryItemRef.current?.type !== 'tool_group') return;
setPendingHistoryItem((item) =>
item?.type === 'tool_group'
? {
...item,
tools: item.tools.map((tool) =>
tool.callId === callId
? {
...tool,
status: ToolCallStatus.Confirming,
confirmationDetails,
}
: tool,
),
}
: null,
);
};
// This function will be fully refactored in a later step
const wireConfirmationSubmission = (
confirmationDetails: ServerToolCallConfirmationDetails,
): ToolCallConfirmationDetails => {
const originalConfirmationDetails = confirmationDetails.details;
const request = confirmationDetails.request;
const resubmittingConfirm = async (outcome: ToolConfirmationOutcome) => {
originalConfirmationDetails.onConfirm(outcome);
if (pendingHistoryItemRef?.current?.type === 'tool_group') {
setPendingHistoryItem((item) =>
item?.type === 'tool_group'
? {
...item,
tools: item.tools.map((tool) =>
tool.callId === request.callId
? {
...tool,
confirmationDetails: undefined,
status: ToolCallStatus.Executing,
}
: tool,
),
}
: item,
);
refreshStatic();
}
if (outcome === ToolConfirmationOutcome.Cancel) {
declineToolExecution(
'User rejected function call.',
ToolCallStatus.Error,
request,
originalConfirmationDetails,
);
} else {
const tool = toolRegistry.getTool(request.name);
if (!tool) {
throw new Error(
`Tool "${request.name}" not found or is not registered.`,
);
}
try {
abortControllerRef.current = new AbortController();
const result = await tool.execute(
request.args,
abortControllerRef.current.signal,
);
if (abortControllerRef.current.signal.aborted) {
declineToolExecution(
result.llmContent,
ToolCallStatus.Canceled,
request,
originalConfirmationDetails,
);
return;
}
const functionResponse: Part = {
functionResponse: {
name: request.name,
id: request.callId,
response: { output: result.llmContent },
},
};
const responseInfo: ToolCallResponseInfo = {
callId: request.callId,
responsePart: functionResponse,
resultDisplay: result.returnDisplay,
error: undefined,
};
updateFunctionResponseUI(responseInfo, ToolCallStatus.Success);
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, Date.now());
setPendingHistoryItem(null);
}
setStreamingState(StreamingState.Idle);
await submitQuery(functionResponse); // Recursive call
} finally {
if (streamingState !== StreamingState.WaitingForConfirmation) {
abortControllerRef.current = null;
}
}
}
};
// Extracted declineToolExecution to be part of wireConfirmationSubmission's closure
// or could be a standalone helper if more params are passed.
function declineToolExecution(
declineMessage: string,
status: ToolCallStatus,
request: ServerToolCallConfirmationDetails['request'],
originalDetails: ServerToolCallConfirmationDetails['details'],
) {
let resultDisplay: ToolResultDisplay | undefined;
if ('fileDiff' in originalDetails) {
resultDisplay = {
fileDiff: (originalDetails as ToolEditConfirmationDetails).fileDiff,
};
} else {
resultDisplay = `~~${(originalDetails as ToolExecuteConfirmationDetails).command}~~`;
}
const functionResponse: Part = {
functionResponse: {
id: request.callId,
name: request.name,
response: { error: declineMessage },
},
};
const responseInfo: ToolCallResponseInfo = {
callId: request.callId,
responsePart: functionResponse,
resultDisplay,
error: new Error(declineMessage),
};
const history = chatSessionRef.current?.getHistory();
if (history) {
history.push({ role: 'model', parts: [functionResponse] });
}
updateFunctionResponseUI(responseInfo, status);
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, Date.now());
setPendingHistoryItem(null);
}
setStreamingState(StreamingState.Idle);
}
return { ...originalConfirmationDetails, onConfirm: resubmittingConfirm };
};
// --- Stream Event Handlers ---
const handleContentEvent = (
eventValue: ContentEvent['value'],
currentGeminiMessageBuffer: string,
userMessageTimestamp: number,
): string => {
let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue;
if (
pendingHistoryItemRef.current?.type !== 'gemini' &&
pendingHistoryItemRef.current?.type !== 'gemini_content'
) {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
setPendingHistoryItem({ type: 'gemini', text: '' });
newGeminiMessageBuffer = eventValue;
}
// Split large messages for better rendering performance. Ideally,
// we should maximize the amount of output sent to <Static />.
const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer);
if (splitPoint === newGeminiMessageBuffer.length) {
// Update the existing message with accumulated content
setPendingHistoryItem((item) => ({
type: item?.type as 'gemini' | 'gemini_content',
text: newGeminiMessageBuffer,
}));
} else {
// This indicates that we need to split up this Gemini Message.
// Splitting a message is primarily a performance consideration. There is a
// <Static> component at the root of App.tsx which takes care of rendering
// content statically or dynamically. Everything but the last message is
// treated as static in order to prevent re-rendering an entire message history
// multiple times per-second (as streaming occurs). Prior to this change you'd
// see heavy flickering of the terminal. This ensures that larger messages get
// broken up so that there are more "statically" rendered.
const beforeText = newGeminiMessageBuffer.substring(0, splitPoint);
const afterText = newGeminiMessageBuffer.substring(splitPoint);
addItem(
{
type: pendingHistoryItemRef.current?.type as
| 'gemini'
| 'gemini_content',
text: beforeText,
},
userMessageTimestamp,
);
setPendingHistoryItem({ type: 'gemini_content', text: afterText });
newGeminiMessageBuffer = afterText;
}
return newGeminiMessageBuffer;
};
const handleToolCallRequestEvent = (
eventValue: ToolCallRequestEvent['value'],
userMessageTimestamp: number,
) => {
const { callId, name, args } = eventValue;
const cliTool = toolRegistry.getTool(name);
if (!cliTool) {
console.error(`CLI Tool "${name}" not found!`);
return; // Skip this event if tool is not found
}
if (pendingHistoryItemRef.current?.type !== 'tool_group') {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
setPendingHistoryItem({ type: 'tool_group', tools: [] });
}
let description: string;
try {
description = cliTool.getDescription(args);
} catch (e) {
description = `Error: Unable to get description: ${getErrorMessage(e)}`;
}
const toolCallDisplay: IndividualToolCallDisplay = {
callId,
name: cliTool.displayName,
description,
status: ToolCallStatus.Pending,
resultDisplay: undefined,
confirmationDetails: undefined,
};
setPendingHistoryItem((pending) =>
pending?.type === 'tool_group'
? { ...pending, tools: [...pending.tools, toolCallDisplay] }
: null,
);
};
const handleToolCallResponseEvent = (
eventValue: ToolCallResponseEvent['value'],
) => {
const status = eventValue.error
? ToolCallStatus.Error
: ToolCallStatus.Success;
updateFunctionResponseUI(eventValue, status);
};
const handleToolCallConfirmationEvent = (
eventValue: ToolCallConfirmationEvent['value'],
) => {
const confirmationDetails = wireConfirmationSubmission(eventValue);
updateConfirmingFunctionStatusUI(
eventValue.request.callId,
confirmationDetails,
);
setStreamingState(StreamingState.WaitingForConfirmation);
};
const handleUserCancelledEvent = (userMessageTimestamp: number) => {
if (pendingHistoryItemRef.current) {
if (pendingHistoryItemRef.current.type === 'tool_group') {
const updatedTools = pendingHistoryItemRef.current.tools.map((tool) =>
tool.status === ToolCallStatus.Pending ||
tool.status === ToolCallStatus.Confirming ||
tool.status === ToolCallStatus.Executing
? { ...tool, status: ToolCallStatus.Canceled }
: tool,
);
const pendingItem: HistoryItemToolGroup = {
...pendingHistoryItemRef.current,
tools: updatedTools,
};
addItem(pendingItem, userMessageTimestamp);
} else {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
setPendingHistoryItem(null);
}
addItem(
{ type: MessageType.INFO, text: 'User cancelled the request.' },
userMessageTimestamp,
);
setStreamingState(StreamingState.Idle);
};
const handleErrorEvent = (
eventValue: ErrorEvent['value'],
userMessageTimestamp: number,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
{ type: MessageType.ERROR, text: `[API Error: ${eventValue.message}]` },
userMessageTimestamp,
);
};
const processGeminiStreamEvents = async (
stream: AsyncIterable<GeminiEvent>,
userMessageTimestamp: number,
): Promise<StreamProcessingStatus> => {
let geminiMessageBuffer = '';
for await (const event of stream) {
if (event.type === ServerGeminiEventType.Content) {
geminiMessageBuffer = handleContentEvent(
event.value,
geminiMessageBuffer,
userMessageTimestamp,
);
} else if (event.type === ServerGeminiEventType.ToolCallRequest) {
handleToolCallRequestEvent(event.value, userMessageTimestamp);
} else if (event.type === ServerGeminiEventType.ToolCallResponse) {
handleToolCallResponseEvent(event.value);
} else if (event.type === ServerGeminiEventType.ToolCallConfirmation) {
handleToolCallConfirmationEvent(event.value);
return StreamProcessingStatus.PausedForConfirmation; // Explicit return as this pauses the stream
} else if (event.type === ServerGeminiEventType.UserCancelled) {
handleUserCancelledEvent(userMessageTimestamp);
return StreamProcessingStatus.UserCancelled;
} else if (event.type === ServerGeminiEventType.Error) {
handleErrorEvent(event.value, userMessageTimestamp);
return StreamProcessingStatus.Error;
}
}
return StreamProcessingStatus.Completed;
};
const submitQuery = useCallback(
async (query: PartListUnion) => {
if (streamingState === StreamingState.Responding) return;
if (typeof query === 'string' && query.trim().length === 0) return;
const userMessageTimestamp = Date.now();
let queryToSendToGemini: PartListUnion | null = null;
setShowHelp(false);
abortControllerRef.current ??= new AbortController();
const signal = abortControllerRef.current.signal;
if (typeof query === 'string') {
const trimmedQuery = query.trim();
onDebugMessage(`User query: '${trimmedQuery}'`);
const { queryToSend, shouldProceed } = await prepareQueryForGemini(
query,
userMessageTimestamp,
signal,
);
if (handleSlashCommand(trimmedQuery)) return;
if (handleShellCommand(trimmedQuery)) return;
if (isAtCommand(trimmedQuery)) {
const atCommandResult = await handleAtCommand({
query: trimmedQuery,
config,
addItem,
onDebugMessage,
messageId: userMessageTimestamp,
signal,
});
if (!atCommandResult.shouldProceed) return;
queryToSendToGemini = atCommandResult.processedQuery;
} else {
addItem(
{ type: MessageType.USER, text: trimmedQuery },
userMessageTimestamp,
);
queryToSendToGemini = trimmedQuery;
}
} else {
queryToSendToGemini = query;
}
if (queryToSendToGemini === null) {
onDebugMessage(
'Query processing resulted in null, not sending to Gemini.',
);
if (!shouldProceed || queryToSend === null) {
return;
}
const client = geminiClientRef.current;
if (!client) {
const errorMsg = 'Gemini client is not available.';
setInitError(errorMsg);
addItem({ type: MessageType.ERROR, text: errorMsg }, Date.now());
return;
}
const { client, chat } = await ensureChatSession();
if (!chatSessionRef.current) {
try {
chatSessionRef.current = await client.startChat();
} catch (err: unknown) {
const errorMsg = `Failed to start chat: ${getErrorMessage(err)}`;
setInitError(errorMsg);
addItem({ type: MessageType.ERROR, text: errorMsg }, Date.now());
setStreamingState(StreamingState.Idle);
return;
}
if (!client || !chat) {
return;
}
setStreamingState(StreamingState.Responding);
setInitError(null);
const chat = chatSessionRef.current;
try {
const stream = client.sendMessageStream(
chat,
queryToSendToGemini,
signal,
const stream = client.sendMessageStream(chat, queryToSend, signal);
const processingStatus = await processGeminiStreamEvents(
stream,
userMessageTimestamp,
);
let geminiMessageBuffer = '';
for await (const event of stream) {
if (event.type === ServerGeminiEventType.Content) {
if (
pendingHistoryItemRef.current?.type !== 'gemini' &&
pendingHistoryItemRef.current?.type !== 'gemini_content'
) {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
setPendingHistoryItem({
type: 'gemini',
text: '',
});
geminiMessageBuffer = '';
}
geminiMessageBuffer += event.value;
// Split large messages for better rendering performance. Ideally,
// we should maximize the amount of output sent to <Static />.
const splitPoint = findLastSafeSplitPoint(geminiMessageBuffer);
if (splitPoint === geminiMessageBuffer.length) {
// Update the existing message with accumulated content
setPendingHistoryItem((item) => ({
type: item?.type as 'gemini' | 'gemini_content',
text: geminiMessageBuffer,
}));
} else {
// This indicates that we need to split up this Gemini Message.
// Splitting a message is primarily a performance consideration. There is a
// <Static> component at the root of App.tsx which takes care of rendering
// content statically or dynamically. Everything but the last message is
// treated as static in order to prevent re-rendering an entire message history
// multiple times per-second (as streaming occurs). Prior to this change you'd
// see heavy flickering of the terminal. This ensures that larger messages get
// broken up so that there are more "statically" rendered.
const beforeText = geminiMessageBuffer.substring(0, splitPoint);
const afterText = geminiMessageBuffer.substring(splitPoint);
geminiMessageBuffer = afterText;
addItem(
{
type: pendingHistoryItemRef.current?.type as
| 'gemini'
| 'gemini_content',
text: beforeText,
},
userMessageTimestamp,
);
setPendingHistoryItem({
type: 'gemini_content',
text: afterText,
});
}
} else if (event.type === ServerGeminiEventType.ToolCallRequest) {
const { callId, name, args } = event.value;
const cliTool = toolRegistry.getTool(name);
if (!cliTool) {
console.error(`CLI Tool "${name}" not found!`);
continue;
}
if (pendingHistoryItemRef.current?.type !== 'tool_group') {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
setPendingHistoryItem({
type: 'tool_group',
tools: [],
});
}
let description: string;
try {
description = cliTool.getDescription(args);
} catch (e) {
description = `Error: Unable to get description: ${getErrorMessage(e)}`;
}
const toolCallDisplay: IndividualToolCallDisplay = {
callId,
name: cliTool.displayName,
description,
status: ToolCallStatus.Pending,
resultDisplay: undefined,
confirmationDetails: undefined,
};
setPendingHistoryItem((pending) =>
pending?.type === 'tool_group'
? {
...pending,
tools: [...pending.tools, toolCallDisplay],
}
: null,
);
} else if (event.type === ServerGeminiEventType.ToolCallResponse) {
const status = event.value.error
? ToolCallStatus.Error
: ToolCallStatus.Success;
updateFunctionResponseUI(event.value, status);
} else if (
event.type === ServerGeminiEventType.ToolCallConfirmation
) {
const confirmationDetails = wireConfirmationSubmission(event.value);
updateConfirmingFunctionStatusUI(
event.value.request.callId,
confirmationDetails,
);
setStreamingState(StreamingState.WaitingForConfirmation);
return;
} else if (event.type === ServerGeminiEventType.UserCancelled) {
if (pendingHistoryItemRef.current) {
if (pendingHistoryItemRef.current.type === 'tool_group') {
const updatedTools = pendingHistoryItemRef.current.tools.map(
(tool) => {
if (
tool.status === ToolCallStatus.Pending ||
tool.status === ToolCallStatus.Confirming ||
tool.status === ToolCallStatus.Executing
) {
return { ...tool, status: ToolCallStatus.Canceled };
}
return tool;
},
);
const pendingHistoryItem = pendingHistoryItemRef.current;
pendingHistoryItem.tools = updatedTools;
addItem(pendingHistoryItem, userMessageTimestamp);
} else {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
setPendingHistoryItem(null);
}
addItem(
{ type: MessageType.INFO, text: 'User cancelled the request.' },
userMessageTimestamp,
);
setStreamingState(StreamingState.Idle);
return;
} else if (event.type === ServerGeminiEventType.Error) {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
{
type: MessageType.ERROR,
text: `[API Error: ${event.value.message}]`,
},
userMessageTimestamp,
);
}
if (
processingStatus === StreamProcessingStatus.PausedForConfirmation ||
processingStatus === StreamProcessingStatus.UserCancelled
) {
return;
}
if (pendingHistoryItemRef.current) {
@ -323,7 +591,12 @@ export const useGeminiStream = (
setPendingHistoryItem(null);
}
setStreamingState(StreamingState.Idle);
if (
processingStatus === StreamProcessingStatus.Completed ||
processingStatus === StreamProcessingStatus.Error
) {
setStreamingState(StreamingState.Idle);
}
} catch (error: unknown) {
if (!isNodeError(error) || error.name !== 'AbortError') {
addItem(
@ -336,191 +609,12 @@ export const useGeminiStream = (
}
setStreamingState(StreamingState.Idle);
} finally {
abortControllerRef.current = null;
}
function updateConfirmingFunctionStatusUI(
callId: string,
confirmationDetails: ToolCallConfirmationDetails | undefined,
) {
if (pendingHistoryItemRef.current?.type !== 'tool_group') return;
setPendingHistoryItem((item) =>
item?.type === 'tool_group'
? {
...item,
tools: item.tools.map((tool) =>
tool.callId === callId
? {
...tool,
status: ToolCallStatus.Confirming,
confirmationDetails,
}
: tool,
),
}
: null,
);
}
function updateFunctionResponseUI(
toolResponse: ToolCallResponseInfo,
status: ToolCallStatus,
) {
setPendingHistoryItem((item) =>
item?.type === 'tool_group'
? {
...item,
tools: item.tools.map((tool) => {
if (tool.callId === toolResponse.callId) {
return {
...tool,
status,
resultDisplay: toolResponse.resultDisplay,
};
} else {
return tool;
}
}),
}
: null,
);
}
function wireConfirmationSubmission(
confirmationDetails: ServerToolCallConfirmationDetails,
): ToolCallConfirmationDetails {
const originalConfirmationDetails = confirmationDetails.details;
const request = confirmationDetails.request;
const resubmittingConfirm = async (
outcome: ToolConfirmationOutcome,
) => {
originalConfirmationDetails.onConfirm(outcome);
if (pendingHistoryItemRef?.current?.type === 'tool_group') {
setPendingHistoryItem((item) =>
item?.type === 'tool_group'
? {
...item,
tools: item.tools.map((tool) =>
tool.callId === request.callId
? {
...tool,
confirmationDetails: undefined,
status: ToolCallStatus.Executing,
}
: tool,
),
}
: item,
);
refreshStatic();
}
if (outcome === ToolConfirmationOutcome.Cancel) {
declineToolExecution(
'User rejected function call.',
ToolCallStatus.Error,
);
} else {
const tool = toolRegistry.getTool(request.name);
if (!tool) {
throw new Error(
`Tool "${request.name}" not found or is not registered.`,
);
}
try {
abortControllerRef.current = new AbortController();
const result = await tool.execute(
request.args,
abortControllerRef.current.signal,
);
if (abortControllerRef.current.signal.aborted) {
declineToolExecution(
result.llmContent,
ToolCallStatus.Canceled,
);
return;
}
const functionResponse: Part = {
functionResponse: {
name: request.name,
id: request.callId,
response: { output: result.llmContent },
},
};
const responseInfo: ToolCallResponseInfo = {
callId: request.callId,
responsePart: functionResponse,
resultDisplay: result.returnDisplay,
error: undefined,
};
updateFunctionResponseUI(responseInfo, ToolCallStatus.Success);
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, Date.now());
setPendingHistoryItem(null);
}
setStreamingState(StreamingState.Idle);
await submitQuery(functionResponse);
} finally {
abortControllerRef.current = null;
}
}
function declineToolExecution(
declineMessage: string,
status: ToolCallStatus,
) {
let resultDisplay: ToolResultDisplay | undefined;
if ('fileDiff' in originalConfirmationDetails) {
resultDisplay = {
fileDiff: (
originalConfirmationDetails as ToolEditConfirmationDetails
).fileDiff,
};
} else {
resultDisplay = `~~${(originalConfirmationDetails as ToolExecuteConfirmationDetails).command}~~`;
}
const functionResponse: Part = {
functionResponse: {
id: request.callId,
name: request.name,
response: { error: declineMessage },
},
};
const responseInfo: ToolCallResponseInfo = {
callId: request.callId,
responsePart: functionResponse,
resultDisplay,
error: new Error(declineMessage),
};
const history = chatSessionRef.current?.getHistory();
if (history) {
history.push({
role: 'model',
parts: [functionResponse],
});
}
updateFunctionResponseUI(responseInfo, status);
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, Date.now());
setPendingHistoryItem(null);
}
setStreamingState(StreamingState.Idle);
}
};
return {
...originalConfirmationDetails,
onConfirm: resubmittingConfirm,
};
if (streamingState !== StreamingState.WaitingForConfirmation) {
abortControllerRef.current = null;
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
streamingState,
setShowHelp,
@ -528,11 +622,10 @@ export const useGeminiStream = (
handleShellCommand,
config,
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
toolRegistry,
refreshStatic,
onDebugMessage,
refreshStatic,
setInitError,
setStreamingState,
],
);

View File

@ -55,18 +55,47 @@ export interface HistoryItemBase {
text?: string; // Text content for user/gemini/info/error messages
}
export type HistoryItemUser = HistoryItemBase & {
type: 'user';
text: string;
};
export type HistoryItemGemini = HistoryItemBase & {
type: 'gemini';
text: string;
};
export type HistoryItemGeminiContent = HistoryItemBase & {
type: 'gemini_content';
text: string;
};
export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
};
export type HistoryItemError = HistoryItemBase & {
type: 'error';
text: string;
};
export type HistoryItemToolGroup = HistoryItemBase & {
type: 'tool_group';
tools: IndividualToolCallDisplay[];
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem.
export type HistoryItemWithoutId = HistoryItemBase &
(
| { type: 'user'; text: string }
| { type: 'gemini'; text: string }
| { type: 'gemini_content'; text: string }
| { type: 'info'; text: string }
| { type: 'error'; text: string }
| { type: 'tool_group'; tools: IndividualToolCallDisplay[] }
);
// Individually exported types extending HistoryItemBase
export type HistoryItemWithoutId =
| HistoryItemUser
| HistoryItemGemini
| HistoryItemGeminiContent
| HistoryItemInfo
| HistoryItemError
| HistoryItemToolGroup;
export type HistoryItem = HistoryItemWithoutId & { id: number };

View File

@ -78,16 +78,43 @@ export interface ServerToolCallConfirmationDetails {
details: ToolCallConfirmationDetails;
}
export type ServerGeminiContentEvent = {
type: GeminiEventType.Content;
value: string;
};
export type ServerGeminiToolCallRequestEvent = {
type: GeminiEventType.ToolCallRequest;
value: ToolCallRequestInfo;
};
export type ServerGeminiToolCallResponseEvent = {
type: GeminiEventType.ToolCallResponse;
value: ToolCallResponseInfo;
};
export type ServerGeminiToolCallConfirmationEvent = {
type: GeminiEventType.ToolCallConfirmation;
value: ServerToolCallConfirmationDetails;
};
export type ServerGeminiUserCancelledEvent = {
type: GeminiEventType.UserCancelled;
};
export type ServerGeminiErrorEvent = {
type: GeminiEventType.Error;
value: GeminiErrorEventValue;
};
// The original union type, now composed of the individual types
export type ServerGeminiStreamEvent =
| { type: GeminiEventType.Content; value: string }
| { type: GeminiEventType.ToolCallRequest; value: ToolCallRequestInfo }
| { type: GeminiEventType.ToolCallResponse; value: ToolCallResponseInfo }
| {
type: GeminiEventType.ToolCallConfirmation;
value: ServerToolCallConfirmationDetails;
}
| { type: GeminiEventType.UserCancelled }
| { type: GeminiEventType.Error; value: GeminiErrorEventValue };
| ServerGeminiContentEvent
| ServerGeminiToolCallRequestEvent
| ServerGeminiToolCallResponseEvent
| ServerGeminiToolCallConfirmationEvent
| ServerGeminiUserCancelledEvent
| ServerGeminiErrorEvent;
// A turn manages the agentic loop turn within the server context.
export class Turn {