mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
1934 lines
61 KiB
TypeScript
1934 lines
61 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
import {
|
|
GeminiEventType as ServerGeminiEventType,
|
|
getErrorMessage,
|
|
isNodeError,
|
|
MessageSenderType,
|
|
logUserPrompt,
|
|
GitService,
|
|
UnauthorizedError,
|
|
UserPromptEvent,
|
|
DEFAULT_GEMINI_FLASH_MODEL,
|
|
logConversationFinishedEvent,
|
|
ConversationFinishedEvent,
|
|
ApprovalMode,
|
|
parseAndFormatApiError,
|
|
ToolConfirmationOutcome,
|
|
MessageBusType,
|
|
promptIdContext,
|
|
tokenLimit,
|
|
debugLogger,
|
|
runInDevTraceSpan,
|
|
EDIT_TOOL_NAMES,
|
|
ASK_USER_TOOL_NAME,
|
|
processRestorableToolCalls,
|
|
recordToolCallInteractions,
|
|
ToolErrorType,
|
|
ValidationRequiredError,
|
|
coreEvents,
|
|
CoreEvent,
|
|
CoreToolCallStatus,
|
|
buildUserSteeringHintPrompt,
|
|
GeminiCliOperation,
|
|
getPlanModeExitMessage,
|
|
} from '@google/gemini-cli-core';
|
|
import type {
|
|
Config,
|
|
EditorType,
|
|
GeminiClient,
|
|
ServerGeminiChatCompressedEvent,
|
|
ServerGeminiContentEvent as ContentEvent,
|
|
ServerGeminiFinishedEvent,
|
|
ServerGeminiStreamEvent as GeminiEvent,
|
|
ThoughtSummary,
|
|
ToolCallRequestInfo,
|
|
ToolCallResponseInfo,
|
|
GeminiErrorEventValue,
|
|
RetryAttemptPayload,
|
|
} from '@google/gemini-cli-core';
|
|
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
|
import type {
|
|
HistoryItem,
|
|
HistoryItemThinking,
|
|
HistoryItemWithoutId,
|
|
HistoryItemToolGroup,
|
|
HistoryItemInfo,
|
|
IndividualToolCallDisplay,
|
|
SlashCommandProcessorResult,
|
|
HistoryItemModel,
|
|
} from '../types.js';
|
|
import { StreamingState, MessageType } from '../types.js';
|
|
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
|
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
|
import { handleAtCommand } from './atCommandProcessor.js';
|
|
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
|
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
|
import { useStateAndRef } from './useStateAndRef.js';
|
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
|
import { useLogger } from './useLogger.js';
|
|
import { SHELL_COMMAND_NAME } from '../constants.js';
|
|
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
|
|
import {
|
|
useToolScheduler,
|
|
type TrackedToolCall,
|
|
type TrackedCompletedToolCall,
|
|
type TrackedCancelledToolCall,
|
|
type TrackedWaitingToolCall,
|
|
type TrackedExecutingToolCall,
|
|
} from './useToolScheduler.js';
|
|
import { theme } from '../semantic-colors.js';
|
|
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
|
|
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { useSessionStats } from '../contexts/SessionContext.js';
|
|
import { useKeypress } from './useKeypress.js';
|
|
import type { LoadedSettings } from '../../config/settings.js';
|
|
|
|
type ToolResponseWithParts = ToolCallResponseInfo & {
|
|
llmContent?: PartListUnion;
|
|
};
|
|
|
|
interface ShellToolData {
|
|
pid?: number;
|
|
command?: string;
|
|
initialOutput?: string;
|
|
}
|
|
|
|
enum StreamProcessingStatus {
|
|
Completed,
|
|
UserCancelled,
|
|
Error,
|
|
}
|
|
|
|
const SUPPRESSED_TOOL_ERRORS_NOTE =
|
|
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for details.';
|
|
const LOW_VERBOSITY_FAILURE_NOTE =
|
|
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
|
|
|
|
function isShellToolData(data: unknown): data is ShellToolData {
|
|
if (typeof data !== 'object' || data === null) {
|
|
return false;
|
|
}
|
|
const d = data as Partial<ShellToolData>;
|
|
return (
|
|
(d.pid === undefined || typeof d.pid === 'number') &&
|
|
(d.command === undefined || typeof d.command === 'string') &&
|
|
(d.initialOutput === undefined || typeof d.initialOutput === 'string')
|
|
);
|
|
}
|
|
|
|
function showCitations(settings: LoadedSettings): boolean {
|
|
const enabled = settings.merged.ui.showCitations;
|
|
if (enabled !== undefined) {
|
|
return enabled;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Calculates the current streaming state based on tool call status and responding flag.
|
|
*/
|
|
function calculateStreamingState(
|
|
isResponding: boolean,
|
|
toolCalls: TrackedToolCall[],
|
|
): StreamingState {
|
|
if (
|
|
toolCalls.some((tc) => tc.status === CoreToolCallStatus.AwaitingApproval)
|
|
) {
|
|
return StreamingState.WaitingForConfirmation;
|
|
}
|
|
|
|
const isAnyToolActive = toolCalls.some((tc) => {
|
|
// These statuses indicate active processing
|
|
if (
|
|
tc.status === CoreToolCallStatus.Executing ||
|
|
tc.status === CoreToolCallStatus.Scheduled ||
|
|
tc.status === CoreToolCallStatus.Validating
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Terminal statuses (success, error, cancelled) still count as "Responding"
|
|
// if the result hasn't been submitted back to Gemini yet.
|
|
if (
|
|
tc.status === CoreToolCallStatus.Success ||
|
|
tc.status === CoreToolCallStatus.Error ||
|
|
tc.status === CoreToolCallStatus.Cancelled
|
|
) {
|
|
return !(tc as TrackedCompletedToolCall | TrackedCancelledToolCall)
|
|
.responseSubmittedToGemini;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
if (isResponding || isAnyToolActive) {
|
|
return StreamingState.Responding;
|
|
}
|
|
|
|
return StreamingState.Idle;
|
|
}
|
|
|
|
/**
|
|
* Manages the Gemini stream, including user input, command processing,
|
|
* API interaction, and tool call lifecycle.
|
|
*/
|
|
export const useGeminiStream = (
|
|
geminiClient: GeminiClient,
|
|
history: HistoryItem[],
|
|
addItem: UseHistoryManagerReturn['addItem'],
|
|
config: Config,
|
|
settings: LoadedSettings,
|
|
onDebugMessage: (message: string) => void,
|
|
handleSlashCommand: (
|
|
cmd: PartListUnion,
|
|
) => Promise<SlashCommandProcessorResult | false>,
|
|
shellModeActive: boolean,
|
|
getPreferredEditor: () => EditorType | undefined,
|
|
onAuthError: (error: string) => void,
|
|
performMemoryRefresh: () => Promise<void>,
|
|
modelSwitchedFromQuotaError: boolean,
|
|
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
|
onCancelSubmit: (shouldRestorePrompt?: boolean) => void,
|
|
setShellInputFocused: (value: boolean) => void,
|
|
terminalWidth: number,
|
|
terminalHeight: number,
|
|
isShellFocused?: boolean,
|
|
consumeUserHint?: () => string | null,
|
|
) => {
|
|
const [initError, setInitError] = useState<string | null>(null);
|
|
const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
|
|
null,
|
|
);
|
|
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
|
|
const suppressedToolErrorCountRef = useRef(0);
|
|
const suppressedToolErrorNoteShownRef = useRef(false);
|
|
const lowVerbosityFailureNoteShownRef = useRef(false);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const turnCancelledRef = useRef(false);
|
|
const activeQueryIdRef = useRef<string | null>(null);
|
|
const previousApprovalModeRef = useRef<ApprovalMode>(
|
|
config.getApprovalMode(),
|
|
);
|
|
const [isResponding, setIsRespondingState] = useState<boolean>(false);
|
|
const isRespondingRef = useRef<boolean>(false);
|
|
const setIsResponding = useCallback(
|
|
(value: boolean) => {
|
|
setIsRespondingState(value);
|
|
isRespondingRef.current = value;
|
|
},
|
|
[setIsRespondingState],
|
|
);
|
|
const [thought, thoughtRef, setThought] =
|
|
useStateAndRef<ThoughtSummary | null>(null);
|
|
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
|
|
useStateAndRef<HistoryItemWithoutId | null>(null);
|
|
|
|
const [lastGeminiActivityTime, setLastGeminiActivityTime] =
|
|
useState<number>(0);
|
|
const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
|
|
useStateAndRef<Set<string>>(new Set());
|
|
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
|
|
useStateAndRef<boolean>(true);
|
|
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
|
|
const { startNewPrompt, getPromptCount } = useSessionStats();
|
|
const storage = config.storage;
|
|
const logger = useLogger(storage);
|
|
const gitService = useMemo(() => {
|
|
if (!config.getProjectRoot()) {
|
|
return;
|
|
}
|
|
return new GitService(config.getProjectRoot(), storage);
|
|
}, [config, storage]);
|
|
|
|
useEffect(() => {
|
|
const handleRetryAttempt = (payload: RetryAttemptPayload) => {
|
|
setRetryStatus(payload);
|
|
};
|
|
coreEvents.on(CoreEvent.RetryAttempt, handleRetryAttempt);
|
|
return () => {
|
|
coreEvents.off(CoreEvent.RetryAttempt, handleRetryAttempt);
|
|
};
|
|
}, []);
|
|
|
|
const [
|
|
toolCalls,
|
|
scheduleToolCalls,
|
|
markToolsAsSubmitted,
|
|
setToolCallsForDisplay,
|
|
cancelAllToolCalls,
|
|
lastToolOutputTime,
|
|
] = useToolScheduler(
|
|
async (completedToolCallsFromScheduler) => {
|
|
// This onComplete is called when ALL scheduled tools for a given batch are done.
|
|
if (completedToolCallsFromScheduler.length > 0) {
|
|
// Add only the tools that haven't been pushed to history yet.
|
|
const toolsToPush = completedToolCallsFromScheduler.filter(
|
|
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
|
|
);
|
|
if (toolsToPush.length > 0) {
|
|
addItem(
|
|
mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], {
|
|
borderTop: isFirstToolInGroupRef.current,
|
|
borderBottom: true,
|
|
borderColor: theme.border.default,
|
|
borderDimColor: false,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Clear the live-updating display now that the final state is in history.
|
|
setToolCallsForDisplay([]);
|
|
|
|
// Record tool calls with full metadata before sending responses.
|
|
try {
|
|
const currentModel =
|
|
config.getGeminiClient().getCurrentSequenceModel() ??
|
|
config.getModel();
|
|
config
|
|
.getGeminiClient()
|
|
.getChat()
|
|
.recordCompletedToolCalls(
|
|
currentModel,
|
|
completedToolCallsFromScheduler,
|
|
);
|
|
|
|
await recordToolCallInteractions(
|
|
config,
|
|
completedToolCallsFromScheduler,
|
|
);
|
|
} catch (error) {
|
|
debugLogger.warn(
|
|
`Error recording completed tool call information: ${error}`,
|
|
);
|
|
}
|
|
|
|
// Handle tool response submission immediately when tools complete
|
|
await handleCompletedTools(
|
|
completedToolCallsFromScheduler as TrackedToolCall[],
|
|
);
|
|
}
|
|
},
|
|
config,
|
|
getPreferredEditor,
|
|
);
|
|
|
|
const activeToolPtyId = useMemo(() => {
|
|
const executingShellTool = toolCalls.find(
|
|
(tc) =>
|
|
tc.status === 'executing' && tc.request.name === 'run_shell_command',
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
|
|
}, [toolCalls]);
|
|
|
|
const onExec = useCallback(
|
|
async (done: Promise<void>) => {
|
|
setIsResponding(true);
|
|
await done;
|
|
setIsResponding(false);
|
|
},
|
|
[setIsResponding],
|
|
);
|
|
|
|
const {
|
|
handleShellCommand,
|
|
activeShellPtyId,
|
|
lastShellOutputTime,
|
|
backgroundShellCount,
|
|
isBackgroundShellVisible,
|
|
toggleBackgroundShell,
|
|
backgroundCurrentShell,
|
|
registerBackgroundShell,
|
|
dismissBackgroundShell,
|
|
backgroundShells,
|
|
} = useShellCommandProcessor(
|
|
addItem,
|
|
setPendingHistoryItem,
|
|
onExec,
|
|
onDebugMessage,
|
|
config,
|
|
geminiClient,
|
|
setShellInputFocused,
|
|
terminalWidth,
|
|
terminalHeight,
|
|
activeToolPtyId,
|
|
);
|
|
|
|
const streamingState = useMemo(
|
|
() => calculateStreamingState(isResponding, toolCalls),
|
|
[isResponding, toolCalls],
|
|
);
|
|
|
|
// Reset tracking when a new batch of tools starts
|
|
useEffect(() => {
|
|
if (toolCalls.length > 0) {
|
|
const isNewBatch = !toolCalls.some((tc) =>
|
|
pushedToolCallIdsRef.current.has(tc.request.callId),
|
|
);
|
|
if (isNewBatch) {
|
|
setPushedToolCallIds(new Set());
|
|
setIsFirstToolInGroup(true);
|
|
}
|
|
} else if (streamingState === StreamingState.Idle) {
|
|
// Clear when idle to be ready for next turn
|
|
setPushedToolCallIds(new Set());
|
|
setIsFirstToolInGroup(true);
|
|
}
|
|
}, [
|
|
toolCalls,
|
|
pushedToolCallIdsRef,
|
|
setPushedToolCallIds,
|
|
setIsFirstToolInGroup,
|
|
streamingState,
|
|
]);
|
|
|
|
// Push completed tools to history as they finish
|
|
useEffect(() => {
|
|
const toolsToPush: TrackedToolCall[] = [];
|
|
for (const tc of toolCalls) {
|
|
if (pushedToolCallIdsRef.current.has(tc.request.callId)) continue;
|
|
|
|
if (
|
|
tc.status === 'success' ||
|
|
tc.status === 'error' ||
|
|
tc.status === 'cancelled'
|
|
) {
|
|
toolsToPush.push(tc);
|
|
} else {
|
|
// Stop at first non-terminal tool to preserve order
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (toolsToPush.length > 0) {
|
|
const newPushed = new Set(pushedToolCallIdsRef.current);
|
|
let isFirst = isFirstToolInGroupRef.current;
|
|
|
|
for (const tc of toolsToPush) {
|
|
newPushed.add(tc.request.callId);
|
|
const isLastInBatch = tc === toolCalls[toolCalls.length - 1];
|
|
|
|
const historyItem = mapTrackedToolCallsToDisplay(tc, {
|
|
borderTop: isFirst,
|
|
borderBottom: isLastInBatch,
|
|
...getToolGroupBorderAppearance(
|
|
{ type: 'tool_group', tools: toolCalls },
|
|
activeShellPtyId,
|
|
!!isShellFocused,
|
|
[],
|
|
backgroundShells,
|
|
),
|
|
});
|
|
addItem(historyItem);
|
|
isFirst = false;
|
|
}
|
|
|
|
setPushedToolCallIds(newPushed);
|
|
setIsFirstToolInGroup(false);
|
|
}
|
|
}, [
|
|
toolCalls,
|
|
pushedToolCallIdsRef,
|
|
isFirstToolInGroupRef,
|
|
setPushedToolCallIds,
|
|
setIsFirstToolInGroup,
|
|
addItem,
|
|
activeShellPtyId,
|
|
isShellFocused,
|
|
backgroundShells,
|
|
]);
|
|
|
|
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
|
|
const remainingTools = toolCalls.filter(
|
|
(tc) => !pushedToolCallIds.has(tc.request.callId),
|
|
);
|
|
|
|
const items: HistoryItemWithoutId[] = [];
|
|
|
|
const appearance = getToolGroupBorderAppearance(
|
|
{ type: 'tool_group', tools: toolCalls },
|
|
activeShellPtyId,
|
|
!!isShellFocused,
|
|
[],
|
|
backgroundShells,
|
|
);
|
|
|
|
if (remainingTools.length > 0) {
|
|
items.push(
|
|
mapTrackedToolCallsToDisplay(remainingTools, {
|
|
borderTop: pushedToolCallIds.size === 0,
|
|
borderBottom: false, // Stay open to connect with the slice below
|
|
...appearance,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Always show a bottom border slice if we have ANY tools in the batch
|
|
// and we haven't finished pushing the whole batch to history yet.
|
|
// Once all tools are terminal and pushed, the last history item handles the closing border.
|
|
const allTerminal =
|
|
toolCalls.length > 0 &&
|
|
toolCalls.every(
|
|
(tc) =>
|
|
tc.status === 'success' ||
|
|
tc.status === 'error' ||
|
|
tc.status === 'cancelled',
|
|
);
|
|
|
|
const allPushed =
|
|
toolCalls.length > 0 &&
|
|
toolCalls.every((tc) => pushedToolCallIds.has(tc.request.callId));
|
|
|
|
const anyVisibleInHistory = pushedToolCallIds.size > 0;
|
|
const anyVisibleInPending = remainingTools.some((tc) => {
|
|
// AskUser tools are rendered by AskUserDialog, not ToolGroupMessage
|
|
const isInProgress =
|
|
tc.status !== 'success' &&
|
|
tc.status !== 'error' &&
|
|
tc.status !== 'cancelled';
|
|
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
|
|
return false;
|
|
}
|
|
return (
|
|
tc.status !== 'scheduled' &&
|
|
tc.status !== 'validating' &&
|
|
tc.status !== 'awaiting_approval'
|
|
);
|
|
});
|
|
|
|
if (
|
|
toolCalls.length > 0 &&
|
|
!(allTerminal && allPushed) &&
|
|
(anyVisibleInHistory || anyVisibleInPending)
|
|
) {
|
|
items.push({
|
|
type: 'tool_group' as const,
|
|
tools: [] as IndividualToolCallDisplay[],
|
|
borderTop: false,
|
|
borderBottom: true,
|
|
...appearance,
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}, [
|
|
toolCalls,
|
|
pushedToolCallIds,
|
|
activeShellPtyId,
|
|
isShellFocused,
|
|
backgroundShells,
|
|
]);
|
|
|
|
const lastQueryRef = useRef<PartListUnion | null>(null);
|
|
const lastPromptIdRef = useRef<string | null>(null);
|
|
const loopDetectedRef = useRef(false);
|
|
const [
|
|
loopDetectionConfirmationRequest,
|
|
setLoopDetectionConfirmationRequest,
|
|
] = useState<{
|
|
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
|
} | null>(null);
|
|
|
|
const activePtyId = activeShellPtyId || activeToolPtyId;
|
|
|
|
const prevActiveShellPtyIdRef = useRef<number | null>(null);
|
|
useEffect(() => {
|
|
if (
|
|
turnCancelledRef.current &&
|
|
prevActiveShellPtyIdRef.current !== null &&
|
|
activeShellPtyId === null
|
|
) {
|
|
addItem({ type: MessageType.INFO, text: 'Request cancelled.' });
|
|
setIsResponding(false);
|
|
}
|
|
prevActiveShellPtyIdRef.current = activeShellPtyId;
|
|
}, [activeShellPtyId, addItem, setIsResponding]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
config.getApprovalMode() === ApprovalMode.YOLO &&
|
|
streamingState === StreamingState.Idle
|
|
) {
|
|
const lastUserMessageIndex = history.findLastIndex(
|
|
(item: HistoryItem) => item.type === MessageType.USER,
|
|
);
|
|
|
|
const turnCount =
|
|
lastUserMessageIndex === -1 ? 0 : history.length - lastUserMessageIndex;
|
|
|
|
if (turnCount > 0) {
|
|
logConversationFinishedEvent(
|
|
config,
|
|
new ConversationFinishedEvent(config.getApprovalMode(), turnCount),
|
|
);
|
|
}
|
|
}
|
|
}, [streamingState, config, history]);
|
|
|
|
useEffect(() => {
|
|
if (!isResponding) {
|
|
setRetryStatus(null);
|
|
}
|
|
}, [isResponding]);
|
|
|
|
const maybeAddSuppressedToolErrorNote = useCallback(
|
|
(userMessageTimestamp?: number) => {
|
|
if (!isLowErrorVerbosity) {
|
|
return;
|
|
}
|
|
if (suppressedToolErrorCountRef.current === 0) {
|
|
return;
|
|
}
|
|
if (suppressedToolErrorNoteShownRef.current) {
|
|
return;
|
|
}
|
|
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: SUPPRESSED_TOOL_ERRORS_NOTE,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
suppressedToolErrorNoteShownRef.current = true;
|
|
},
|
|
[addItem, isLowErrorVerbosity],
|
|
);
|
|
|
|
const maybeAddLowVerbosityFailureNote = useCallback(
|
|
(userMessageTimestamp?: number) => {
|
|
if (!isLowErrorVerbosity || config.getDebugMode()) {
|
|
return;
|
|
}
|
|
if (
|
|
lowVerbosityFailureNoteShownRef.current ||
|
|
suppressedToolErrorNoteShownRef.current
|
|
) {
|
|
return;
|
|
}
|
|
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: LOW_VERBOSITY_FAILURE_NOTE,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
lowVerbosityFailureNoteShownRef.current = true;
|
|
},
|
|
[addItem, config, isLowErrorVerbosity],
|
|
);
|
|
|
|
const cancelOngoingRequest = useCallback(() => {
|
|
if (
|
|
streamingState !== StreamingState.Responding &&
|
|
streamingState !== StreamingState.WaitingForConfirmation
|
|
) {
|
|
return;
|
|
}
|
|
if (turnCancelledRef.current) {
|
|
return;
|
|
}
|
|
turnCancelledRef.current = true;
|
|
|
|
// A full cancellation means no tools have produced a final result yet.
|
|
// This determines if we show a generic "Request cancelled" message.
|
|
const isFullCancellation = !toolCalls.some(
|
|
(tc) => tc.status === 'success' || tc.status === 'error',
|
|
);
|
|
|
|
// Ensure we have an abort controller, creating one if it doesn't exist.
|
|
if (!abortControllerRef.current) {
|
|
abortControllerRef.current = new AbortController();
|
|
}
|
|
|
|
// The order is important here.
|
|
// 1. Fire the signal to interrupt any active async operations.
|
|
abortControllerRef.current.abort();
|
|
// 2. Call the imperative cancel to clear the queue of pending tools.
|
|
cancelAllToolCalls(abortControllerRef.current.signal);
|
|
|
|
if (pendingHistoryItemRef.current) {
|
|
const isShellCommand =
|
|
pendingHistoryItemRef.current.type === 'tool_group' &&
|
|
pendingHistoryItemRef.current.tools.some(
|
|
(t) => t.name === SHELL_COMMAND_NAME,
|
|
);
|
|
|
|
// If it is a shell command, we update the status to Canceled and clear the output
|
|
// to avoid artifacts, then add it to history immediately.
|
|
if (isShellCommand) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const toolGroup = pendingHistoryItemRef.current as HistoryItemToolGroup;
|
|
const updatedTools = toolGroup.tools.map((tool) => {
|
|
if (tool.name === SHELL_COMMAND_NAME) {
|
|
return {
|
|
...tool,
|
|
status: CoreToolCallStatus.Cancelled,
|
|
resultDisplay: tool.resultDisplay,
|
|
};
|
|
}
|
|
return tool;
|
|
});
|
|
addItem({ ...toolGroup, tools: updatedTools } as HistoryItemWithoutId);
|
|
} else {
|
|
addItem(pendingHistoryItemRef.current);
|
|
}
|
|
}
|
|
setPendingHistoryItem(null);
|
|
|
|
// If it was a full cancellation, add the info message now.
|
|
// Otherwise, we let handleCompletedTools figure out the next step,
|
|
// which might involve sending partial results back to the model.
|
|
if (isFullCancellation) {
|
|
// If shell is active, we delay this message to ensure correct ordering
|
|
// (Shell item first, then Info message).
|
|
if (!activeShellPtyId) {
|
|
addItem({
|
|
type: MessageType.INFO,
|
|
text: 'Request cancelled.',
|
|
});
|
|
setIsResponding(false);
|
|
}
|
|
}
|
|
|
|
onCancelSubmit(false);
|
|
setShellInputFocused(false);
|
|
}, [
|
|
streamingState,
|
|
addItem,
|
|
setPendingHistoryItem,
|
|
onCancelSubmit,
|
|
pendingHistoryItemRef,
|
|
setShellInputFocused,
|
|
cancelAllToolCalls,
|
|
toolCalls,
|
|
activeShellPtyId,
|
|
setIsResponding,
|
|
]);
|
|
|
|
useKeypress(
|
|
(key) => {
|
|
if (key.name === 'escape' && !isShellFocused) {
|
|
cancelOngoingRequest();
|
|
}
|
|
},
|
|
{
|
|
isActive:
|
|
streamingState === StreamingState.Responding ||
|
|
streamingState === StreamingState.WaitingForConfirmation,
|
|
},
|
|
);
|
|
|
|
const prepareQueryForGemini = useCallback(
|
|
async (
|
|
query: PartListUnion,
|
|
userMessageTimestamp: number,
|
|
abortSignal: AbortSignal,
|
|
prompt_id: string,
|
|
): Promise<{
|
|
queryToSend: PartListUnion | null;
|
|
shouldProceed: boolean;
|
|
}> => {
|
|
if (turnCancelledRef.current) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
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();
|
|
await logger?.logMessage(MessageSenderType.USER, trimmedQuery);
|
|
|
|
if (!shellModeActive) {
|
|
// Handle UI-only commands first
|
|
const slashCommandResult = isSlashCommand(trimmedQuery)
|
|
? await handleSlashCommand(trimmedQuery)
|
|
: false;
|
|
|
|
if (slashCommandResult) {
|
|
switch (slashCommandResult.type) {
|
|
case 'schedule_tool': {
|
|
const { toolName, toolArgs, postSubmitPrompt } =
|
|
slashCommandResult;
|
|
const toolCallRequest: ToolCallRequestInfo = {
|
|
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
name: toolName,
|
|
args: toolArgs,
|
|
isClientInitiated: true,
|
|
prompt_id,
|
|
};
|
|
await scheduleToolCalls([toolCallRequest], abortSignal);
|
|
|
|
if (postSubmitPrompt) {
|
|
localQueryToSendToGemini = postSubmitPrompt;
|
|
return {
|
|
queryToSend: localQueryToSendToGemini,
|
|
shouldProceed: true,
|
|
};
|
|
}
|
|
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
case 'submit_prompt': {
|
|
localQueryToSendToGemini = slashCommandResult.content;
|
|
|
|
return {
|
|
queryToSend: localQueryToSendToGemini,
|
|
shouldProceed: true,
|
|
};
|
|
}
|
|
case 'handled': {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
default: {
|
|
const unreachable: never = slashCommandResult;
|
|
throw new Error(
|
|
`Unhandled slash command result type: ${unreachable}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
|
|
// Handle @-commands (which might involve tool calls)
|
|
if (isAtCommand(trimmedQuery)) {
|
|
// Add user's turn before @ command processing for correct UI ordering.
|
|
addItem(
|
|
{ type: MessageType.USER, text: trimmedQuery },
|
|
userMessageTimestamp,
|
|
);
|
|
|
|
const atCommandResult = await handleAtCommand({
|
|
query: trimmedQuery,
|
|
config,
|
|
addItem,
|
|
onDebugMessage,
|
|
messageId: userMessageTimestamp,
|
|
signal: abortSignal,
|
|
});
|
|
|
|
if (atCommandResult.error) {
|
|
onDebugMessage(atCommandResult.error);
|
|
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 };
|
|
},
|
|
[
|
|
config,
|
|
addItem,
|
|
onDebugMessage,
|
|
handleShellCommand,
|
|
handleSlashCommand,
|
|
logger,
|
|
shellModeActive,
|
|
scheduleToolCalls,
|
|
],
|
|
);
|
|
|
|
// --- Stream Event Handlers ---
|
|
|
|
const handleContentEvent = useCallback(
|
|
(
|
|
eventValue: ContentEvent['value'],
|
|
currentGeminiMessageBuffer: string,
|
|
userMessageTimestamp: number,
|
|
): string => {
|
|
setRetryStatus(null);
|
|
if (turnCancelledRef.current) {
|
|
// Prevents additional output after a user initiated cancel.
|
|
return '';
|
|
}
|
|
let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue;
|
|
if (
|
|
pendingHistoryItemRef.current?.type !== 'gemini' &&
|
|
pendingHistoryItemRef.current?.type !== 'gemini_content'
|
|
) {
|
|
// Flush any pending item before starting 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) => ({
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
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);
|
|
if (beforeText.length > 0) {
|
|
addItem(
|
|
{
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
type: pendingHistoryItemRef.current?.type as
|
|
| 'gemini'
|
|
| 'gemini_content',
|
|
text: beforeText,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
setPendingHistoryItem({ type: 'gemini_content', text: afterText });
|
|
newGeminiMessageBuffer = afterText;
|
|
}
|
|
return newGeminiMessageBuffer;
|
|
},
|
|
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
|
);
|
|
|
|
const handleThoughtEvent = useCallback(
|
|
(eventValue: ThoughtSummary, _userMessageTimestamp: number) => {
|
|
setThought(eventValue);
|
|
|
|
if (getInlineThinkingMode(settings) === 'full') {
|
|
addItem({
|
|
type: 'thinking',
|
|
thought: eventValue,
|
|
} as HistoryItemThinking);
|
|
}
|
|
},
|
|
[addItem, settings, setThought],
|
|
);
|
|
|
|
const handleUserCancelledEvent = useCallback(
|
|
(userMessageTimestamp: number) => {
|
|
if (turnCancelledRef.current) {
|
|
return;
|
|
}
|
|
if (pendingHistoryItemRef.current) {
|
|
if (pendingHistoryItemRef.current.type === 'tool_group') {
|
|
const updatedTools = pendingHistoryItemRef.current.tools.map(
|
|
(tool) =>
|
|
tool.status === CoreToolCallStatus.Validating ||
|
|
tool.status === CoreToolCallStatus.Scheduled ||
|
|
tool.status === CoreToolCallStatus.AwaitingApproval ||
|
|
tool.status === CoreToolCallStatus.Executing
|
|
? { ...tool, status: CoreToolCallStatus.Cancelled }
|
|
: 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,
|
|
);
|
|
setIsResponding(false);
|
|
setThought(null); // Reset thought when user cancels
|
|
},
|
|
[
|
|
addItem,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
setThought,
|
|
setIsResponding,
|
|
],
|
|
);
|
|
|
|
const handleErrorEvent = useCallback(
|
|
(eventValue: GeminiErrorEventValue, userMessageTimestamp: number) => {
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
|
|
addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: parseAndFormatApiError(
|
|
eventValue.error,
|
|
config.getContentGeneratorConfig()?.authType,
|
|
undefined,
|
|
config.getModel(),
|
|
DEFAULT_GEMINI_FLASH_MODEL,
|
|
),
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
|
setThought(null); // Reset thought when there's an error
|
|
},
|
|
[
|
|
addItem,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
config,
|
|
setThought,
|
|
maybeAddSuppressedToolErrorNote,
|
|
maybeAddLowVerbosityFailureNote,
|
|
],
|
|
);
|
|
|
|
const handleCitationEvent = useCallback(
|
|
(text: string, userMessageTimestamp: number) => {
|
|
if (!showCitations(settings)) {
|
|
return;
|
|
}
|
|
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem({ type: MessageType.INFO, text }, userMessageTimestamp);
|
|
},
|
|
[addItem, pendingHistoryItemRef, setPendingHistoryItem, settings],
|
|
);
|
|
|
|
const handleFinishedEvent = useCallback(
|
|
(event: ServerGeminiFinishedEvent, userMessageTimestamp: number) => {
|
|
const finishReason = event.value.reason;
|
|
if (!finishReason) {
|
|
return;
|
|
}
|
|
|
|
const finishReasonMessages: Record<FinishReason, string | undefined> = {
|
|
[FinishReason.FINISH_REASON_UNSPECIFIED]: undefined,
|
|
[FinishReason.STOP]: undefined,
|
|
[FinishReason.MAX_TOKENS]: 'Response truncated due to token limits.',
|
|
[FinishReason.SAFETY]: 'Response stopped due to safety reasons.',
|
|
[FinishReason.RECITATION]: 'Response stopped due to recitation policy.',
|
|
[FinishReason.LANGUAGE]:
|
|
'Response stopped due to unsupported language.',
|
|
[FinishReason.BLOCKLIST]: 'Response stopped due to forbidden terms.',
|
|
[FinishReason.PROHIBITED_CONTENT]:
|
|
'Response stopped due to prohibited content.',
|
|
[FinishReason.SPII]:
|
|
'Response stopped due to sensitive personally identifiable information.',
|
|
[FinishReason.OTHER]: 'Response stopped for other reasons.',
|
|
[FinishReason.MALFORMED_FUNCTION_CALL]:
|
|
'Response stopped due to malformed function call.',
|
|
[FinishReason.IMAGE_SAFETY]:
|
|
'Response stopped due to image safety violations.',
|
|
[FinishReason.UNEXPECTED_TOOL_CALL]:
|
|
'Response stopped due to unexpected tool call.',
|
|
[FinishReason.IMAGE_PROHIBITED_CONTENT]:
|
|
'Response stopped due to prohibited image content.',
|
|
[FinishReason.NO_IMAGE]:
|
|
'Response stopped because no image was generated.',
|
|
[FinishReason.IMAGE_RECITATION]:
|
|
'Response stopped due to image recitation policy.',
|
|
[FinishReason.IMAGE_OTHER]:
|
|
'Response stopped due to other image-related reasons.',
|
|
};
|
|
|
|
const message = finishReasonMessages[finishReason];
|
|
if (message) {
|
|
addItem(
|
|
{
|
|
type: 'info',
|
|
text: `⚠️ ${message}`,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
},
|
|
[addItem],
|
|
);
|
|
|
|
const handleChatCompressionEvent = useCallback(
|
|
(
|
|
eventValue: ServerGeminiChatCompressedEvent['value'],
|
|
userMessageTimestamp: number,
|
|
) => {
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
|
|
const limit = tokenLimit(config.getModel());
|
|
const originalPercentage = Math.round(
|
|
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
|
|
);
|
|
const newPercentage = Math.round(
|
|
((eventValue?.newTokenCount ?? 0) / limit) * 100,
|
|
);
|
|
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
|
|
secondaryText: `Change threshold in /settings.`,
|
|
color: theme.status.warning,
|
|
marginBottom: 1,
|
|
} as HistoryItemInfo,
|
|
userMessageTimestamp,
|
|
);
|
|
},
|
|
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
|
|
);
|
|
|
|
const handleMaxSessionTurnsEvent = useCallback(
|
|
() =>
|
|
addItem({
|
|
type: 'info',
|
|
text:
|
|
`The session has reached the maximum number of turns: ${config.getMaxSessionTurns()}. ` +
|
|
`Please update this limit in your setting.json file.`,
|
|
}),
|
|
[addItem, config],
|
|
);
|
|
|
|
const handleContextWindowWillOverflowEvent = useCallback(
|
|
(estimatedRequestTokenCount: number, remainingTokenCount: number) => {
|
|
onCancelSubmit(true);
|
|
|
|
const limit = tokenLimit(config.getModel());
|
|
|
|
const isMoreThan25PercentUsed =
|
|
limit > 0 && remainingTokenCount < limit * 0.75;
|
|
|
|
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
|
|
|
|
if (isMoreThan25PercentUsed) {
|
|
text +=
|
|
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
|
|
}
|
|
|
|
addItem({
|
|
type: 'info',
|
|
text,
|
|
});
|
|
},
|
|
[addItem, onCancelSubmit, config],
|
|
);
|
|
|
|
const handleChatModelEvent = useCallback(
|
|
(eventValue: string, userMessageTimestamp: number) => {
|
|
if (!settings.merged.ui.showModelInfoInChat) {
|
|
return;
|
|
}
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{
|
|
type: 'model',
|
|
model: eventValue,
|
|
} as HistoryItemModel,
|
|
userMessageTimestamp,
|
|
);
|
|
},
|
|
[addItem, pendingHistoryItemRef, setPendingHistoryItem, settings],
|
|
);
|
|
|
|
const handleAgentExecutionStoppedEvent = useCallback(
|
|
(
|
|
reason: string,
|
|
userMessageTimestamp: number,
|
|
systemMessage?: string,
|
|
contextCleared?: boolean,
|
|
) => {
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: `Agent execution stopped: ${systemMessage?.trim() || reason}`,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
|
if (contextCleared) {
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'Conversation context has been cleared.',
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
setIsResponding(false);
|
|
},
|
|
[
|
|
addItem,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
setIsResponding,
|
|
maybeAddLowVerbosityFailureNote,
|
|
],
|
|
);
|
|
|
|
const handleAgentExecutionBlockedEvent = useCallback(
|
|
(
|
|
reason: string,
|
|
userMessageTimestamp: number,
|
|
systemMessage?: string,
|
|
contextCleared?: boolean,
|
|
) => {
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{
|
|
type: MessageType.WARNING,
|
|
text: `Agent execution blocked: ${systemMessage?.trim() || reason}`,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
|
if (contextCleared) {
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'Conversation context has been cleared.',
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
},
|
|
[
|
|
addItem,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
maybeAddLowVerbosityFailureNote,
|
|
],
|
|
);
|
|
|
|
const processGeminiStreamEvents = useCallback(
|
|
async (
|
|
stream: AsyncIterable<GeminiEvent>,
|
|
userMessageTimestamp: number,
|
|
signal: AbortSignal,
|
|
): Promise<StreamProcessingStatus> => {
|
|
let geminiMessageBuffer = '';
|
|
const toolCallRequests: ToolCallRequestInfo[] = [];
|
|
for await (const event of stream) {
|
|
if (
|
|
event.type !== ServerGeminiEventType.Thought &&
|
|
thoughtRef.current !== null
|
|
) {
|
|
setThought(null);
|
|
}
|
|
|
|
switch (event.type) {
|
|
case ServerGeminiEventType.Thought:
|
|
setLastGeminiActivityTime(Date.now());
|
|
handleThoughtEvent(event.value, userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.Content:
|
|
setLastGeminiActivityTime(Date.now());
|
|
geminiMessageBuffer = handleContentEvent(
|
|
event.value,
|
|
geminiMessageBuffer,
|
|
userMessageTimestamp,
|
|
);
|
|
break;
|
|
case ServerGeminiEventType.ToolCallRequest:
|
|
toolCallRequests.push(event.value);
|
|
break;
|
|
case ServerGeminiEventType.UserCancelled:
|
|
handleUserCancelledEvent(userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.Error:
|
|
handleErrorEvent(event.value, userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.AgentExecutionStopped:
|
|
handleAgentExecutionStoppedEvent(
|
|
event.value.reason,
|
|
userMessageTimestamp,
|
|
event.value.systemMessage,
|
|
event.value.contextCleared,
|
|
);
|
|
break;
|
|
case ServerGeminiEventType.AgentExecutionBlocked:
|
|
handleAgentExecutionBlockedEvent(
|
|
event.value.reason,
|
|
userMessageTimestamp,
|
|
event.value.systemMessage,
|
|
event.value.contextCleared,
|
|
);
|
|
break;
|
|
case ServerGeminiEventType.ChatCompressed:
|
|
handleChatCompressionEvent(event.value, userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.ToolCallConfirmation:
|
|
case ServerGeminiEventType.ToolCallResponse:
|
|
// do nothing
|
|
break;
|
|
case ServerGeminiEventType.MaxSessionTurns:
|
|
handleMaxSessionTurnsEvent();
|
|
break;
|
|
case ServerGeminiEventType.ContextWindowWillOverflow:
|
|
handleContextWindowWillOverflowEvent(
|
|
event.value.estimatedRequestTokenCount,
|
|
event.value.remainingTokenCount,
|
|
);
|
|
break;
|
|
case ServerGeminiEventType.Finished:
|
|
handleFinishedEvent(event, userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.Citation:
|
|
handleCitationEvent(event.value, userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.ModelInfo:
|
|
handleChatModelEvent(event.value, userMessageTimestamp);
|
|
break;
|
|
case ServerGeminiEventType.LoopDetected:
|
|
// handle later because we want to move pending history to history
|
|
// before we add loop detected message to history
|
|
loopDetectedRef.current = true;
|
|
break;
|
|
case ServerGeminiEventType.Retry:
|
|
case ServerGeminiEventType.InvalidStream:
|
|
// Will add the missing logic later
|
|
break;
|
|
default: {
|
|
// enforces exhaustive switch-case
|
|
const unreachable: never = event;
|
|
return unreachable;
|
|
}
|
|
}
|
|
}
|
|
if (toolCallRequests.length > 0) {
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
await scheduleToolCalls(toolCallRequests, signal);
|
|
}
|
|
return StreamProcessingStatus.Completed;
|
|
},
|
|
[
|
|
handleContentEvent,
|
|
handleThoughtEvent,
|
|
thoughtRef,
|
|
handleUserCancelledEvent,
|
|
handleErrorEvent,
|
|
scheduleToolCalls,
|
|
handleChatCompressionEvent,
|
|
handleFinishedEvent,
|
|
handleMaxSessionTurnsEvent,
|
|
handleContextWindowWillOverflowEvent,
|
|
handleCitationEvent,
|
|
handleChatModelEvent,
|
|
handleAgentExecutionStoppedEvent,
|
|
handleAgentExecutionBlockedEvent,
|
|
addItem,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
setThought,
|
|
],
|
|
);
|
|
const submitQuery = useCallback(
|
|
async (
|
|
query: PartListUnion,
|
|
options?: { isContinuation: boolean },
|
|
prompt_id?: string,
|
|
) =>
|
|
runInDevTraceSpan(
|
|
{
|
|
operation: options?.isContinuation
|
|
? GeminiCliOperation.SystemPrompt
|
|
: GeminiCliOperation.UserPrompt,
|
|
},
|
|
async ({ metadata: spanMetadata }) => {
|
|
spanMetadata.input = query;
|
|
|
|
if (
|
|
(isRespondingRef.current ||
|
|
streamingState === StreamingState.Responding ||
|
|
streamingState === StreamingState.WaitingForConfirmation) &&
|
|
!options?.isContinuation
|
|
)
|
|
return;
|
|
const queryId = `${Date.now()}-${Math.random()}`;
|
|
activeQueryIdRef.current = queryId;
|
|
|
|
const userMessageTimestamp = Date.now();
|
|
|
|
// Reset quota error flag when starting a new query (not a continuation)
|
|
if (!options?.isContinuation) {
|
|
setModelSwitchedFromQuotaError(false);
|
|
config.setQuotaErrorOccurred(false);
|
|
config.resetBillingTurnState(
|
|
settings.merged.billing?.overageStrategy,
|
|
);
|
|
suppressedToolErrorCountRef.current = 0;
|
|
suppressedToolErrorNoteShownRef.current = false;
|
|
lowVerbosityFailureNoteShownRef.current = false;
|
|
}
|
|
|
|
abortControllerRef.current = new AbortController();
|
|
const abortSignal = abortControllerRef.current.signal;
|
|
turnCancelledRef.current = false;
|
|
|
|
if (!prompt_id) {
|
|
prompt_id = config.getSessionId() + '########' + getPromptCount();
|
|
}
|
|
return promptIdContext.run(prompt_id, async () => {
|
|
const { queryToSend, shouldProceed } = await prepareQueryForGemini(
|
|
query,
|
|
userMessageTimestamp,
|
|
abortSignal,
|
|
prompt_id!,
|
|
);
|
|
|
|
if (!shouldProceed || queryToSend === null) {
|
|
return;
|
|
}
|
|
|
|
if (!options?.isContinuation) {
|
|
if (typeof queryToSend === 'string') {
|
|
// logging the text prompts only for now
|
|
const promptText = queryToSend;
|
|
logUserPrompt(
|
|
config,
|
|
new UserPromptEvent(
|
|
promptText.length,
|
|
prompt_id!,
|
|
config.getContentGeneratorConfig()?.authType,
|
|
promptText,
|
|
),
|
|
);
|
|
}
|
|
startNewPrompt();
|
|
setThought(null); // Reset thought when starting a new prompt
|
|
}
|
|
|
|
setIsResponding(true);
|
|
setInitError(null);
|
|
|
|
// Store query and prompt_id for potential retry on loop detection
|
|
lastQueryRef.current = queryToSend;
|
|
lastPromptIdRef.current = prompt_id!;
|
|
|
|
try {
|
|
const stream = geminiClient.sendMessageStream(
|
|
queryToSend,
|
|
abortSignal,
|
|
prompt_id!,
|
|
undefined,
|
|
false,
|
|
query,
|
|
);
|
|
const processingStatus = await processGeminiStreamEvents(
|
|
stream,
|
|
userMessageTimestamp,
|
|
abortSignal,
|
|
);
|
|
|
|
if (processingStatus === StreamProcessingStatus.UserCancelled) {
|
|
return;
|
|
}
|
|
|
|
if (pendingHistoryItemRef.current) {
|
|
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
if (loopDetectedRef.current) {
|
|
loopDetectedRef.current = false;
|
|
// Show the confirmation dialog to choose whether to disable loop detection
|
|
setLoopDetectionConfirmationRequest({
|
|
onComplete: async (result: {
|
|
userSelection: 'disable' | 'keep';
|
|
}) => {
|
|
setLoopDetectionConfirmationRequest(null);
|
|
|
|
if (result.userSelection === 'disable') {
|
|
config
|
|
.getGeminiClient()
|
|
.getLoopDetectionService()
|
|
.disableForSession();
|
|
addItem({
|
|
type: 'info',
|
|
text: `Loop detection has been disabled for this session. Retrying request...`,
|
|
});
|
|
|
|
if (lastQueryRef.current && lastPromptIdRef.current) {
|
|
await submitQuery(
|
|
lastQueryRef.current,
|
|
{ isContinuation: true },
|
|
lastPromptIdRef.current,
|
|
);
|
|
}
|
|
} else {
|
|
addItem({
|
|
type: 'info',
|
|
text: `A potential loop was detected. This can happen due to repetitive tool calls or other model behavior. The request has been halted.`,
|
|
});
|
|
}
|
|
},
|
|
});
|
|
}
|
|
} catch (error: unknown) {
|
|
spanMetadata.error = error;
|
|
if (error instanceof UnauthorizedError) {
|
|
onAuthError('Session expired or is unauthorized.');
|
|
} else if (
|
|
// Suppress ValidationRequiredError if it was marked as handled (e.g. user clicked change_auth or cancelled)
|
|
error instanceof ValidationRequiredError &&
|
|
error.userHandled
|
|
) {
|
|
// Error was handled by validation dialog, don't display again
|
|
} else if (!isNodeError(error) || error.name !== 'AbortError') {
|
|
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
|
|
addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: parseAndFormatApiError(
|
|
getErrorMessage(error) || 'Unknown error',
|
|
config.getContentGeneratorConfig()?.authType,
|
|
undefined,
|
|
config.getModel(),
|
|
DEFAULT_GEMINI_FLASH_MODEL,
|
|
),
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
|
|
}
|
|
} finally {
|
|
if (activeQueryIdRef.current === queryId) {
|
|
setIsResponding(false);
|
|
}
|
|
}
|
|
});
|
|
},
|
|
),
|
|
[
|
|
streamingState,
|
|
setModelSwitchedFromQuotaError,
|
|
prepareQueryForGemini,
|
|
processGeminiStreamEvents,
|
|
pendingHistoryItemRef,
|
|
addItem,
|
|
setPendingHistoryItem,
|
|
setInitError,
|
|
geminiClient,
|
|
onAuthError,
|
|
config,
|
|
startNewPrompt,
|
|
getPromptCount,
|
|
setThought,
|
|
maybeAddSuppressedToolErrorNote,
|
|
maybeAddLowVerbosityFailureNote,
|
|
settings.merged.billing?.overageStrategy,
|
|
setIsResponding,
|
|
],
|
|
);
|
|
|
|
const handleApprovalModeChange = useCallback(
|
|
async (newApprovalMode: ApprovalMode) => {
|
|
if (
|
|
previousApprovalModeRef.current === ApprovalMode.PLAN &&
|
|
newApprovalMode !== ApprovalMode.PLAN &&
|
|
streamingState === StreamingState.Idle
|
|
) {
|
|
if (geminiClient) {
|
|
try {
|
|
await geminiClient.addHistory({
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: getPlanModeExitMessage(newApprovalMode, true),
|
|
},
|
|
],
|
|
});
|
|
} catch (error) {
|
|
onDebugMessage(
|
|
`Failed to notify model of Plan Mode exit: ${getErrorMessage(error)}`,
|
|
);
|
|
addItem({
|
|
type: MessageType.ERROR,
|
|
text: 'Failed to update the model about exiting Plan Mode. The model might be out of sync. Please consider restarting the session if you see unexpected behavior.',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
previousApprovalModeRef.current = newApprovalMode;
|
|
|
|
// Auto-approve pending tool calls when switching to auto-approval modes
|
|
if (
|
|
newApprovalMode === ApprovalMode.YOLO ||
|
|
newApprovalMode === ApprovalMode.AUTO_EDIT
|
|
) {
|
|
let awaitingApprovalCalls = toolCalls.filter(
|
|
(call): call is TrackedWaitingToolCall =>
|
|
call.status === 'awaiting_approval',
|
|
);
|
|
|
|
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
|
|
if (newApprovalMode === ApprovalMode.AUTO_EDIT) {
|
|
awaitingApprovalCalls = awaitingApprovalCalls.filter((call) =>
|
|
EDIT_TOOL_NAMES.has(call.request.name),
|
|
);
|
|
}
|
|
|
|
// Process pending tool calls sequentially to reduce UI chaos
|
|
for (const call of awaitingApprovalCalls) {
|
|
if (call.correlationId) {
|
|
try {
|
|
await config.getMessageBus().publish({
|
|
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
|
correlationId: call.correlationId,
|
|
confirmed: true,
|
|
requiresUserConfirmation: false,
|
|
outcome: ToolConfirmationOutcome.ProceedOnce,
|
|
});
|
|
} catch (error) {
|
|
debugLogger.warn(
|
|
`Failed to auto-approve tool call ${call.request.callId}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[config, toolCalls, geminiClient, streamingState, addItem, onDebugMessage],
|
|
);
|
|
|
|
const handleCompletedTools = useCallback(
|
|
async (completedToolCallsFromScheduler: TrackedToolCall[]) => {
|
|
const completedAndReadyToSubmitTools =
|
|
completedToolCallsFromScheduler.filter(
|
|
(
|
|
tc: TrackedToolCall,
|
|
): tc is TrackedCompletedToolCall | TrackedCancelledToolCall => {
|
|
const isTerminalState =
|
|
tc.status === 'success' ||
|
|
tc.status === 'error' ||
|
|
tc.status === 'cancelled';
|
|
|
|
if (isTerminalState) {
|
|
const completedOrCancelledCall = tc as
|
|
| TrackedCompletedToolCall
|
|
| TrackedCancelledToolCall;
|
|
return (
|
|
completedOrCancelledCall.response?.responseParts !== undefined
|
|
);
|
|
}
|
|
return false;
|
|
},
|
|
);
|
|
|
|
// Finalize any client-initiated tools as soon as they are done.
|
|
const clientTools = completedAndReadyToSubmitTools.filter(
|
|
(t) => t.request.isClientInitiated,
|
|
);
|
|
if (clientTools.length > 0) {
|
|
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
|
|
}
|
|
|
|
// Identify new, successful save_memory calls that we haven't processed yet.
|
|
const newSuccessfulMemorySaves = completedAndReadyToSubmitTools.filter(
|
|
(t) =>
|
|
t.request.name === 'save_memory' &&
|
|
t.status === 'success' &&
|
|
!processedMemoryToolsRef.current.has(t.request.callId),
|
|
);
|
|
|
|
// Handle backgrounded shell tools
|
|
completedAndReadyToSubmitTools.forEach((t) => {
|
|
const isShell = t.request.name === 'run_shell_command';
|
|
// Access result from the tracked tool call response
|
|
const response = t.response as ToolResponseWithParts;
|
|
const rawData = response?.data;
|
|
const data = isShellToolData(rawData) ? rawData : undefined;
|
|
|
|
// Use data.pid for shell commands moved to the background.
|
|
const pid = data?.pid;
|
|
|
|
if (isShell && pid) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const command = (data?.['command'] as string) ?? 'shell';
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const initialOutput = (data?.['initialOutput'] as string) ?? '';
|
|
|
|
registerBackgroundShell(pid, command, initialOutput);
|
|
}
|
|
});
|
|
|
|
if (newSuccessfulMemorySaves.length > 0) {
|
|
// Perform the refresh only if there are new ones.
|
|
void performMemoryRefresh();
|
|
// Mark them as processed so we don't do this again on the next render.
|
|
newSuccessfulMemorySaves.forEach((t) =>
|
|
processedMemoryToolsRef.current.add(t.request.callId),
|
|
);
|
|
}
|
|
|
|
const geminiTools = completedAndReadyToSubmitTools.filter(
|
|
(t) => !t.request.isClientInitiated,
|
|
);
|
|
|
|
if (isLowErrorVerbosity) {
|
|
// Low-mode suppression applies only to model-initiated tool failures.
|
|
suppressedToolErrorCountRef.current += geminiTools.filter(
|
|
(tc) => tc.status === CoreToolCallStatus.Error,
|
|
).length;
|
|
}
|
|
|
|
if (geminiTools.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Check if any tool requested to stop execution immediately
|
|
const stopExecutionTool = geminiTools.find(
|
|
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
|
|
);
|
|
|
|
if (stopExecutionTool && stopExecutionTool.response.error) {
|
|
maybeAddSuppressedToolErrorNote();
|
|
addItem({
|
|
type: MessageType.INFO,
|
|
text: `Agent execution stopped: ${stopExecutionTool.response.error.message}`,
|
|
});
|
|
maybeAddLowVerbosityFailureNote();
|
|
setIsResponding(false);
|
|
|
|
const callIdsToMarkAsSubmitted = geminiTools.map(
|
|
(toolCall) => toolCall.request.callId,
|
|
);
|
|
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
|
return;
|
|
}
|
|
|
|
// If all the tools were cancelled, don't submit a response to Gemini.
|
|
const allToolsCancelled = geminiTools.every(
|
|
(tc) => tc.status === CoreToolCallStatus.Cancelled,
|
|
);
|
|
|
|
if (allToolsCancelled) {
|
|
// If the turn was cancelled via the imperative escape key flow,
|
|
// the cancellation message is added there. We check the ref to avoid duplication.
|
|
if (!turnCancelledRef.current) {
|
|
addItem({
|
|
type: MessageType.INFO,
|
|
text: 'Request cancelled.',
|
|
});
|
|
}
|
|
setIsResponding(false);
|
|
|
|
if (geminiClient) {
|
|
// We need to manually add the function responses to the history
|
|
// so the model knows the tools were cancelled.
|
|
const combinedParts = geminiTools.flatMap(
|
|
(toolCall) => toolCall.response.responseParts,
|
|
);
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
geminiClient.addHistory({
|
|
role: 'user',
|
|
parts: combinedParts,
|
|
});
|
|
}
|
|
|
|
const callIdsToMarkAsSubmitted = geminiTools.map(
|
|
(toolCall) => toolCall.request.callId,
|
|
);
|
|
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
|
return;
|
|
}
|
|
|
|
const responsesToSend: Part[] = geminiTools.flatMap(
|
|
(toolCall) => toolCall.response.responseParts,
|
|
);
|
|
|
|
if (consumeUserHint) {
|
|
const userHint = consumeUserHint();
|
|
if (userHint && userHint.trim().length > 0) {
|
|
const hintText = userHint.trim();
|
|
responsesToSend.unshift({
|
|
text: buildUserSteeringHintPrompt(hintText),
|
|
});
|
|
}
|
|
}
|
|
|
|
const callIdsToMarkAsSubmitted = geminiTools.map(
|
|
(toolCall) => toolCall.request.callId,
|
|
);
|
|
|
|
const prompt_ids = geminiTools.map(
|
|
(toolCall) => toolCall.request.prompt_id,
|
|
);
|
|
|
|
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
|
|
|
// Don't continue if model was switched due to quota error
|
|
if (modelSwitchedFromQuotaError) {
|
|
return;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
submitQuery(
|
|
responsesToSend,
|
|
{
|
|
isContinuation: true,
|
|
},
|
|
prompt_ids[0],
|
|
);
|
|
},
|
|
[
|
|
submitQuery,
|
|
markToolsAsSubmitted,
|
|
geminiClient,
|
|
performMemoryRefresh,
|
|
modelSwitchedFromQuotaError,
|
|
addItem,
|
|
registerBackgroundShell,
|
|
consumeUserHint,
|
|
isLowErrorVerbosity,
|
|
maybeAddSuppressedToolErrorNote,
|
|
maybeAddLowVerbosityFailureNote,
|
|
setIsResponding,
|
|
],
|
|
);
|
|
|
|
const pendingHistoryItems = useMemo(
|
|
() =>
|
|
[pendingHistoryItem, ...pendingToolGroupItems].filter(
|
|
(i): i is HistoryItemWithoutId => i !== undefined && i !== null,
|
|
),
|
|
[pendingHistoryItem, pendingToolGroupItems],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const saveRestorableToolCalls = async () => {
|
|
if (!config.getCheckpointingEnabled()) {
|
|
return;
|
|
}
|
|
const restorableToolCalls = toolCalls.filter(
|
|
(toolCall) =>
|
|
EDIT_TOOL_NAMES.has(toolCall.request.name) &&
|
|
toolCall.status === CoreToolCallStatus.AwaitingApproval,
|
|
);
|
|
|
|
if (restorableToolCalls.length > 0) {
|
|
if (!gitService) {
|
|
onDebugMessage(
|
|
'Checkpointing is enabled but Git service is not available. Failed to create snapshot. Ensure Git is installed and working properly.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const { checkpointsToWrite, errors } = await processRestorableToolCalls<
|
|
HistoryItem[]
|
|
>(
|
|
restorableToolCalls.map((call) => call.request),
|
|
gitService,
|
|
geminiClient,
|
|
history,
|
|
);
|
|
|
|
if (errors.length > 0) {
|
|
errors.forEach(onDebugMessage);
|
|
}
|
|
|
|
if (checkpointsToWrite.size > 0) {
|
|
const checkpointDir = storage.getProjectTempCheckpointsDir();
|
|
try {
|
|
await fs.mkdir(checkpointDir, { recursive: true });
|
|
for (const [fileName, content] of checkpointsToWrite) {
|
|
const filePath = path.join(checkpointDir, fileName);
|
|
await fs.writeFile(filePath, content);
|
|
}
|
|
} catch (error) {
|
|
onDebugMessage(
|
|
`Failed to write checkpoint file: ${getErrorMessage(error)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
saveRestorableToolCalls();
|
|
}, [
|
|
toolCalls,
|
|
config,
|
|
onDebugMessage,
|
|
gitService,
|
|
history,
|
|
geminiClient,
|
|
storage,
|
|
]);
|
|
|
|
const lastOutputTime = Math.max(
|
|
lastToolOutputTime,
|
|
lastShellOutputTime,
|
|
lastGeminiActivityTime,
|
|
);
|
|
|
|
return {
|
|
streamingState,
|
|
submitQuery,
|
|
initError,
|
|
pendingHistoryItems,
|
|
thought,
|
|
cancelOngoingRequest,
|
|
pendingToolCalls: toolCalls,
|
|
handleApprovalModeChange,
|
|
activePtyId,
|
|
loopDetectionConfirmationRequest,
|
|
lastOutputTime,
|
|
backgroundShellCount,
|
|
isBackgroundShellVisible,
|
|
toggleBackgroundShell,
|
|
backgroundCurrentShell,
|
|
backgroundShells,
|
|
dismissBackgroundShell,
|
|
retryStatus,
|
|
};
|
|
};
|