mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(core): surface Anthropic refusal details Propagate Anthropic refusal stop_details through candidate finishMessage and the Finished event so both terminal UIs can show the provider's refusal category instead of a generic empty safety stop. Co-authored-by: Qwen-Coder <[email protected]> * style(core): apply Prettier to the refusal-detail diff The incremental lint gate selected 12 changed files and Prettier rejected four of them; this commit only re-wraps those lines, no behaviour change. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmvygjkzk7s * fix(core,cli): scope the refusal notice to safety stops Review follow-up on #16079: - Both renderers now prefer `finishMessage` only for `SAFETY` stops. The field is a provider wire field (`@google/genai` types it), so a Gemini turn carrying it could otherwise replace the curated notice for `MAX_TOKENS` and even add an info line on `STOP`. - The refusal category lookup uses an own-property check: a provider `category` of `__proto__` / `constructor` now falls back to the generic notice instead of returning an object that renders as `[object Object]`. - `llm-chat` deletes `finishMessage` together with the deferred `finishReason`, so the stream no longer yields a notice with no reason. - Tests: category table (all mapped categories + prototype key + unknown) and a non-safety renderer case pinning the curated notice. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw651acyim --------- Co-authored-by: Qwen-Coder <[email protected]>
8610 lines
327 KiB
TypeScript
8610 lines
327 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
completeRestoreToolResponse,
|
|
type RestoreToolResponseBinding,
|
|
} from '../utils/restore-tool-response.js';
|
|
import { observeInteractiveModTool } from '../../services/interactive-mod-tool-observer.js';
|
|
import {
|
|
useState,
|
|
useRef,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useLayoutEffect,
|
|
type RefObject,
|
|
} from 'react';
|
|
import {
|
|
runOutsideAgentContext,
|
|
type Config,
|
|
type EditorType,
|
|
type LlmClient,
|
|
type SendMessageOptions,
|
|
type Logger,
|
|
type RetryInfo,
|
|
type ServerLlmChatCompressedEvent,
|
|
type ServerLlmContentEvent as ContentEvent,
|
|
type ServerLlmFinishedEvent,
|
|
type ServerLlmStreamEvent as LlmEvent,
|
|
type ThoughtSummary,
|
|
type ToolCallRequestInfo,
|
|
type ToolCallResponseInfo,
|
|
type LlmErrorEventValue,
|
|
type GoalTurnPermit,
|
|
type SteerInput,
|
|
LlmEventType as ServerLlmEventType,
|
|
SendMessageType,
|
|
createDebugLogger,
|
|
ToolNames,
|
|
goalToolResultProvenance,
|
|
goalPauseReasonForFailure,
|
|
GOAL_PAUSE_REASON_USER_INTERRUPT,
|
|
getErrorMessage,
|
|
isNodeError,
|
|
MessageSenderType,
|
|
logUserPrompt,
|
|
logUserRetry,
|
|
UnauthorizedError,
|
|
UserPromptEvent,
|
|
UserRetryEvent,
|
|
logConversationFinishedEvent,
|
|
ConversationFinishedEvent,
|
|
ApprovalMode,
|
|
parseAndFormatApiError,
|
|
promptIdContext,
|
|
ToolConfirmationOutcome,
|
|
logApiCancel,
|
|
ApiCancelEvent,
|
|
detectAutonomousSentinel,
|
|
isSupportedImageMimeType,
|
|
getUnsupportedImageFormatWarning,
|
|
runVisionBridge,
|
|
shouldRunVisionBridge,
|
|
formatVisionBridgeNotice,
|
|
formatFullTurnVisionNotice,
|
|
getFullTurnVisionModelSelector,
|
|
hasImageParts,
|
|
clampInlineMediaPart,
|
|
splitImageParts,
|
|
generateToolUseSummary,
|
|
goalRequiresExactPermit,
|
|
createDuplicateProviderToolCallResponse,
|
|
markDuplicateProviderToolCallResponseSent,
|
|
findRepeatedDuplicateProviderToolCall,
|
|
getCachedToolCallFingerprint,
|
|
isReplayOfHandledToolCall,
|
|
recordHandledToolCall,
|
|
AutonomousLoopTickResolver,
|
|
didWriteProjectContextFile,
|
|
refreshMemoryAfterManagedWrite,
|
|
refreshMemoryInstruction,
|
|
finalizeToolResponses,
|
|
endInteractionSpan,
|
|
getActiveInteractionSpan,
|
|
decideNotificationAdmission,
|
|
DroppedNotificationTally,
|
|
MAX_BACKGROUND_NOTIFICATION_QUEUE,
|
|
type BackgroundNotificationKind,
|
|
renderGoalContinuationTurn,
|
|
getApiHistoryPromptId,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import type { HumanPromptInput } from '@qwen-code/qwen-code-core/core/workflow-intent.js';
|
|
import {
|
|
runModelRound,
|
|
runToolBatch,
|
|
type AgentLoopRound,
|
|
type AgentLoopStopReason,
|
|
} from '@qwen-code/qwen-code-core/core/agent-loop.js';
|
|
import type { InteractiveToolCall } from '../../services/interactive-mod-tool-scheduler.js';
|
|
import { createInkToolExecutor } from './ink-tool-executor.js';
|
|
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
|
import {
|
|
getInteractiveModSession,
|
|
MODS_FAILED_TO_INITIALIZE,
|
|
} from '../../services/interactive-mod-session.js';
|
|
import type { ModTurn } from '../../services/mod-session.js';
|
|
import type { ModQueuedPrompt } from '@qwen-code/qwen-code-core/mods/types.js';
|
|
import {
|
|
frameModPrompt,
|
|
modPromptRow,
|
|
} from '../../services/mod-prompt-frame.js';
|
|
import type {
|
|
HistoryItem,
|
|
HistoryItemWithoutId,
|
|
HistoryItemToolGroup,
|
|
HistoryItemLlm,
|
|
InlineImageData,
|
|
LoopDetectionConfirmationRequest,
|
|
SlashCommandProcessorResult,
|
|
} from '../types.js';
|
|
import { StreamingState, MessageType, ToolCallStatus } from '../types.js';
|
|
import {
|
|
isAtCommand,
|
|
isBtwCommand,
|
|
isSlashCommand,
|
|
} from '../utils/commandUtils.js';
|
|
import {
|
|
findLastUserItemIndex,
|
|
stripLeadingSystemReminders,
|
|
} from '../utils/historyUtils.js';
|
|
import {
|
|
collapseRewrite,
|
|
REWRITE_CHANGED_PASTE,
|
|
expandPendingPastePlaceholders,
|
|
framePasteExpansion,
|
|
} from '../utils/paste-placeholders.js';
|
|
import { useShellCommandProcessor } from './shellCommandProcessor.js';
|
|
import {
|
|
handleAtCommand,
|
|
resolveAtCommandQuery,
|
|
} from './atCommandProcessor.js';
|
|
import {
|
|
findLastSafeSplitPoint,
|
|
splitFencedMarkdown,
|
|
getEnclosingFenceInfo,
|
|
} from '../utils/markdown-utilities.js';
|
|
import { fitPendingSlice } from '../utils/pending-rendered-height.js';
|
|
import { useStateAndRef } from './useStateAndRef.js';
|
|
import { normalizePartList } from '../../utils/normalize-part-list.js';
|
|
import { isInlineModelOverrideAllowed } from '../../utils/acpModelUtils.js';
|
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
|
import {
|
|
useReactToolScheduler,
|
|
mapToDisplay as mapTrackedToolCallsToDisplay,
|
|
type TrackedToolCall,
|
|
type TrackedCompletedToolCall,
|
|
type TrackedCancelledToolCall,
|
|
type TrackedExecutingToolCall,
|
|
type TrackedWaitingToolCall,
|
|
} from './useReactToolScheduler.js';
|
|
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { useSessionStats } from '../contexts/SessionContext.js';
|
|
import type { LoadedSettings } from '../../config/settings.js';
|
|
import { t } from '../../i18n/index.js';
|
|
import { useDualOutput } from '../../dualOutput/DualOutputContext.js';
|
|
import { shouldDisplayGoalStateCause } from '../utils/goal-runtime.js';
|
|
import { sanitizeDisplayText } from '../../utils/extension-mention.js';
|
|
import process from 'node:process';
|
|
import {
|
|
GOAL_COMMAND_RE,
|
|
type DirectUserAdmission,
|
|
type QueuedGoalTurn,
|
|
} from './useMessageQueue.js';
|
|
import { classifyApiError } from '../../utils/classify-api-error.js';
|
|
import {
|
|
describeLoopStop,
|
|
formatInteractiveLoopDetectedMessage,
|
|
} from '../../utils/loop-detected-message.js';
|
|
import type { LoopStop } from '../../utils/loop-detected-message.js';
|
|
import {
|
|
LoopReason,
|
|
LoopType,
|
|
} from '@qwen-code/qwen-code-core/telemetry/types.js';
|
|
import { cleanupReviewWorktreeLeases } from '../../services/review-worktree-lease.js';
|
|
import {
|
|
getInlineImageData,
|
|
MAX_INLINE_IMAGES_PER_ITEM,
|
|
} from '../utils/inline-image-parts.js';
|
|
|
|
const debugLogger = createDebugLogger('GEMINI_STREAM');
|
|
|
|
/**
|
|
* A queued teammate→leader message. `modelText` is the full nonce-tagged
|
|
* envelope sent to the leader's model; `display` is the compact `● …`
|
|
* line shown to the user in its place — the same two-text split the
|
|
* unified notification queue uses, so teammate reports don't dump the
|
|
* whole raw envelope into the conversation as a user bubble.
|
|
*/
|
|
interface TeammateQueueEntry {
|
|
modelText: string;
|
|
display: string;
|
|
displayed?: boolean;
|
|
}
|
|
|
|
interface ToolContinuationOwner {
|
|
restoreResponseBinding?: RestoreToolResponseBinding;
|
|
promptId: string;
|
|
signal: AbortSignal;
|
|
survivesGenerationChange: boolean;
|
|
detachedAbortController?: AbortController;
|
|
foregroundAbortController?: AbortController;
|
|
}
|
|
|
|
// The per-turn model override is held in two coupled refs: the model id and a
|
|
// flag marking whether it came from an explicit inline `/model <id> <prompt>`
|
|
// (which must win over skill-tool overrides for the rest of the turn). The two
|
|
// always move together; these helpers are the single place that writes both so
|
|
// the invariant (inline flag true => model id set) can't be broken by editing
|
|
// one ref in isolation, and every set/clear is traceable via debug logs.
|
|
function applyModelOverride(
|
|
modelOverrideRef: { current: string | undefined },
|
|
inlineActiveRef: { current: boolean },
|
|
value: string | undefined,
|
|
isInline: boolean,
|
|
): void {
|
|
modelOverrideRef.current = value;
|
|
inlineActiveRef.current = isInline;
|
|
debugLogger.debug(
|
|
`model override ${
|
|
value === undefined ? 'cleared' : `set to ${value}`
|
|
} (inline=${isInline})`,
|
|
);
|
|
}
|
|
|
|
function clearModelOverride(
|
|
modelOverrideRef: { current: string | undefined },
|
|
inlineActiveRef: { current: boolean },
|
|
): void {
|
|
applyModelOverride(modelOverrideRef, inlineActiveRef, undefined, false);
|
|
}
|
|
|
|
const MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MS = 10_000;
|
|
const MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MESSAGE =
|
|
'Mid-turn @ command resolution timed out';
|
|
|
|
interface PendingDuplicateToolResponses {
|
|
executableCallIds: Set<string>;
|
|
promptId: string | undefined;
|
|
callOrder: string[];
|
|
duplicateResponses: Array<{
|
|
request: ToolCallRequestInfo;
|
|
response: ToolCallResponseInfo;
|
|
}>;
|
|
}
|
|
|
|
interface ResolvedSteerMessages {
|
|
parts: Part[];
|
|
accept: () => void;
|
|
restoreMessages: string[];
|
|
}
|
|
|
|
interface GoalTurnBinding {
|
|
permit: GoalTurnPermit;
|
|
turnKey: string;
|
|
controller: AbortController;
|
|
origin: 'runtime' | 'user';
|
|
}
|
|
|
|
type GoalTurnAdmission = Omit<GoalTurnBinding, 'permit'>;
|
|
|
|
interface ForegroundModTurn {
|
|
turn: ModTurn;
|
|
promptId: string;
|
|
ending?: 'aborted' | 'error';
|
|
// Ends the turn's turn.start hooks, whichever round the cancel ends.
|
|
cancel: AbortController;
|
|
}
|
|
|
|
// Records how the Mod turn of `promptId` ended; the first ending stands.
|
|
function recordModTurnEnding(
|
|
modTurn: ForegroundModTurn | null,
|
|
promptId: string | undefined,
|
|
ending: 'aborted' | 'error',
|
|
): void {
|
|
if (!modTurn || modTurn.promptId !== promptId) return;
|
|
modTurn.ending ??= ending;
|
|
if (ending === 'aborted') modTurn.cancel.abort();
|
|
}
|
|
|
|
function sameGoalPermit(left: GoalTurnPermit, right: GoalTurnPermit): boolean {
|
|
return (
|
|
left.goalId === right.goalId &&
|
|
left.revision === right.revision &&
|
|
left.turnId === right.turnId
|
|
);
|
|
}
|
|
|
|
function sharedGoalPermit(
|
|
contexts: Array<GoalTurnPermit | undefined>,
|
|
): GoalTurnPermit | undefined {
|
|
const first = contexts[0];
|
|
if (contexts.every((context) => context === undefined)) return undefined;
|
|
if (
|
|
!first ||
|
|
contexts.some((context) => !context || !sameGoalPermit(first, context))
|
|
) {
|
|
throw new Error('ToolResult batch has mixed Goal contexts');
|
|
}
|
|
return { ...first };
|
|
}
|
|
|
|
/**
|
|
* Pull the assistant's most recent visible text from the UI history. Used as
|
|
* an intent prefix for tool-use summary generation so the summarizer knows
|
|
* what the user was trying to accomplish.
|
|
*/
|
|
function extractLastAssistantText(history: HistoryItem[]): string | undefined {
|
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
const item = history[i];
|
|
if (
|
|
(item.type === 'gemini' || item.type === 'gemini_content') &&
|
|
typeof item.text === 'string' &&
|
|
item.text.trim().length > 0
|
|
) {
|
|
return item.text;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function stripLeadingBlankLines(text: string): string {
|
|
return text.replace(/^(?:[ \t]*\r?\n)+/, '');
|
|
}
|
|
|
|
async function resolveWithAbort<T>(
|
|
signal: AbortSignal,
|
|
run: () => Promise<T>,
|
|
): Promise<T> {
|
|
let onAbort: (() => void) | undefined;
|
|
const abortPromise = new Promise<never>((_, reject) => {
|
|
onAbort = () => {
|
|
reject(
|
|
signal.reason instanceof Error
|
|
? signal.reason
|
|
: new Error('Mid-turn @ command resolution aborted'),
|
|
);
|
|
};
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
|
|
try {
|
|
return await Promise.race([run(), abortPromise]);
|
|
} finally {
|
|
if (onAbort) {
|
|
signal.removeEventListener('abort', onAbort);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flatten `functionResponse` parts into a compact string for the summarizer.
|
|
* The summarizer itself truncates to 300 chars per field, so we just join
|
|
* whatever is available without re-serializing.
|
|
*/
|
|
function extractToolResultText(parts: Part[] | Part | undefined): unknown {
|
|
if (!parts) return '';
|
|
const list = Array.isArray(parts) ? parts : [parts];
|
|
const chunks: unknown[] = [];
|
|
for (const part of list) {
|
|
if ('functionResponse' in part && part.functionResponse) {
|
|
const response = (part.functionResponse as { response?: unknown })
|
|
.response;
|
|
if (response !== undefined) chunks.push(response);
|
|
} else if ('text' in part && typeof part.text === 'string') {
|
|
chunks.push(part.text);
|
|
}
|
|
}
|
|
if (chunks.length === 0) return '';
|
|
if (chunks.length === 1) return chunks[0];
|
|
return chunks;
|
|
}
|
|
|
|
/**
|
|
* Checks if image parts have supported formats and returns unsupported ones
|
|
*/
|
|
function checkImageFormatsSupport(parts: PartListUnion): {
|
|
hasImages: boolean;
|
|
hasUnsupportedFormats: boolean;
|
|
unsupportedMimeTypes: string[];
|
|
} {
|
|
const unsupportedMimeTypes: string[] = [];
|
|
let hasImages = false;
|
|
|
|
if (typeof parts === 'string') {
|
|
return {
|
|
hasImages: false,
|
|
hasUnsupportedFormats: false,
|
|
unsupportedMimeTypes: [],
|
|
};
|
|
}
|
|
|
|
const partsArray = Array.isArray(parts) ? parts : [parts];
|
|
|
|
for (const part of partsArray) {
|
|
if (typeof part === 'string') continue;
|
|
|
|
let mimeType: string | undefined;
|
|
|
|
// Check inlineData
|
|
if (
|
|
'inlineData' in part &&
|
|
part.inlineData?.mimeType?.startsWith('image/')
|
|
) {
|
|
hasImages = true;
|
|
mimeType = part.inlineData.mimeType;
|
|
}
|
|
|
|
// Check fileData
|
|
if ('fileData' in part && part.fileData?.mimeType?.startsWith('image/')) {
|
|
hasImages = true;
|
|
mimeType = part.fileData.mimeType;
|
|
}
|
|
|
|
// Check if the mime type is supported
|
|
if (mimeType && !isSupportedImageMimeType(mimeType)) {
|
|
unsupportedMimeTypes.push(mimeType);
|
|
}
|
|
}
|
|
|
|
return {
|
|
hasImages,
|
|
hasUnsupportedFormats: unsupportedMimeTypes.length > 0,
|
|
unsupportedMimeTypes,
|
|
};
|
|
}
|
|
|
|
enum StreamProcessingStatus {
|
|
Completed,
|
|
UserCancelled,
|
|
Error,
|
|
}
|
|
|
|
interface StreamProcessingResult {
|
|
status: StreamProcessingStatus;
|
|
scheduledToolContinuation: boolean;
|
|
userPromptBlocked: boolean;
|
|
/** A shared round's tool batch, which the turn awaits. */
|
|
sharedToolBatch?: SharedToolBatchHandle;
|
|
}
|
|
|
|
/**
|
|
* The next round of a turn whose tool batch completed: it takes the input
|
|
* queued for the tool boundary, adds it after the batch's results and sends
|
|
* the round, or stops when the turn was cancelled meanwhile.
|
|
*/
|
|
type ToolResultRound = () => Promise<void>;
|
|
|
|
/** What the next round after a tool batch needs of its completion step. */
|
|
interface ToolResultRoundState {
|
|
/** The batch's results; the input taken at the boundary is added. */
|
|
responsesToSend: Part[];
|
|
promptId: string | undefined;
|
|
continuationOwner: ToolContinuationOwner | undefined;
|
|
continuationWasCancelled: () => boolean;
|
|
toolGoalBinding: GoalTurnBinding | undefined;
|
|
endToolInteraction: (
|
|
status: 'ok' | 'error' | 'cancelled',
|
|
errorMessage?: string,
|
|
errorType?: string,
|
|
endsModTurn?: boolean,
|
|
) => void;
|
|
/** The shared batch's scheduler hold, released right before the send. */
|
|
releaseScheduler?: () => void;
|
|
}
|
|
|
|
/** Whether the turn of a shared tool batch waits for it. */
|
|
interface SharedToolBatchTurn {
|
|
/** The turn did not wait: the batch sends its next round itself. */
|
|
left: boolean;
|
|
/** The next round the batch left to the turn. */
|
|
round?: ToolResultRound;
|
|
}
|
|
|
|
/** A shared round's tool batch, as the turn that sent the round sees it. */
|
|
interface SharedToolBatchHandle {
|
|
/**
|
|
* Settles once the batch and its completion step are done, with the next
|
|
* round when the step continues the turn. The turn sends that round.
|
|
*/
|
|
done: Promise<ToolResultRound | undefined>;
|
|
/**
|
|
* For a turn that does not wait for the batch: the batch sends its next
|
|
* round itself. Returns the round when the step has already decided it.
|
|
*/
|
|
leave(): ToolResultRound | undefined;
|
|
}
|
|
|
|
/**
|
|
* A shared model round's tool calls, which run as one batch of the shared
|
|
* loop on the TUI's scheduler (`experimental.unifiedAgentLoop`).
|
|
*/
|
|
interface SharedToolBatch {
|
|
calls: ToolCallRequestInfo[];
|
|
signal: AbortSignal;
|
|
modelOverride: string | undefined;
|
|
promptId: string | undefined;
|
|
/** Provider call fingerprints: history's, then this turn's. */
|
|
handled: Map<string, string>;
|
|
}
|
|
|
|
/** A shared tool batch's results, for `handleCompletedTools`. */
|
|
interface SharedToolBatchResults {
|
|
/**
|
|
* The finalized results by call, replays included, each with its
|
|
* recording, which waits until the result is sent.
|
|
*/
|
|
responses: Array<{ callId: string; parts: Part[]; record?: () => void }>;
|
|
/** The calls the TUI does not count again: those the batch counted. */
|
|
ranCallIds: ReadonlySet<string>;
|
|
promptId: string | undefined;
|
|
/**
|
|
* The session and chat the scheduler completed the batch in, captured
|
|
* before the batch finalized its results, as the current path captures
|
|
* them when the scheduler completes its batch.
|
|
*/
|
|
owner: { sessionId: string; chat: unknown };
|
|
/**
|
|
* The executed calls that launched a background agent. The completion
|
|
* step checks the background slots when it sends, since the batch may
|
|
* wait for another stream to end first.
|
|
*/
|
|
backgroundLaunchCallIds: ReadonlySet<string>;
|
|
/**
|
|
* Lets the scheduler start its queued batches: the next round calls it
|
|
* right before it is sent, since its calls need the scheduler. When the
|
|
* completion step stops instead, the batch calls it once the step
|
|
* returned.
|
|
*/
|
|
releaseScheduler?: () => void;
|
|
}
|
|
|
|
/** Whether a result launched a background agent. */
|
|
function launchedBackgroundAgent(
|
|
request: ToolCallRequestInfo,
|
|
response: ToolCallResponseInfo,
|
|
): boolean {
|
|
const display = response.resultDisplay;
|
|
return (
|
|
request.name === ToolNames.AGENT &&
|
|
typeof display === 'object' &&
|
|
display !== null &&
|
|
'type' in display &&
|
|
'status' in display &&
|
|
display.type === 'task_execution' &&
|
|
display.status === 'background'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether a background agent launch spent the last background slot, so the
|
|
* turn stops rather than wait on it.
|
|
*/
|
|
function backgroundSlotsSpent(config: Config): boolean {
|
|
const registry = config.getBackgroundTaskRegistry();
|
|
return (
|
|
registry.getMaxConcurrentBackgroundAgents() === 1 &&
|
|
!registry.canStartBackgroundAgent()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The settled calls of a shared batch that it counted: the Ink executor
|
|
* leaves cancelled calls to the TUI, which counts them where the current
|
|
* path does.
|
|
*/
|
|
function countedCallIds(calls: readonly InteractiveToolCall[]): Set<string> {
|
|
return new Set(
|
|
calls
|
|
.filter(({ status }) => status !== 'cancelled')
|
|
.map(({ request }) => request.callId),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A model round on the shared agent loop (`experimental.unifiedAgentLoop`):
|
|
* `runModelRound` sends it, collects its tool calls and turns the client's
|
|
* terminal events into a stop reason.
|
|
*/
|
|
interface SharedModelRound {
|
|
client: LlmClient;
|
|
round: AgentLoopRound;
|
|
signal: AbortSignal;
|
|
}
|
|
|
|
/** Ends a shared model round from inside its event callback. */
|
|
class StreamProcessingEnded {
|
|
constructor(readonly result: StreamProcessingResult) {}
|
|
}
|
|
|
|
const EDIT_TOOL_NAMES = new Set([
|
|
ToolNames.EDIT,
|
|
'replace', // legacy alias, may still arrive from older providers
|
|
ToolNames.WRITE_FILE,
|
|
ToolNames.NOTEBOOK_EDIT,
|
|
]);
|
|
const STREAM_UPDATE_THROTTLE_MS = 60;
|
|
const STREAM_PENDING_ITEM_MAX_CHARS = 16_384;
|
|
// Rows kept in reserve below the commit budget so the incremental commit fires
|
|
// BEFORE MarkdownDisplay's safety-net clip (which reserves 2). Keeping the
|
|
// pending item's rendered height under the safety budget stops that clip from
|
|
// engaging and hiding a table (or slicing the tail) in step with the commit
|
|
// cycle.
|
|
const STREAM_PENDING_COMMIT_RESERVE_ROWS = 5;
|
|
// Conservative estimate of the rows the composer/footer occupy, used to derive
|
|
// a content-area height from terminalHeight before the live value is known.
|
|
const STREAM_PENDING_COMPOSER_RESERVE_ROWS = 12;
|
|
const LOADING_THOUGHT_DESCRIPTION_MAX_CHARS = 4_096;
|
|
|
|
/**
|
|
* Minimum interval between model turns triggered by interim (status
|
|
* 'running') monitor notifications (#10818). A monitor whose command prints on
|
|
* every poll emits one <task-notification> per line; without a session-level
|
|
* minimum interval each pulse starts its own model turn, so a ~0.5 Hz pulse
|
|
* stream keeps the session permanently busy — Esc cancels the in-flight turn
|
|
* but the next pulse starts another immediately, and typed input never finds
|
|
* a clean idle edge. Only interim monitor pulses are gated; terminal
|
|
* notifications and cron fires stay prompt. Queued pulses still batch-drain
|
|
* into a single catch-up turn once the window elapses, so no update is lost.
|
|
*/
|
|
export const INTERIM_MONITOR_MIN_TURN_INTERVAL_MS = 10_000;
|
|
|
|
/**
|
|
* How long the notification batch at the front of `queue` still waits out
|
|
* {@link INTERIM_MONITOR_MIN_TURN_INTERVAL_MS}, or 0 when it can run now.
|
|
*/
|
|
function interimMonitorWaitMs(
|
|
queue: readonly QueuedNotification[],
|
|
lastInterimMonitorTurnAt: number,
|
|
): number {
|
|
const leading = queue[0];
|
|
if (
|
|
leading?.sendMessageType !== SendMessageType.Notification ||
|
|
leading.monitor?.status !== 'running'
|
|
)
|
|
return 0;
|
|
return Math.max(
|
|
0,
|
|
INTERIM_MONITOR_MIN_TURN_INTERVAL_MS -
|
|
(Date.now() - lastInterimMonitorTurnAt),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* An overflow summary taken from the tally and awaiting a turn to carry it.
|
|
* `displayed` mirrors the per-notification flag so a re-queued summary is not
|
|
* rendered twice.
|
|
*/
|
|
interface PendingDroppedSummary {
|
|
displayText: string;
|
|
modelText: string;
|
|
status: 'dropped' | 'recorded';
|
|
displayed?: boolean;
|
|
}
|
|
|
|
/**
|
|
* One entry in the unified notification queue. `kind`, `taskId` and `interim`
|
|
* feed the shared admission rule and name what was lost when overflow discards
|
|
* an entry; `monitor` stays separate because the drain also uses it to prune
|
|
* pulses from monitors that were cancelled while queued.
|
|
*/
|
|
interface QueuedNotification {
|
|
displayText: string;
|
|
modelText: string;
|
|
sendMessageType: SendMessageType;
|
|
kind: BackgroundNotificationKind;
|
|
taskId?: string;
|
|
interim?: boolean;
|
|
monitor?: { id: string; status: string };
|
|
todoWorkChainId?: string;
|
|
onDelivered?: () => void;
|
|
onDeliveryFailed?: () => void;
|
|
displayed?: boolean;
|
|
}
|
|
|
|
function isProtectedNotification(item: QueuedNotification): boolean {
|
|
return (
|
|
item.kind === 'agent' || item.kind === 'workflow' || item.kind === 'cron'
|
|
);
|
|
}
|
|
|
|
type BufferedStreamEvent =
|
|
| { kind: 'content'; value: string }
|
|
| { kind: 'image'; value: InlineImageData }
|
|
| { kind: 'thought'; value: ThoughtSummary };
|
|
|
|
function showCitations(settings: LoadedSettings): boolean {
|
|
const enabled = settings?.merged?.ui?.showCitations;
|
|
if (enabled !== undefined) {
|
|
return enabled;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function clampLoadingThoughtDescription(description: string): string {
|
|
if (description.length <= LOADING_THOUGHT_DESCRIPTION_MAX_CHARS) {
|
|
return description;
|
|
}
|
|
|
|
return description.slice(0, LOADING_THOUGHT_DESCRIPTION_MAX_CHARS);
|
|
}
|
|
|
|
/**
|
|
* Character index just after the `keptLines`-th source line of `text`, or -1 if
|
|
* there are not that many lines. Converts a fitPendingSlice line count into a
|
|
* commit boundary; callers pass it through `findLastSafeSplitPoint` so the cut
|
|
* never lands inside a fenced code block.
|
|
*/
|
|
function charIndexAfterLine(text: string, keptLines: number): number {
|
|
if (keptLines <= 0) return -1;
|
|
let count = 0;
|
|
for (let i = 0; i < text.length; i++) {
|
|
if (text[i] === '\n') {
|
|
count++;
|
|
if (count === keptLines) return i + 1;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Synchronous snapshot passed to `onCancelSubmit` so the cancel handler can
|
|
* decide whether the model produced meaningful in-flight content WITHOUT
|
|
* waiting for React state to flush. Closes the race where
|
|
* `pendingHistoryItem` was just set from a stream chunk but the consumer's
|
|
* React-state copy still reads as empty.
|
|
*/
|
|
/** What the Mods' `prompt.submit` hooks let in, when they heard a prompt. */
|
|
interface PromptSubmitted {
|
|
/** A rewrite: its text, and the same with its pastes collapsed. */
|
|
rewritten?: { text: string; display: string };
|
|
context?: string[];
|
|
}
|
|
|
|
export interface CancelSubmitInfo {
|
|
/** `pendingHistoryItemRef.current` captured before any cancel mutation. */
|
|
pendingItem: HistoryItemWithoutId | null;
|
|
/**
|
|
* The USER history item that this turn added, if any. `null` when the
|
|
* turn took a path that does NOT push a user history item (Cron,
|
|
* Notification, slash `submit_prompt`, Retry, etc.). The `id` lets
|
|
* consumers verify identity even when `addItem` skipped a
|
|
* consecutive-duplicate user message (text alone would wrongly match
|
|
* the older row).
|
|
*/
|
|
lastTurnUserItem: {
|
|
id: number;
|
|
text: string;
|
|
submittedPrompt?: string;
|
|
humanInput?: HumanPromptInput;
|
|
hasUnknownInput?: boolean;
|
|
pasteExpansions?: ReadonlyMap<string, string>;
|
|
/** Prompt as sent to the model, when it differed from `text`. */
|
|
modelText?: string;
|
|
} | null;
|
|
/**
|
|
* Whether removing the Logger's latest USER entry can only target
|
|
* `lastTurnUserItem`. A concurrent BTW command writes a newer USER entry,
|
|
* so the cancel handler must keep the log intact rather than remove the
|
|
* side-question by mistake.
|
|
*/
|
|
canUndoLastLoggedUserMessage: boolean;
|
|
/**
|
|
* True if a content event landed during this turn, including during
|
|
* the pre-cancel flush of throttle-buffered events. Lets the
|
|
* auto-restore guard reject a turn that produced meaningful text even
|
|
* when the consumer's React history snapshot is still stale.
|
|
*/
|
|
turnProducedMeaningfulContent: boolean;
|
|
/**
|
|
* True when the cancelled turn was a Goal continuation turn. Such a turn
|
|
* appends a synthetic continuation prompt to the chat history but, unlike a
|
|
* UserQuery, adds no UI user item, so the cancel handler's auto-restore
|
|
* branch bails before its orphan strip runs. The handler uses this flag to
|
|
* strip that prompt so it can't merge into the user's next real message.
|
|
*/
|
|
wasGoalTurn: boolean;
|
|
/**
|
|
* True when the cancelled turn ran a prompt a Mod submitted with
|
|
* `$.prompt.submit`. Like a Goal continuation it adds no UI user item, so
|
|
* the handler strips its framed prompt: the person stopped it, and the next
|
|
* prompt must not reach the model behind it.
|
|
*/
|
|
wasModPromptTurn: boolean;
|
|
}
|
|
|
|
/**
|
|
* Manages the LLM stream, including user input, command processing,
|
|
* API interaction, and tool call lifecycle.
|
|
*/
|
|
export const useLlmStream = (
|
|
llmClient: LlmClient,
|
|
history: HistoryItem[],
|
|
addItem: UseHistoryManagerReturn['addItem'],
|
|
config: Config,
|
|
isConfigInitialized: boolean,
|
|
settings: LoadedSettings,
|
|
onDebugMessage: (message: string) => void,
|
|
handleSlashCommand: (
|
|
cmd: PartListUnion,
|
|
oneTimeShellAllowlist?: Set<string>,
|
|
overwriteConfirmed?: boolean,
|
|
existingInvocationItemId?: number,
|
|
invocationPromptId?: string,
|
|
isCurrentSubmission?: () => boolean,
|
|
) => Promise<SlashCommandProcessorResult | false>,
|
|
shellModeActive: boolean,
|
|
getPreferredEditor: () => EditorType | undefined,
|
|
onAuthError: (error: string) => void,
|
|
performMemoryRefresh: () => Promise<void>,
|
|
modelSwitchedFromQuotaError: boolean,
|
|
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
|
onEditorClose: () => void,
|
|
onCancelSubmit: (info?: CancelSubmitInfo) => void,
|
|
setShellInputFocused: (value: boolean) => void,
|
|
terminalWidth: number,
|
|
terminalHeight: number,
|
|
midTurnDrainRef?: React.RefObject<
|
|
((includeDeferred?: boolean, goalTurnActive?: boolean) => string[]) | null
|
|
>,
|
|
logger?: Logger | null,
|
|
// Live content-area height (terminal minus composer/header). Used to bound the
|
|
// pending item's rendered height so it commits to <Static> before it can grow
|
|
// tall enough to trigger the scroll-to-top redraw. A ref (not a value) because
|
|
// it is computed after this hook is called in AppContainer.
|
|
availableTerminalHeightRef?: React.RefObject<number>,
|
|
// Live terminal width, paired with the height ref so the commit loop reads
|
|
// both dimensions consistently across a mid-stream resize.
|
|
terminalWidthRef?: React.RefObject<number>,
|
|
midTurnRestoreRef?: React.RefObject<((messages: string[]) => void) | null>,
|
|
goalQueueRef?: React.RefObject<{
|
|
peekNextUserBatchKey: (goalTurnActive?: boolean) => string | undefined;
|
|
claimDirectUserAdmission?: () => DirectUserAdmission;
|
|
claimGoalTurn?: () => QueuedGoalTurn | undefined;
|
|
hasQueuedUserMessages?: () => boolean;
|
|
getPendingSubmissionCount?: () => number;
|
|
waitForReservationSettlement?: () => Promise<void>;
|
|
submissionInFlightRef?: React.RefObject<boolean>;
|
|
onSubmissionSettled?: () => void;
|
|
} | null>,
|
|
// Hands a typed prompt that was not sent back to the composer, with the
|
|
// pastes its placeholders stand for.
|
|
onPromptReturned?: (
|
|
text: string,
|
|
pasteExpansions?: ReadonlyMap<string, string>,
|
|
) => void,
|
|
) => {
|
|
const [workflowKeywordActive, setWorkflowKeywordActive] = useState(false);
|
|
const workflowConfigRef = useRef(config);
|
|
workflowConfigRef.current = config;
|
|
const [initError, setInitError] = useState<string | null>(null);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const activeInteractionPromptIdRef = useRef<string | undefined>(undefined);
|
|
const activeInteractionOwnerRef = useRef<
|
|
NonNullable<ReturnType<typeof getActiveInteractionSpan>> | undefined
|
|
>(undefined);
|
|
const activeGoalTurnRef = useRef<GoalTurnBinding | null>(null);
|
|
const activeGoalAdmissionRef = useRef<GoalTurnAdmission | null>(null);
|
|
const goalTurnBindingsRef = useRef(new Map<string, GoalTurnBinding>());
|
|
const bindGoalTurn = useCallback(
|
|
(
|
|
permit: GoalTurnPermit,
|
|
turnKey: string,
|
|
origin: GoalTurnBinding['origin'],
|
|
controller = new AbortController(),
|
|
): GoalTurnBinding => {
|
|
const existing = goalTurnBindingsRef.current.get(permit.turnId);
|
|
if (
|
|
existing &&
|
|
existing.turnKey === turnKey &&
|
|
sameGoalPermit(existing.permit, permit) &&
|
|
!existing.controller.signal.aborted
|
|
) {
|
|
activeGoalTurnRef.current = existing;
|
|
activeGoalAdmissionRef.current = existing;
|
|
return existing;
|
|
}
|
|
const binding: GoalTurnBinding = {
|
|
permit: { ...permit },
|
|
turnKey,
|
|
controller,
|
|
origin,
|
|
};
|
|
goalTurnBindingsRef.current.set(permit.turnId, binding);
|
|
activeGoalTurnRef.current = binding;
|
|
activeGoalAdmissionRef.current = binding;
|
|
return binding;
|
|
},
|
|
[],
|
|
);
|
|
const releaseGoalTurn = useCallback((binding: GoalTurnBinding) => {
|
|
if (goalTurnBindingsRef.current.get(binding.permit.turnId) === binding) {
|
|
goalTurnBindingsRef.current.delete(binding.permit.turnId);
|
|
}
|
|
if (activeGoalTurnRef.current === binding) {
|
|
activeGoalTurnRef.current = null;
|
|
}
|
|
if (
|
|
activeGoalAdmissionRef.current?.controller === binding.controller &&
|
|
activeGoalAdmissionRef.current.turnKey === binding.turnKey
|
|
) {
|
|
activeGoalAdmissionRef.current = null;
|
|
}
|
|
}, []);
|
|
const failClosedGoalTurn = useCallback(
|
|
async (
|
|
binding: GoalTurnBinding,
|
|
reason: string,
|
|
options?: { userCancelled?: boolean; pauseReason?: string },
|
|
): Promise<void> => {
|
|
if (!binding.controller.signal.aborted) {
|
|
binding.controller.abort(reason);
|
|
}
|
|
|
|
try {
|
|
const runtime = await config.getGoalRuntimeReady();
|
|
const admittedPermit = runtime.permitForTurn(binding.turnKey);
|
|
if (
|
|
!admittedPermit ||
|
|
!sameGoalPermit(admittedPermit, binding.permit)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (runtime.getSnapshot().goal?.status === 'active') {
|
|
try {
|
|
await runtime.dispatch({
|
|
action: 'pause',
|
|
expectedGoalId: binding.permit.goalId,
|
|
expectedRevision: binding.permit.revision,
|
|
// `reason` is the abort cause, which sibling hosts compare
|
|
// against sentinel constants and which the debug log wants
|
|
// verbatim. It is a scheduler diagnostic, so it never reaches
|
|
// the durable user-facing reason: a caller that has a sentence
|
|
// for the reader passes it as `pauseReason`, and everything
|
|
// else falls back to the builder's detail-free wording.
|
|
reason:
|
|
options?.pauseReason ??
|
|
(options?.userCancelled
|
|
? GOAL_PAUSE_REASON_USER_INTERRUPT
|
|
: goalPauseReasonForFailure('')),
|
|
});
|
|
} catch (error) {
|
|
debugLogger.warn('Failed to pause invalid Goal tool batch', error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await config.getChatRecordingService()?.flush();
|
|
} catch (error) {
|
|
debugLogger.warn('Failed to flush invalid Goal tool batch', error);
|
|
}
|
|
|
|
const currentPermit = runtime.permitForTurn(binding.turnKey);
|
|
if (currentPermit && sameGoalPermit(currentPermit, binding.permit)) {
|
|
await runtime.finishTurn(binding.permit);
|
|
}
|
|
} catch (error) {
|
|
debugLogger.warn('Failed to close invalid Goal tool batch', error);
|
|
} finally {
|
|
releaseGoalTurn(binding);
|
|
}
|
|
},
|
|
[config, releaseGoalTurn],
|
|
);
|
|
const releaseUndeliveredGoalTurn = useCallback(
|
|
async (turnKey: string | undefined): Promise<void> => {
|
|
if (!turnKey) return;
|
|
try {
|
|
const runtime = await config.getGoalRuntimeReady();
|
|
await runtime.releaseTurn(turnKey);
|
|
} catch (error) {
|
|
debugLogger.warn(
|
|
`Failed to release undelivered Goal turn ${turnKey}`,
|
|
error,
|
|
);
|
|
}
|
|
},
|
|
[config],
|
|
);
|
|
const flushBufferedStreamEventsRef = useRef<Set<() => void>>(new Set());
|
|
const turnCancelledRef = useRef(false);
|
|
// Whether the turn running now (or the last one) runs a Mod's own prompt.
|
|
const modPromptTurnRef = useRef(false);
|
|
const isSubmittingQueryRef = useRef(false);
|
|
const submissionLeaseGenerationRef = useRef(0);
|
|
// Re-runs the drain of the Mods' prompts (`$.prompt.submit`). One queued
|
|
// or put back while another submission held the session waits for that one
|
|
// to settle: it may start no turn (a cron's command, a notification dropped
|
|
// before sending), so the session can stay idle throughout.
|
|
const [modPromptTrigger, setModPromptTrigger] = useState(0);
|
|
const setSubmissionInFlight = useCallback(
|
|
(inFlight: boolean) => {
|
|
const changed = isSubmittingQueryRef.current !== inFlight;
|
|
isSubmittingQueryRef.current = inFlight;
|
|
const sharedRef = goalQueueRef?.current?.submissionInFlightRef;
|
|
if (sharedRef) sharedRef.current = inFlight;
|
|
if (changed && !inFlight) {
|
|
goalQueueRef?.current?.onSubmissionSettled?.();
|
|
if (getInteractiveModSession(config)?.hasSubmittedPrompts)
|
|
setModPromptTrigger((n) => n + 1);
|
|
}
|
|
},
|
|
[config, goalQueueRef],
|
|
);
|
|
// Teammate message queue. Declared here (above `handleCompletedTools`)
|
|
// because the tool-round boundary drains it (#8172): in a multi-round
|
|
// task `streamingState` never reaches Idle between rounds, so waiting
|
|
// for the Idle drain would hold teammate messages for the whole task.
|
|
const teammateQueueRef = useRef<TeammateQueueEntry[]>([]);
|
|
const [teammateTrigger, setTeammateTrigger] = useState(0);
|
|
// A TeamManager swap invalidates every teammate batch of the outgoing
|
|
// team — the queued ones AND the ones already drained but not yet
|
|
// settled (in flight inside a tool-round submission). The swap handler
|
|
// clears the queue; this generation counter covers the in-flight ones:
|
|
// every drain captures the generation, and both its restore and its
|
|
// settlement refuse to act on the queue/journal once it has moved.
|
|
const teammateQueueGenerationRef = useRef(0);
|
|
// Shared drain protocol for both delivery paths (tool-round boundary
|
|
// and Idle fallback): splice the pending batch, render one compact
|
|
// `● …` notification line per report (the full envelope goes only to
|
|
// the model), and hand back an idempotent restore that requeues the
|
|
// batch and re-arms the Idle drain. Keeping this in one place stops
|
|
// the display contract and the restore policy from drifting between
|
|
// the two call sites. What each call site does with the batch AFTER
|
|
// draining (submission shape, acceptance/restore settlement) stays at
|
|
// the call site, because the two paths genuinely differ there.
|
|
const drainTeammateQueue = useCallback((): {
|
|
entries: TeammateQueueEntry[];
|
|
restore: () => void;
|
|
generation: number;
|
|
} => {
|
|
const generation = teammateQueueGenerationRef.current;
|
|
const entries = teammateQueueRef.current.splice(0);
|
|
for (const entry of entries) {
|
|
if (!entry.displayed) {
|
|
addItem(
|
|
{ type: 'notification' as const, text: entry.display },
|
|
Date.now(),
|
|
);
|
|
entry.displayed = true;
|
|
}
|
|
}
|
|
let settled = false;
|
|
const restore = () => {
|
|
if (settled || entries.length === 0) return;
|
|
settled = true;
|
|
if (teammateQueueGenerationRef.current !== generation) {
|
|
debugLogger.debug(
|
|
`dropping ${entries.length} drained teammate message(s): team changed while in flight`,
|
|
);
|
|
return;
|
|
}
|
|
// A TeamManager swap moved the generation while this batch was in
|
|
// flight: the entries belong to a team that no longer exists, so
|
|
// requeueing them would submit them into the NEW team's session
|
|
// (the swap handler clears only the queue, not this closure).
|
|
// Drop them instead — the same fate as the queued entries the swap
|
|
// handler clears.
|
|
teammateQueueRef.current.unshift(...entries);
|
|
// Re-arm the Idle drain in case no further state change happens.
|
|
setTeammateTrigger((n) => n + 1);
|
|
};
|
|
return { entries, restore, generation };
|
|
}, [addItem]);
|
|
const lastPromptRef = useRef<PartListUnion | null>(null);
|
|
// Records the USER history item that THIS turn's prepareQueryForLlm
|
|
// added (if any). Reset to null at the start of every turn (including
|
|
// Retry, which bypasses prepareQueryForLlm). Cron / Notification /
|
|
// slash submit_prompt paths don't add a user item, so this stays null
|
|
// on those turns. The cancel handler uses this to verify that the
|
|
// candidate `lastUserItem` it's about to rewind actually came from the
|
|
// cancelled turn — without the guard, an older user item with
|
|
// only-synthetic trailing could be wrongly truncated when a non-USER
|
|
// turn is cancelled.
|
|
//
|
|
// Identity is carried as `{ id, text }` (not just text) because
|
|
// `useHistoryManager.addItem` skips consecutive-duplicate user
|
|
// messages while still returning a freshly-generated id — text alone
|
|
// would let the auto-restore guard wrongly match an older USER row
|
|
// when the user re-submits the same prompt.
|
|
const lastTurnUserItemRef = useRef<{
|
|
id: number;
|
|
text: string;
|
|
submittedPrompt?: string;
|
|
humanInput?: HumanPromptInput;
|
|
hasUnknownInput?: boolean;
|
|
pasteExpansions?: ReadonlyMap<string, string>;
|
|
/** Prompt as sent to the model, when it differed from `text`. */
|
|
modelText?: string;
|
|
} | null>(null);
|
|
const canUndoLastLoggedUserMessageRef = useRef(false);
|
|
// Set to true the first time a content event lands this turn — even
|
|
// during the pre-cancel flush. AppContainer's auto-restore guard
|
|
// can't otherwise see content that was just addItem'd inside flush
|
|
// (React history hasn't re-rendered) and would wrongly truncate the
|
|
// committed text alongside the cancelled prompt. Reset at turn start
|
|
// alongside lastTurnUserItemRef.
|
|
const turnSawContentEventRef = useRef(false);
|
|
const lastPromptErroredRef = useRef(false);
|
|
const goalTerminalErrorRef = useRef(false);
|
|
// Envelope parts stripped from `lastPromptRef` when their drained
|
|
// teammate batch was ACCEPTED (the push landed, so the envelopes are in
|
|
// the session history and a retry must not re-send them). The debt is
|
|
// re-attached in `retryLastPrompt` when — and only when — the pushed
|
|
// entry is a trailing orphan at retry time, i.e. the accepted round
|
|
// failed terminally BEFORE producing content: the Retry path pops that
|
|
// orphan entry before re-pushing the stored payload, so a payload still
|
|
// missing its envelopes would silently lose them while the delivery
|
|
// journal claims delivered. Consumption is gated on admission: the
|
|
// evaluation runs inside `retryLastPrompt` only after the admission
|
|
// gate is known to pass, and the consumed records transfer into a
|
|
// settlement carrier on the retry's own submission, which records debt
|
|
// for the retry's re-pushed entry when its push lands — so an envelope
|
|
// that survives one retry is still protected if a later, different
|
|
// payload's retry orphans it again.
|
|
//
|
|
// Each record also carries `pushedEntryParts` — the parts of the pushed
|
|
// history entry as captured at accept time — as an identity fingerprint
|
|
// for the retry-time match. Envelope texts alone are not an identity:
|
|
// teammate envelopes are deterministic machine text (e.g. repeated
|
|
// `<team_error>` notices), so a byte-identical resend can orphan a
|
|
// YOUNGER entry while this debt's own entry sits safely mid-history —
|
|
// a text-only match would then re-attach the debt and deliver the
|
|
// report twice. The fingerprint carries the entry's tool-response
|
|
// parts (unique callIds), which a colliding younger entry cannot share.
|
|
const boundaryEnvelopeRetryDebtRef = useRef<
|
|
Array<{ envelopeParts: Part[]; pushedEntryParts: Part[] }>
|
|
>([]);
|
|
|
|
// Wrapper around addItem that attaches timestamp to gemini items for display.
|
|
// Only 'gemini' (new assistant turn) gets a timestamp; 'gemini_content'
|
|
// (same turn, performance-split continuation) does not.
|
|
const commitItem = useCallback(
|
|
(item: HistoryItemWithoutId, userMessageTimestamp: number): number => {
|
|
if (item.type === 'gemini' && !(item as HistoryItemLlm).timestamp) {
|
|
(item as HistoryItemLlm).timestamp = Date.now();
|
|
}
|
|
return addItem(item, userMessageTimestamp);
|
|
},
|
|
[addItem],
|
|
);
|
|
|
|
const dualOutput = useDualOutput();
|
|
const [isResponding, setIsResponding] = useState<boolean>(false);
|
|
// React state can lag by one render; this tracks the actual stream lifetime.
|
|
const activeModelStreamsRef = useRef(0);
|
|
// Shared tool batches that settled during a model stream, waiting for no
|
|
// stream to be active, as the current path holds such a batch. The drain
|
|
// resumes them and waits until each is `done`, so their results join the
|
|
// queue it drains.
|
|
const modelStreamsIdleWaitersRef = useRef<
|
|
Array<{ resume: () => void; done: Promise<void> }>
|
|
>([]);
|
|
const waitForModelStreamsIdle = useCallback(
|
|
(done: Promise<void>) =>
|
|
activeModelStreamsRef.current === 0
|
|
? Promise.resolve()
|
|
: new Promise<void>((resume) => {
|
|
modelStreamsIdleWaitersRef.current.push({ resume, done });
|
|
}),
|
|
[],
|
|
);
|
|
// While the drain waits on the batches it resumed, completed batches join
|
|
// its queue, as during a model stream.
|
|
const completedToolBatchDrainHoldsRef = useRef(0);
|
|
// The quota switch as it is now, for tool batches that settle or drain
|
|
// renders after it changed.
|
|
const modelSwitchedFromQuotaErrorRef = useRef(modelSwitchedFromQuotaError);
|
|
useLayoutEffect(() => {
|
|
modelSwitchedFromQuotaErrorRef.current = modelSwitchedFromQuotaError;
|
|
}, [modelSwitchedFromQuotaError]);
|
|
// A continuation may be admitted while an earlier submission is finalizing.
|
|
const submissionActivitiesByGenerationRef = useRef(new Map<number, number>());
|
|
const settleSubmissionStateIfIdle = useCallback(() => {
|
|
const currentGeneration = submissionLeaseGenerationRef.current;
|
|
if (
|
|
(submissionActivitiesByGenerationRef.current.get(currentGeneration) ??
|
|
0) === 0 &&
|
|
activeModelStreamsRef.current === 0
|
|
) {
|
|
setIsResponding(false);
|
|
setSubmissionInFlight(false);
|
|
}
|
|
}, [setSubmissionInFlight]);
|
|
const retainSubmissionActivity = useCallback(
|
|
(generation: number) => {
|
|
submissionActivitiesByGenerationRef.current.set(
|
|
generation,
|
|
(submissionActivitiesByGenerationRef.current.get(generation) ?? 0) + 1,
|
|
);
|
|
return () => {
|
|
const remainingActivities = Math.max(
|
|
0,
|
|
(submissionActivitiesByGenerationRef.current.get(generation) ?? 1) -
|
|
1,
|
|
);
|
|
if (remainingActivities === 0) {
|
|
submissionActivitiesByGenerationRef.current.delete(generation);
|
|
} else {
|
|
submissionActivitiesByGenerationRef.current.set(
|
|
generation,
|
|
remainingActivities,
|
|
);
|
|
}
|
|
settleSubmissionStateIfIdle();
|
|
};
|
|
},
|
|
[settleSubmissionStateIfIdle],
|
|
);
|
|
const [thought, setThought] = useState<ThoughtSummary | null>(null);
|
|
// Hold the latest history in a ref so handleCompletedTools can read it
|
|
// without depending on `history` (which would recreate the tool scheduler
|
|
// every render). Use useLayoutEffect instead of writing during render —
|
|
// writing refs in the render phase is unsafe under React's concurrent
|
|
// rendering (a bailed-out render could leave the ref with a dropped value).
|
|
const historyRef = useRef<HistoryItem[]>(history);
|
|
useLayoutEffect(() => {
|
|
historyRef.current = history;
|
|
}, [history]);
|
|
// In-flight auxiliary work. Some work is batch-scoped rather than turn-scoped:
|
|
// summaries intentionally outlive the turn, and mid-turn @ resolution may run
|
|
// before submitQuery installs the next turn controller.
|
|
// cancelOngoingRequest aborts these controllers so Ctrl+C still cancels them.
|
|
const auxiliaryAbortRefsRef = useRef<Set<AbortController>>(new Set());
|
|
// The Mods hear the foreground turn from its admission until the stream
|
|
// goes idle; a cancel or a failure on the way records how it ended.
|
|
const modTurnRef = useRef<ForegroundModTurn | null>(null);
|
|
// Whether the quiet cancel `$.turn.abort` asked for ended anything.
|
|
const cancelForModRef = useRef<() => boolean>(() => false);
|
|
const quietCancelRef = useRef(false);
|
|
// A turn whose stream ends at once may never render as Responding.
|
|
const [modTurnId, setModTurnId] = useState<string>();
|
|
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
|
|
useStateAndRef<HistoryItemWithoutId | null>(null);
|
|
// Mixed assistant output needs multiple live rows to preserve
|
|
// text/image/reasoning ordering. Keep completed runs in the dynamic region until
|
|
// the response reaches a normal commit boundary so a fresh retry or model
|
|
// fallback can still discard the entire failed attempt.
|
|
const [
|
|
pendingAssistantItems,
|
|
pendingAssistantItemsRef,
|
|
setPendingAssistantItems,
|
|
] = useStateAndRef<HistoryItemWithoutId[]>([]);
|
|
const commitPendingAssistantItems = useCallback(
|
|
(userMessageTimestamp: number) => {
|
|
const items = pendingAssistantItemsRef.current;
|
|
if (items.length === 0) {
|
|
return;
|
|
}
|
|
for (const item of items) {
|
|
commitItem(item, userMessageTimestamp);
|
|
}
|
|
setPendingAssistantItems([]);
|
|
},
|
|
[commitItem, pendingAssistantItemsRef, setPendingAssistantItems],
|
|
);
|
|
const commitItemInOrder = useCallback(
|
|
(item: HistoryItemWithoutId, userMessageTimestamp: number): number => {
|
|
commitPendingAssistantItems(userMessageTimestamp);
|
|
return commitItem(item, userMessageTimestamp);
|
|
},
|
|
[commitItem, commitPendingAssistantItems],
|
|
);
|
|
const stagePendingAssistantItem = useCallback((): boolean => {
|
|
const item = pendingHistoryItemRef.current;
|
|
if (item?.type !== 'gemini' && item?.type !== 'gemini_content') {
|
|
return false;
|
|
}
|
|
setPendingAssistantItems((items) => [...items, item]);
|
|
setPendingHistoryItem(null);
|
|
return true;
|
|
}, [pendingHistoryItemRef, setPendingAssistantItems, setPendingHistoryItem]);
|
|
// Streamed model reasoning for the current turn. Rendered (height-limited)
|
|
// after preceding assistant output, then stored as a collapsible
|
|
// `gemini_thought` block when the next answer/tool/turn begins.
|
|
const [pendingThoughtItem, pendingThoughtItemRef, setPendingThoughtItem] =
|
|
useStateAndRef<HistoryItemWithoutId | null>(null);
|
|
const thoughtStartTimeRef = useRef<number | null>(null);
|
|
const thoughtGroupIdRef = useRef<number | undefined>(undefined);
|
|
const lastThoughtGroupIdRef = useRef(0);
|
|
// Commit the streamed reasoning to history as a collapsible block (or drop
|
|
// it). Called when the answer/tool/turn begins, or on cancel/error.
|
|
const commitPendingThought = useCallback(
|
|
(userMessageTimestamp: number, deferForInterleaving = false) => {
|
|
const pendingThought = pendingThoughtItemRef.current;
|
|
if (
|
|
(pendingThought?.type === 'gemini_thought' ||
|
|
pendingThought?.type === 'gemini_thought_content') &&
|
|
pendingThought.thoughtGroupId !== undefined
|
|
) {
|
|
setPendingAssistantItems((items) =>
|
|
items.map((item) =>
|
|
(item.type === 'gemini_thought' ||
|
|
item.type === 'gemini_thought_content') &&
|
|
item.thoughtGroupId === pendingThought.thoughtGroupId
|
|
? { ...item, isStreaming: false }
|
|
: item,
|
|
),
|
|
);
|
|
}
|
|
if (!deferForInterleaving) {
|
|
commitPendingAssistantItems(userMessageTimestamp);
|
|
}
|
|
if (pendingThought) {
|
|
const item = { ...pendingThought };
|
|
if (
|
|
(item.type === 'gemini_thought' ||
|
|
item.type === 'gemini_thought_content') &&
|
|
item.thoughtGroupId !== undefined
|
|
) {
|
|
item.isStreaming = false;
|
|
}
|
|
if (item.type === 'gemini_thought' && thoughtStartTimeRef.current) {
|
|
item.durationMs = Date.now() - thoughtStartTimeRef.current;
|
|
}
|
|
if (
|
|
deferForInterleaving &&
|
|
pendingAssistantItemsRef.current.length > 0
|
|
) {
|
|
setPendingAssistantItems((items) => [...items, item]);
|
|
} else {
|
|
addItem(item, userMessageTimestamp);
|
|
}
|
|
}
|
|
setPendingThoughtItem(null);
|
|
thoughtStartTimeRef.current = null;
|
|
thoughtGroupIdRef.current = undefined;
|
|
},
|
|
[
|
|
addItem,
|
|
pendingThoughtItemRef,
|
|
setPendingThoughtItem,
|
|
commitPendingAssistantItems,
|
|
pendingAssistantItemsRef,
|
|
setPendingAssistantItems,
|
|
],
|
|
);
|
|
|
|
const [
|
|
pendingRetryErrorItem,
|
|
pendingRetryErrorItemRef,
|
|
setPendingRetryErrorItem,
|
|
] = useStateAndRef<HistoryItemWithoutId | null>(null);
|
|
const [
|
|
pendingRetryCountdownItem,
|
|
pendingRetryCountdownItemRef,
|
|
setPendingRetryCountdownItem,
|
|
] = useStateAndRef<HistoryItemWithoutId | null>(null);
|
|
const clearPendingState = useCallback(() => {
|
|
setWorkflowKeywordActive(false);
|
|
setPendingAssistantItems([]);
|
|
setPendingHistoryItem(null);
|
|
setPendingRetryErrorItem(null);
|
|
}, [
|
|
setPendingAssistantItems,
|
|
setPendingHistoryItem,
|
|
setPendingRetryErrorItem,
|
|
]);
|
|
const retryCountdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(
|
|
null,
|
|
);
|
|
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
|
|
const submitPromptOnCompleteRef = useRef<(() => Promise<void>) | null>(null);
|
|
const refreshContextFilesOnWriteRef = useRef(false);
|
|
const modelOverrideRef = useRef<string | undefined>(undefined);
|
|
// True when the current turn's model override came from an explicit inline
|
|
// `/model <id> <prompt>`. Skill-tool overrides must not clobber a user's
|
|
// explicit choice mid-turn, so this takes precedence until the next user turn.
|
|
const inlineModelOverrideActiveRef = useRef<boolean>(false);
|
|
const canUseToolResultFullTurnModel = useCallback((model: string) => {
|
|
const current = modelOverrideRef.current;
|
|
return (
|
|
!inlineModelOverrideActiveRef.current &&
|
|
(!current?.endsWith('\0') || current === model)
|
|
);
|
|
}, []);
|
|
// Provider tool-call id → (name, args) fingerprint of calls admitted for
|
|
// execution in the current submit; merged with the history-derived map for
|
|
// duplicate provider-id replay detection.
|
|
const handledToolCallFingerprintsRef = useRef<Map<string, string>>(new Map());
|
|
// Scoped to a top-level submit and cleared below before a new user prompt.
|
|
// Repeated duplicate provider ids within that submit are terminal/drop-only.
|
|
const duplicateProviderToolCallResponseIdsRef = useRef<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const pendingDuplicateToolResponsesRef = useRef<
|
|
PendingDuplicateToolResponses[]
|
|
>([]);
|
|
const interactionOwnersByToolCallIdRef = useRef(
|
|
new Map<string, NonNullable<ReturnType<typeof getActiveInteractionSpan>>>(),
|
|
);
|
|
const continuationOwnersByToolCallIdRef = useRef(
|
|
new Map<string, ToolContinuationOwner>(),
|
|
);
|
|
const detachedToolContinuationAbortControllersRef = useRef(
|
|
new Set<AbortController>(),
|
|
);
|
|
// Batches that completed while a model stream was active, with the shared
|
|
// batch's results when it ran them.
|
|
const pendingCompletedToolBatchesRef = useRef<
|
|
Array<{ tools: TrackedToolCall[]; shared?: SharedToolBatchResults }>
|
|
>([]);
|
|
const sendToolResultRoundRef = useRef<
|
|
(round: ToolResultRoundState) => Promise<void>
|
|
>(async () => {});
|
|
// On the shared loop the completion step returns the next round instead
|
|
// of sending it.
|
|
const handleCompletedToolsRef = useRef<
|
|
(
|
|
completedTools: TrackedToolCall[],
|
|
shared?: SharedToolBatchResults,
|
|
) => Promise<ToolResultRound | undefined>
|
|
>(async () => undefined);
|
|
const commitCompletedToolBatchRef = useRef<
|
|
(
|
|
completed: InteractiveToolCall[],
|
|
shared?: SharedToolBatchResults,
|
|
committedToolGroup?: { batchId: string | undefined },
|
|
) => Promise<ToolResultRound | undefined>
|
|
>(async () => undefined);
|
|
// The client the TUI holds now, for a shared batch that settles renders
|
|
// after it started.
|
|
const llmClientRef = useRef(llmClient);
|
|
useLayoutEffect(() => {
|
|
llmClientRef.current = llmClient;
|
|
}, [llmClient]);
|
|
const runSharedToolBatchRef = useRef<
|
|
(
|
|
batch: SharedToolBatch,
|
|
turn: SharedToolBatchTurn,
|
|
) => Promise<ToolResultRound | undefined>
|
|
>(async () => undefined);
|
|
const immediateDuplicateToolResponsesRef = useRef<{
|
|
promptId: string | undefined;
|
|
responses: Array<{
|
|
request: ToolCallRequestInfo;
|
|
response: ToolCallResponseInfo;
|
|
}>;
|
|
} | null>(null);
|
|
// --- Real-time token display ---
|
|
// Accumulates output character count across the whole turn (not per API call).
|
|
// Uses a ref to avoid re-renders on every text_delta.
|
|
const streamingResponseLengthRef = useRef(0);
|
|
// Tracks whether we are receiving content (↓) or waiting for API (↑).
|
|
const [isReceivingContent, setIsReceivingContent] = useState(false);
|
|
const {
|
|
startNewPrompt,
|
|
getPromptCount,
|
|
stats: sessionStates,
|
|
} = useSessionStats();
|
|
const storage = config.storage;
|
|
|
|
// Batch identity for tool_group duplicate collapsing (#9420): minted when
|
|
// a batch is scheduled, stamped on both the live pending display group and
|
|
// the history item committed by onComplete below, so MainContent can
|
|
// collapse the transient double render of one batch by identity — callIds
|
|
// are not an identity (ids are re-minted after core-history compaction and
|
|
// providers can reuse wire ids across turns).
|
|
const toolBatchIdByCallIdRef = useRef(new Map<string, string>());
|
|
const toolBatchCounterRef = useRef(0);
|
|
// Per-mount nonce: checkpoint JSON persists stamped history and /restore
|
|
// loads it into a session whose counter restarts at 0 — without it, a
|
|
// restored committed row would collide with a freshly minted batch and
|
|
// the collapse would drop the wrong row.
|
|
const toolBatchNonceRef = useRef(Math.random().toString(36).slice(2));
|
|
const registerToolBatch = useCallback(
|
|
(requests: ToolCallRequestInfo | ToolCallRequestInfo[]) => {
|
|
const batchNumber = ++toolBatchCounterRef.current;
|
|
const batchId = `tool-batch-${toolBatchNonceRef.current}-${batchNumber}`;
|
|
for (const request of Array.isArray(requests) ? requests : [requests]) {
|
|
toolBatchIdByCallIdRef.current.set(request.callId, batchId);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
const getToolBatchId = useCallback(
|
|
(callId: string): string | undefined =>
|
|
toolBatchIdByCallIdRef.current.get(callId),
|
|
[],
|
|
);
|
|
|
|
const [toolCalls, scheduleToolCalls, markToolsAsSubmitted] =
|
|
useReactToolScheduler(
|
|
// This onComplete is called when ALL scheduled tools for a given batch
|
|
// are done.
|
|
// Without the shared batch's results the completion step sends the
|
|
// next round itself.
|
|
async (completedToolCallsFromScheduler) => {
|
|
await commitCompletedToolBatchRef.current(
|
|
completedToolCallsFromScheduler,
|
|
);
|
|
},
|
|
config,
|
|
getPreferredEditor,
|
|
onEditorClose,
|
|
canUseToolResultFullTurnModel,
|
|
(call) =>
|
|
observeInteractiveModTool(
|
|
config,
|
|
call,
|
|
refreshContextFilesOnWriteRef.current,
|
|
),
|
|
);
|
|
|
|
const pendingToolCallGroupDisplay = useMemo(() => {
|
|
if (!toolCalls.length) return undefined;
|
|
const group = mapTrackedToolCallsToDisplay(
|
|
toolCalls,
|
|
config.getProjectRoot(),
|
|
);
|
|
group.batchId = getToolBatchId(toolCalls[0].request.callId);
|
|
return group;
|
|
}, [toolCalls, config, getToolBatchId]);
|
|
|
|
const activeToolPtyId = useMemo(() => {
|
|
const executingShellTool = toolCalls?.find(
|
|
(tc) =>
|
|
tc.status === 'executing' && tc.request.name === 'run_shell_command',
|
|
);
|
|
if (executingShellTool) {
|
|
return (executingShellTool as { pid?: number }).pid;
|
|
}
|
|
return undefined;
|
|
}, [toolCalls]);
|
|
|
|
const loopDetectedRef = useRef(false);
|
|
// Type and reason of the stop behind loopDetectedRef, for the dialog and
|
|
// the message after it.
|
|
const loopStopRef = useRef<LoopStop | undefined>(undefined);
|
|
const [
|
|
loopDetectionConfirmationRequest,
|
|
setLoopDetectionConfirmationRequest,
|
|
] = useState<LoopDetectionConfirmationRequest | null>(null);
|
|
|
|
const stopRetryCountdownTimer = useCallback(() => {
|
|
if (retryCountdownTimerRef.current) {
|
|
clearInterval(retryCountdownTimerRef.current);
|
|
retryCountdownTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Clears the retry countdown timer and pending retry items.
|
|
*/
|
|
const clearRetryCountdown = useCallback(() => {
|
|
stopRetryCountdownTimer();
|
|
skipRetryDelayRef.current = null;
|
|
setPendingRetryErrorItem(null);
|
|
setPendingRetryCountdownItem(null);
|
|
}, [
|
|
setPendingRetryErrorItem,
|
|
setPendingRetryCountdownItem,
|
|
stopRetryCountdownTimer,
|
|
]);
|
|
|
|
// Holds the skipDelay callback from the current rate-limit RetryInfo.
|
|
// Managed symmetrically: set in startRetryCountdown, cleared in clearRetryCountdown.
|
|
const skipRetryDelayRef = useRef<(() => void) | null>(null);
|
|
|
|
const startRetryCountdown = useCallback(
|
|
(retryInfo: RetryInfo) => {
|
|
stopRetryCountdownTimer();
|
|
skipRetryDelayRef.current = retryInfo.skipDelay;
|
|
const startTime = Date.now();
|
|
const { message, attempt, maxRetries, delayMs } = retryInfo;
|
|
const retryReasonText =
|
|
message ?? t('Rate limit exceeded. Please wait and try again.');
|
|
|
|
// Countdown line updates every second (dim/secondary color)
|
|
const updateCountdown = () => {
|
|
const elapsedMs = Date.now() - startTime;
|
|
const remainingMs = Math.max(0, delayMs - elapsedMs);
|
|
const remainingSec = Math.ceil(remainingMs / 1000);
|
|
|
|
// Update error item with hint containing countdown info (short format)
|
|
const hintText = `Retrying in ${remainingSec}s… (attempt ${attempt}/${maxRetries})`;
|
|
|
|
setPendingRetryErrorItem({
|
|
type: MessageType.ERROR,
|
|
text: retryReasonText,
|
|
hint: hintText,
|
|
});
|
|
|
|
setPendingRetryCountdownItem({
|
|
type: 'retry_countdown',
|
|
text: t(
|
|
'Retrying in {{seconds}} seconds… (attempt {{attempt}}/{{maxRetries}})',
|
|
{
|
|
seconds: String(remainingSec),
|
|
attempt: String(attempt),
|
|
maxRetries: String(maxRetries),
|
|
},
|
|
),
|
|
} as HistoryItemWithoutId);
|
|
|
|
if (remainingMs <= 0) {
|
|
stopRetryCountdownTimer();
|
|
}
|
|
};
|
|
|
|
updateCountdown();
|
|
retryCountdownTimerRef.current = setInterval(updateCountdown, 1000);
|
|
},
|
|
[
|
|
setPendingRetryErrorItem,
|
|
setPendingRetryCountdownItem,
|
|
stopRetryCountdownTimer,
|
|
],
|
|
);
|
|
|
|
useEffect(() => () => stopRetryCountdownTimer(), [stopRetryCountdownTimer]);
|
|
|
|
const onExec = useCallback(
|
|
async (done: Promise<void>) => {
|
|
const shellAbortController = abortControllerRef.current;
|
|
if (shellAbortController) {
|
|
detachedToolContinuationAbortControllersRef.current.add(
|
|
shellAbortController,
|
|
);
|
|
}
|
|
const releaseShellActivity = retainSubmissionActivity(
|
|
submissionLeaseGenerationRef.current,
|
|
);
|
|
setIsResponding(true);
|
|
try {
|
|
await done;
|
|
} finally {
|
|
if (shellAbortController) {
|
|
detachedToolContinuationAbortControllersRef.current.delete(
|
|
shellAbortController,
|
|
);
|
|
}
|
|
releaseShellActivity();
|
|
}
|
|
},
|
|
[retainSubmissionActivity],
|
|
);
|
|
const { handleShellCommand, activeShellPtyId } = useShellCommandProcessor(
|
|
addItem,
|
|
setPendingHistoryItem,
|
|
onExec,
|
|
onDebugMessage,
|
|
config,
|
|
llmClient,
|
|
setShellInputFocused,
|
|
terminalWidth,
|
|
terminalHeight,
|
|
);
|
|
|
|
const activePtyId = activeShellPtyId || activeToolPtyId;
|
|
|
|
useEffect(() => {
|
|
if (!activePtyId) {
|
|
setShellInputFocused(false);
|
|
}
|
|
}, [activePtyId, setShellInputFocused]);
|
|
|
|
const streamingState = useMemo(() => {
|
|
if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) {
|
|
return StreamingState.WaitingForConfirmation;
|
|
}
|
|
// Check if any executing subagent task has a pending confirmation
|
|
if (
|
|
toolCalls.some((tc) => {
|
|
if (tc.status !== 'executing') return false;
|
|
const liveOutput = (tc as TrackedExecutingToolCall).liveOutput;
|
|
return (
|
|
typeof liveOutput === 'object' &&
|
|
liveOutput !== null &&
|
|
'type' in liveOutput &&
|
|
liveOutput.type === 'task_execution' &&
|
|
'pendingConfirmation' in liveOutput &&
|
|
liveOutput.pendingConfirmation != null
|
|
);
|
|
})
|
|
) {
|
|
return StreamingState.WaitingForConfirmation;
|
|
}
|
|
if (
|
|
isResponding ||
|
|
toolCalls.some(
|
|
(tc) =>
|
|
tc.status === 'executing' ||
|
|
tc.status === 'scheduled' ||
|
|
tc.status === 'validating' ||
|
|
((tc.status === 'success' ||
|
|
tc.status === 'error' ||
|
|
tc.status === 'cancelled') &&
|
|
!(tc as TrackedCompletedToolCall | TrackedCancelledToolCall)
|
|
.responseSubmittedToLlm),
|
|
)
|
|
) {
|
|
return StreamingState.Responding;
|
|
}
|
|
return StreamingState.Idle;
|
|
}, [isResponding, toolCalls]);
|
|
|
|
useEffect(() => {
|
|
if (streamingState === StreamingState.Idle) setWorkflowKeywordActive(false);
|
|
}, [streamingState]);
|
|
const finishModTurn = useCallback(() => {
|
|
const current = modTurnRef.current;
|
|
modTurnRef.current = null;
|
|
void current?.turn.complete(current.ending);
|
|
}, []);
|
|
useEffect(() => {
|
|
if (streamingState === StreamingState.Idle) finishModTurn();
|
|
}, [streamingState, modTurnId, finishModTurn]);
|
|
useEffect(() => {
|
|
setWorkflowKeywordActive(false);
|
|
}, [config, sessionStates.sessionId]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
config.getApprovalMode() === ApprovalMode.YOLO &&
|
|
streamingState === StreamingState.Idle
|
|
) {
|
|
const lastUserMessageIndex = findLastUserItemIndex(history);
|
|
|
|
const turnCount =
|
|
lastUserMessageIndex === -1 ? 0 : history.length - lastUserMessageIndex;
|
|
|
|
if (turnCount > 0) {
|
|
logConversationFinishedEvent(
|
|
config,
|
|
new ConversationFinishedEvent(config.getApprovalMode(), turnCount),
|
|
);
|
|
}
|
|
}
|
|
}, [streamingState, config, history]);
|
|
|
|
const cancelOngoingRequest = useCallback((): boolean => {
|
|
// Set for one call by `$.turn.abort`: the same cancel, without its
|
|
// notice, the loop stop or the composer restore.
|
|
const quiet = quietCancelRef.current;
|
|
quietCancelRef.current = false;
|
|
setWorkflowKeywordActive(false);
|
|
if (turnCancelledRef.current) {
|
|
for (const controller of detachedToolContinuationAbortControllersRef.current) {
|
|
controller.abort();
|
|
}
|
|
detachedToolContinuationAbortControllersRef.current.clear();
|
|
return false;
|
|
}
|
|
// A Mod can end its turn before React renders the turn's admission:
|
|
// the turn and the stream it opened already count.
|
|
if (
|
|
quiet
|
|
? streamingState === StreamingState.Idle &&
|
|
!(modTurnRef.current && activeModelStreamsRef.current > 0)
|
|
: streamingState !== StreamingState.Responding
|
|
) {
|
|
return false;
|
|
}
|
|
// The person stopped the session's work: the Mods' prompts still queued
|
|
// do not start once it is idle, nor do those the stopped turn's hooks
|
|
// submit. A Mod that ends a turn keeps them.
|
|
if (!quiet) getInteractiveModSession(config)?.stopSubmittedPrompts();
|
|
if (modTurnRef.current) {
|
|
modTurnRef.current.ending = 'aborted';
|
|
modTurnRef.current.cancel.abort();
|
|
}
|
|
// Flush throttled stream chunks FIRST so anything sitting in the
|
|
// per-turn bufferedEvents lands on `pendingHistoryItemRef.current`
|
|
// before we snapshot. Snapshotting before flush would miss content
|
|
// events that arrived inside the throttle window
|
|
// (STREAM_UPDATE_THROTTLE_MS), making AppContainer's auto-restore
|
|
// wrongly conclude the model produced nothing — and the subsequent
|
|
// commitItem(pendingHistoryItemRef.current) below would commit content
|
|
// that auto-restore then truncates away.
|
|
for (const flushBufferedStreamEvents of flushBufferedStreamEventsRef.current) {
|
|
flushBufferedStreamEvents();
|
|
}
|
|
// Snapshot AFTER flush, BEFORE any addItem / setPendingHistoryItem(null)
|
|
// mutate the ref. This is what `onCancelSubmit` consumers (auto-restore
|
|
// in AppContainer) need to decide whether the model produced meaningful
|
|
// in-flight content — reading the React-state copy at the consumer
|
|
// would race with stream chunks that haven't re-rendered yet.
|
|
const pendingItemAtCancel = pendingHistoryItemRef.current;
|
|
turnCancelledRef.current = true;
|
|
submissionLeaseGenerationRef.current += 1;
|
|
setSubmissionInFlight(false);
|
|
const foregroundAbortController = abortControllerRef.current;
|
|
if (
|
|
foregroundAbortController &&
|
|
!foregroundAbortController.signal.aborted
|
|
) {
|
|
foregroundAbortController.abort();
|
|
} else {
|
|
for (const controller of detachedToolContinuationAbortControllersRef.current) {
|
|
controller.abort();
|
|
}
|
|
detachedToolContinuationAbortControllersRef.current.clear();
|
|
}
|
|
const activeInteractionPromptId = activeInteractionPromptIdRef.current;
|
|
const activeInteractionOwner = activeInteractionOwnerRef.current;
|
|
if (
|
|
activeInteractionPromptId &&
|
|
activeInteractionOwner &&
|
|
getActiveInteractionSpan(activeInteractionPromptId) ===
|
|
activeInteractionOwner
|
|
) {
|
|
endInteractionSpan('cancelled', {
|
|
promptId: activeInteractionPromptId,
|
|
});
|
|
activeInteractionOwnerRef.current = undefined;
|
|
}
|
|
// Aborting a tick-in-flight ends any self-paced /loop: drop pending loop
|
|
// wakeups so the loop doesn't resume after the cancelled tick. Only clears
|
|
// session wakeups (never cron jobs); lazily-creating an empty scheduler
|
|
// here is inert.
|
|
const loopWakeupsCancelled = quiet
|
|
? 0
|
|
: (config.getCronScheduler()?.cancelAllWakeups() ?? 0);
|
|
// Cancel any in-flight auxiliary work so its Promise.then doesn't add
|
|
// stale content after the user cancelled.
|
|
for (const ac of auxiliaryAbortRefsRef.current) {
|
|
ac.abort();
|
|
}
|
|
auxiliaryAbortRefsRef.current.clear();
|
|
|
|
// Report cancellation to arena status reporter (if in arena mode).
|
|
// This is needed because cancellation during tool execution won't
|
|
// flow through sendMessageStream where the inline reportCancelled()
|
|
// lives — tools get cancelled and handleCompletedTools returns early.
|
|
config.getArenaAgentClient()?.reportCancelled();
|
|
|
|
// Log API cancellation
|
|
const prompt_id = config.getSessionId() + '########' + getPromptCount();
|
|
const cancellationEvent = new ApiCancelEvent(
|
|
modelOverrideRef.current ?? config.getModel(),
|
|
prompt_id,
|
|
config.getContentGeneratorConfig()?.authType,
|
|
loopWakeupsCancelled > 0 ? loopWakeupsCancelled : undefined,
|
|
);
|
|
logApiCancel(config, cancellationEvent);
|
|
|
|
commitPendingThought(Date.now());
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(pendingHistoryItemRef.current, Date.now());
|
|
}
|
|
if (!quiet) {
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: 'Request cancelled.',
|
|
},
|
|
Date.now(),
|
|
);
|
|
}
|
|
if (loopWakeupsCancelled > 0) {
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: `Stopped the self-paced loop: cancelled ${loopWakeupsCancelled} pending wakeup${
|
|
loopWakeupsCancelled === 1 ? '' : 's'
|
|
}.`,
|
|
},
|
|
Date.now(),
|
|
);
|
|
}
|
|
setPendingHistoryItem(null);
|
|
clearRetryCountdown();
|
|
// Wrap the consumer callback so a throw in AppContainer's cancel
|
|
// handler can't strand the stream in `Responding` (which would lock
|
|
// the UI — Esc would no-op, the user would have to restart). State
|
|
// resets always run.
|
|
//
|
|
// Coupling note: AppContainer's auto-restore guard reads
|
|
// `historyRef.current` which does NOT yet contain the INFO/pending
|
|
// items we just enqueued via addItem above (React batches updates).
|
|
// That guard's correctness depends on the items added here staying
|
|
// synthetic (info/error/etc.) so the trailing-only-synthetic check
|
|
// returns the same answer with or without them. If you ever add a
|
|
// non-synthetic item here (e.g., a meaningful assistant block),
|
|
// either move the auto-restore check to read functional setState
|
|
// or revisit isSyntheticHistoryItem.
|
|
try {
|
|
if (!quiet)
|
|
onCancelSubmit({
|
|
pendingItem: pendingItemAtCancel,
|
|
lastTurnUserItem: lastTurnUserItemRef.current,
|
|
canUndoLastLoggedUserMessage: canUndoLastLoggedUserMessageRef.current,
|
|
turnProducedMeaningfulContent: turnSawContentEventRef.current,
|
|
wasGoalTurn: activeGoalTurnRef.current !== null,
|
|
wasModPromptTurn: modPromptTurnRef.current,
|
|
});
|
|
} finally {
|
|
setIsResponding(false);
|
|
setShellInputFocused(false);
|
|
}
|
|
return true;
|
|
}, [
|
|
streamingState,
|
|
addItem,
|
|
commitItemInOrder,
|
|
commitPendingThought,
|
|
setPendingHistoryItem,
|
|
onCancelSubmit,
|
|
pendingHistoryItemRef,
|
|
setShellInputFocused,
|
|
clearRetryCountdown,
|
|
config,
|
|
getPromptCount,
|
|
setSubmissionInFlight,
|
|
]);
|
|
// `$.turn.abort` arrives from the Mod worker, outside any render.
|
|
useLayoutEffect(() => {
|
|
cancelForModRef.current = () => {
|
|
// A turn already cancelled is not the Mod's to end; a second cancel
|
|
// would also abort the detached work the first one spared.
|
|
if (turnCancelledRef.current) return false;
|
|
quietCancelRef.current = true;
|
|
return cancelOngoingRequest();
|
|
};
|
|
}, [cancelOngoingRequest]);
|
|
|
|
const applyVisionBridgeIfNeeded = useCallback(
|
|
async (
|
|
parts: PartListUnion | null,
|
|
timestamp: number,
|
|
signal: AbortSignal,
|
|
): Promise<{ parts: PartListUnion | null; shouldProceed: boolean }> => {
|
|
if (parts === null || !hasImageParts(parts)) {
|
|
return { parts, shouldProceed: true };
|
|
}
|
|
if (modelOverrideRef.current?.endsWith('\0')) {
|
|
return { parts, shouldProceed: true };
|
|
}
|
|
if (inlineModelOverrideActiveRef.current) {
|
|
return { parts, shouldProceed: true };
|
|
}
|
|
if (!shouldRunVisionBridge(config)) {
|
|
return { parts, shouldProceed: true };
|
|
}
|
|
if (signal.aborted) {
|
|
return { parts: null, shouldProceed: false };
|
|
}
|
|
|
|
debugLogger.debug('vision bridge: gate matched, running conversion');
|
|
const fullTurnModel = config.getDefaultVisionBridgeModel();
|
|
if (fullTurnModel?.agentCapable) {
|
|
const fullTurnParts = (Array.isArray(parts) ? parts : [parts]).map(
|
|
(part) =>
|
|
typeof part === 'string'
|
|
? { text: part }
|
|
: clampInlineMediaPart(part),
|
|
);
|
|
if (!hasImageParts(fullTurnParts)) {
|
|
return { parts: fullTurnParts, shouldProceed: true };
|
|
}
|
|
applyModelOverride(
|
|
modelOverrideRef,
|
|
inlineModelOverrideActiveRef,
|
|
getFullTurnVisionModelSelector(fullTurnModel),
|
|
false,
|
|
);
|
|
addItem(
|
|
{
|
|
type: MessageType.VISION_NOTICE,
|
|
text: formatFullTurnVisionNotice(fullTurnModel),
|
|
},
|
|
timestamp,
|
|
);
|
|
return { parts: fullTurnParts, shouldProceed: true };
|
|
}
|
|
|
|
const bridgeResult = await runVisionBridge({ config, parts, signal });
|
|
debugLogger.debug(
|
|
`vision bridge: status=${bridgeResult.status} applied=${bridgeResult.applied} model=${bridgeResult.modelId ?? '(none)'}`,
|
|
);
|
|
// Surface one notice: egress + transcript on success, reason on failure,
|
|
// and egress disclosure after cancellation if data was already sent.
|
|
if (bridgeResult.status !== 'skipped' || bridgeResult.egressOccurred) {
|
|
addItem(
|
|
{
|
|
type:
|
|
bridgeResult.status === 'failed'
|
|
? MessageType.ERROR
|
|
: MessageType.VISION_NOTICE,
|
|
text: formatVisionBridgeNotice(bridgeResult),
|
|
},
|
|
timestamp,
|
|
);
|
|
}
|
|
if (signal.aborted) {
|
|
return { parts: null, shouldProceed: false };
|
|
}
|
|
if (bridgeResult.applied && bridgeResult.parts != null) {
|
|
return { parts: bridgeResult.parts, shouldProceed: true };
|
|
}
|
|
// The bridge produced no usable replacement. Never forward images to a
|
|
// text-only model (it can't read them): drop them and proceed on the
|
|
// remaining text, or stop if nothing is left.
|
|
const textOnly = splitImageParts(parts).nonImageParts;
|
|
return textOnly.length > 0
|
|
? { parts: textOnly, shouldProceed: true }
|
|
: { parts: null, shouldProceed: false };
|
|
},
|
|
[addItem, config],
|
|
);
|
|
|
|
const prepareQueryForLlm = useCallback(
|
|
async (
|
|
query: PartListUnion,
|
|
userMessageTimestamp: number,
|
|
abortSignal: AbortSignal,
|
|
prompt_id: string,
|
|
submitType: SendMessageType,
|
|
submittedPrompt: string | undefined,
|
|
preserveTurnOwnership: boolean,
|
|
shellModeIntent?: boolean,
|
|
displayText?: string,
|
|
humanInput?: HumanPromptInput,
|
|
pasteExpansions?: ReadonlyMap<string, string>,
|
|
hasUnknownInput?: boolean,
|
|
isCurrentSubmission?: () => boolean,
|
|
modPrompt?: ModQueuedPrompt,
|
|
): Promise<{
|
|
queryToSend: PartListUnion | null;
|
|
shouldProceed: boolean;
|
|
scheduledClientTool?: {
|
|
request: ToolCallRequestInfo;
|
|
restoreResponseBinding?: RestoreToolResponseBinding;
|
|
};
|
|
/** What the Mods' `prompt.submit` hooks let in, when they heard it. */
|
|
promptSubmitted?: PromptSubmitted;
|
|
}> => {
|
|
if (turnCancelledRef.current && !preserveTurnOwnership) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
if (typeof query === 'string' && query.trim().length === 0) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
|
|
// Reset at turn start. Only the user-typed-text path below assigns
|
|
// this — paths that don't add a USER history item (Cron /
|
|
// Notification / slash submit_prompt) leave it null so cancel
|
|
// never wrongly targets an older user item.
|
|
if (!preserveTurnOwnership) {
|
|
lastTurnUserItemRef.current = null;
|
|
}
|
|
|
|
let localQueryToSendToLlm: PartListUnion | null = null;
|
|
let promptSubmitted: PromptSubmitted | undefined;
|
|
|
|
if (typeof query === 'string') {
|
|
let trimmedQuery = query.trim();
|
|
|
|
// Notification messages (e.g. background agent completions) are
|
|
// pre-processed by the notification drain loop which already
|
|
// added the display item to history. Just pass the model text
|
|
// through to the API. Cron prompts still go through the normal
|
|
// slash/@-command/shell preprocessing path below.
|
|
if (submitType === SendMessageType.Notification) {
|
|
onDebugMessage(
|
|
`Received notification (${trimmedQuery.length} chars)`,
|
|
);
|
|
return { queryToSend: trimmedQuery, shouldProceed: true };
|
|
}
|
|
|
|
// A Mod's own prompt already passed the Mods' `prompt.submit` hooks,
|
|
// and the person did not type it: it is not recalled, reads no `@`
|
|
// reference and runs no command, and its row names the Mod.
|
|
if (modPrompt) {
|
|
addItem(
|
|
{
|
|
type: 'notification',
|
|
text: modPromptRow(modPrompt.plugin, modPrompt.text),
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
return { queryToSend: trimmedQuery, shouldProceed: true };
|
|
}
|
|
|
|
// Teammate envelopes are model-authored text already rendered
|
|
// as a `● …` notification by the teammate drain. They must NOT
|
|
// enter the slash/shell/@ preprocessing below: with `!` shell
|
|
// mode active a teammate report would be EXECUTED as a shell
|
|
// command, and a leading `/` or an `@path` would be
|
|
// reinterpreted against the leader's session. Pass the
|
|
// envelope straight through to the model, like Notification.
|
|
if (submitType === SendMessageType.Teammate) {
|
|
onDebugMessage(
|
|
`Received teammate message (${trimmedQuery.length} chars)`,
|
|
);
|
|
return { queryToSend: trimmedQuery, shouldProceed: true };
|
|
}
|
|
|
|
onDebugMessage(`Received user query (${trimmedQuery.length} chars)`);
|
|
// Handle UI-only commands first
|
|
const isSlash = isSlashCommand(trimmedQuery);
|
|
// One-shot notices the TUI folded into this prompt stay on the model
|
|
// side only: the recall log, the transcript row and the cancel-restore
|
|
// buffer use the composer's projection (#11553), since reverse-parsing
|
|
// cannot tell an injected envelope from one the user typed. The strip
|
|
// is the fallback for producers outside this process; slash commands
|
|
// keep their own text. Logged before the slash and shell arms can
|
|
// return, so handled commands stay recallable.
|
|
let userText =
|
|
displayText ??
|
|
submittedPrompt ??
|
|
(isSlash ? trimmedQuery : stripLeadingSystemReminders(trimmedQuery));
|
|
let recallText = pasteExpansions?.size
|
|
? expandPendingPastePlaceholders(userText, pasteExpansions)
|
|
: userText;
|
|
// The Mods hear a prompt the person typed before anything reads it:
|
|
// the recall log, the transcript row, `@` references and the send.
|
|
const mods =
|
|
submitType === SendMessageType.UserQuery &&
|
|
!isSlash &&
|
|
!(shellModeIntent ?? shellModeActive)
|
|
? getInteractiveModSession(config)
|
|
: undefined;
|
|
if (mods) {
|
|
let submitted: Awaited<ReturnType<typeof mods.submitPrompt>>;
|
|
try {
|
|
submitted = await mods.submitPrompt(recallText, abortSignal);
|
|
} catch (error) {
|
|
if (!abortSignal.aborted) throw error;
|
|
onPromptReturned?.(userText, pasteExpansions);
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
// A dropped prompt is neither shown nor recalled, since it may hold
|
|
// what the hook kept out; the composer gets it back to edit.
|
|
if ('drop' in submitted) {
|
|
addItem(
|
|
{ type: MessageType.ERROR, text: submitted.drop },
|
|
userMessageTimestamp,
|
|
);
|
|
onPromptReturned?.(userText, pasteExpansions);
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
promptSubmitted = {
|
|
...(submitted.context?.length
|
|
? { context: submitted.context }
|
|
: {}),
|
|
};
|
|
const rewritten = submitted.text.trim();
|
|
if (rewritten !== recallText.trim()) {
|
|
// The typed text as the composer sent it: pastes framed. What the
|
|
// composer put ahead of it, attachment references and model-only
|
|
// notices, stays ahead of the rewrite.
|
|
const typed = (
|
|
pasteExpansions?.size
|
|
? framePasteExpansion(userText, pasteExpansions)
|
|
: recallText
|
|
).trim();
|
|
const found = trimmedQuery.endsWith(typed);
|
|
if (!found) {
|
|
debugLogger.warn(
|
|
'prompt.submit: the typed text was not found in the prompt; sending the rewrite alone',
|
|
);
|
|
}
|
|
const ahead = found
|
|
? trimmedQuery.slice(0, trimmedQuery.length - typed.length)
|
|
: '';
|
|
// Each paste comes back whole to be collapsed and framed again,
|
|
// or the rewrite is refused rather than sent unframed.
|
|
const collapsed = collapseRewrite(
|
|
userText,
|
|
rewritten,
|
|
pasteExpansions,
|
|
);
|
|
if (!collapsed) {
|
|
addItem(
|
|
{ type: MessageType.ERROR, text: REWRITE_CHANGED_PASTE },
|
|
userMessageTimestamp,
|
|
);
|
|
onPromptReturned?.(userText, pasteExpansions);
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
pasteExpansions = collapsed.pasteExpansions;
|
|
userText = collapsed.text;
|
|
recallText = rewritten;
|
|
trimmedQuery =
|
|
ahead +
|
|
(pasteExpansions
|
|
? framePasteExpansion(userText, pasteExpansions)
|
|
: rewritten);
|
|
submittedPrompt = userText;
|
|
if (humanInput) humanInput = { text: rewritten };
|
|
promptSubmitted.rewritten = { text: rewritten, display: userText };
|
|
}
|
|
}
|
|
await logger?.logMessage(MessageSenderType.USER, recallText);
|
|
if (abortSignal.aborted) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
canUndoLastLoggedUserMessageRef.current =
|
|
!preserveTurnOwnership && logger != null;
|
|
|
|
const slashCommandResult = isSlash
|
|
? await handleSlashCommand(
|
|
trimmedQuery,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
submitType === SendMessageType.UserQuery ? prompt_id : undefined,
|
|
isCurrentSubmission,
|
|
)
|
|
: false;
|
|
|
|
if (slashCommandResult) {
|
|
switch (slashCommandResult.type) {
|
|
case 'schedule_tool': {
|
|
const { toolName, toolArgs } = slashCommandResult;
|
|
const toolCallRequest: ToolCallRequestInfo = {
|
|
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
name: toolName,
|
|
args: toolArgs,
|
|
isClientInitiated: true,
|
|
prompt_id,
|
|
// Client-direct provenance (omni policy design): slash
|
|
// commands scheduling a tool are the in-process `client`
|
|
// channel — subject to the same media-policy modelAccess
|
|
// gate as model calls, never to fixed-policy semantics.
|
|
executionOrigin: { kind: 'client' },
|
|
};
|
|
return {
|
|
queryToSend: null,
|
|
shouldProceed: false,
|
|
scheduledClientTool: {
|
|
request: toolCallRequest,
|
|
...(slashCommandResult.restoreResponseBinding
|
|
? {
|
|
restoreResponseBinding:
|
|
slashCommandResult.restoreResponseBinding,
|
|
}
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
case 'submit_prompt': {
|
|
localQueryToSendToLlm = slashCommandResult.content;
|
|
submitPromptOnCompleteRef.current =
|
|
slashCommandResult.onComplete ?? null;
|
|
refreshContextFilesOnWriteRef.current = Boolean(
|
|
slashCommandResult.refreshContextFilesOnWrite,
|
|
);
|
|
// Per-turn model override (e.g. inline `/model <id> <prompt>`).
|
|
// Runs after the new-user-turn reset above and before the stream
|
|
// is sent, so it applies to this turn and — because the reset is
|
|
// skipped for ToolResult/Retry — persists across the tool loop,
|
|
// then clears on the next user turn. Re-validate provider identity
|
|
// here rather than trust the producer: any slash command can set
|
|
// `modelOverride`, so the consumer enforces that it names a model
|
|
// on the active provider before redirecting API calls to it.
|
|
if (slashCommandResult.modelOverride) {
|
|
if (
|
|
isInlineModelOverrideAllowed(
|
|
config,
|
|
slashCommandResult.modelOverride,
|
|
)
|
|
) {
|
|
applyModelOverride(
|
|
modelOverrideRef,
|
|
inlineModelOverrideActiveRef,
|
|
slashCommandResult.modelOverride,
|
|
true,
|
|
);
|
|
} else {
|
|
debugLogger.warn(
|
|
`ignoring model override '${slashCommandResult.modelOverride}': not a model on the active provider`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const bridgeResult = await applyVisionBridgeIfNeeded(
|
|
localQueryToSendToLlm,
|
|
userMessageTimestamp,
|
|
abortSignal,
|
|
);
|
|
if (!bridgeResult.shouldProceed) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
localQueryToSendToLlm = bridgeResult.parts;
|
|
|
|
return {
|
|
queryToSend: localQueryToSendToLlm,
|
|
shouldProceed: true,
|
|
};
|
|
}
|
|
case 'handled': {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
default: {
|
|
const unreachable: never = slashCommandResult;
|
|
throw new Error(
|
|
`Unhandled slash command result type: ${unreachable}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A queued submission carries the shell intent recorded when the
|
|
// user submitted it; the live flag may have flipped while the
|
|
// entry waited in the queue (#11626). Other producers record no
|
|
// intent and route on the live flag, as before.
|
|
const routeToShell = shellModeIntent ?? shellModeActive;
|
|
if (routeToShell && handleShellCommand(trimmedQuery, abortSignal)) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
|
|
localQueryToSendToLlm = trimmedQuery;
|
|
|
|
// Cron prompts are already rendered as a `● …` notification by
|
|
// their queue drain, so skip the user-message history item to
|
|
// avoid a duplicate `> …` line. Preprocessing (@/slash/shell)
|
|
// still runs for Cron. (Teammate envelopes returned earlier
|
|
// and never reach this point.)
|
|
if (submitType !== SendMessageType.Cron) {
|
|
const insertedId = addItem(
|
|
{
|
|
type: MessageType.USER,
|
|
text: userText,
|
|
...(recallText !== userText ? { recallText } : {}),
|
|
promptId: prompt_id,
|
|
// The envelope masked a leading `?` from the lexical fallback in
|
|
// isRealUserTurn; an un-noticed prompt keeps that fallback.
|
|
...(userText === trimmedQuery ? {} : { sentToModel: true }),
|
|
} as HistoryItemWithoutId,
|
|
userMessageTimestamp,
|
|
);
|
|
// Capture id+text so the cancel handler can verify identity,
|
|
// not just text. `addItem` returns a fresh id even when it
|
|
// skipped insertion (consecutive-duplicate user); the older
|
|
// matching USER in history carries a DIFFERENT id, so the
|
|
// mismatch makes auto-restore bail correctly in that case.
|
|
if (!preserveTurnOwnership) {
|
|
lastTurnUserItemRef.current = {
|
|
id: insertedId,
|
|
text: userText,
|
|
...(submittedPrompt === undefined ? {} : { submittedPrompt }),
|
|
...(humanInput === undefined ? {} : { humanInput }),
|
|
...(pasteExpansions?.size ? { pasteExpansions } : {}),
|
|
...(hasUnknownInput ? { hasUnknownInput } : {}),
|
|
// What the model actually read, so a cancel + resubmit does not
|
|
// drop the one-shot notice this turn consumed.
|
|
...(userText === trimmedQuery ? {} : { modelText: trimmedQuery }),
|
|
};
|
|
}
|
|
|
|
// Yield via macrotask to let Ink/React flush the user message
|
|
// render before continuing with @-command processing and API
|
|
// call. React 19.2.4 (Ink 7.0.3) schedules renders via
|
|
// MessageChannel.postMessage (a macrotask), so a microtask yield
|
|
// (await Promise.resolve()) does NOT give React a chance to
|
|
// render — the continuation runs first. setImmediate fires in
|
|
// the check phase after I/O events (where MessageChannel
|
|
// delivers its postMessage), guaranteeing React renders first
|
|
// without the ~1ms timer overhead of setTimeout(0).
|
|
// Only needed for non-Cron submissions since Cron skips addItem().
|
|
await new Promise((r) => setImmediate(r));
|
|
}
|
|
|
|
// Handle @-commands (which might involve tool calls)
|
|
if (isAtCommand(trimmedQuery)) {
|
|
const atCommandResult = await handleAtCommand({
|
|
query: trimmedQuery,
|
|
config,
|
|
onDebugMessage,
|
|
messageId: userMessageTimestamp,
|
|
signal: abortSignal,
|
|
addItem,
|
|
});
|
|
|
|
if (!atCommandResult.shouldProceed) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
localQueryToSendToLlm = atCommandResult.processedQuery;
|
|
}
|
|
|
|
const bridgeResult = await applyVisionBridgeIfNeeded(
|
|
localQueryToSendToLlm,
|
|
userMessageTimestamp,
|
|
abortSignal,
|
|
);
|
|
if (!bridgeResult.shouldProceed) {
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
localQueryToSendToLlm = bridgeResult.parts;
|
|
} else {
|
|
// It's a function response (PartListUnion that isn't a string)
|
|
localQueryToSendToLlm = query;
|
|
}
|
|
|
|
if (localQueryToSendToLlm === null) {
|
|
onDebugMessage(
|
|
'Query processing resulted in null, not sending to the model.',
|
|
);
|
|
return { queryToSend: null, shouldProceed: false };
|
|
}
|
|
return {
|
|
queryToSend: localQueryToSendToLlm,
|
|
shouldProceed: true,
|
|
...(promptSubmitted ? { promptSubmitted } : {}),
|
|
};
|
|
},
|
|
[
|
|
config,
|
|
addItem,
|
|
onDebugMessage,
|
|
handleShellCommand,
|
|
handleSlashCommand,
|
|
logger,
|
|
shellModeActive,
|
|
applyVisionBridgeIfNeeded,
|
|
onPromptReturned,
|
|
],
|
|
);
|
|
|
|
// --- Stream Event Handlers ---
|
|
|
|
const handleContentEvent = useCallback(
|
|
(
|
|
eventValue: ContentEvent['value'],
|
|
currentLlmMessageBuffer: string,
|
|
userMessageTimestamp: number,
|
|
startAsContinuation = false,
|
|
): string => {
|
|
if (turnCancelledRef.current) {
|
|
// Prevents additional output after a user initiated cancel.
|
|
return '';
|
|
}
|
|
// Track output chars for real-time token estimation & mark as receiving.
|
|
streamingResponseLengthRef.current += eventValue.length;
|
|
setIsReceivingContent(true);
|
|
// Pin "this turn produced meaningful content" so the cancel
|
|
// handler's snapshot reflects content events even when they land
|
|
// during the pre-cancel flush (their addItem hasn't re-rendered
|
|
// React history by the time AppContainer's guard runs).
|
|
turnSawContentEventRef.current = true;
|
|
let newLlmMessageBuffer = currentLlmMessageBuffer + eventValue;
|
|
const pendingItem = pendingHistoryItemRef.current;
|
|
if (
|
|
(pendingItem?.type === 'gemini' ||
|
|
pendingItem?.type === 'gemini_content') &&
|
|
(pendingItem.images?.length || pendingItem.omittedImageCount)
|
|
) {
|
|
if (newLlmMessageBuffer.trim().length === 0) {
|
|
return newLlmMessageBuffer;
|
|
}
|
|
stagePendingAssistantItem();
|
|
}
|
|
if (
|
|
pendingHistoryItemRef.current?.type !== 'gemini' &&
|
|
pendingHistoryItemRef.current?.type !== 'gemini_content'
|
|
) {
|
|
if (newLlmMessageBuffer.trim().length === 0) {
|
|
return newLlmMessageBuffer;
|
|
}
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
setPendingHistoryItem(
|
|
startAsContinuation
|
|
? { type: 'gemini_content', text: '' }
|
|
: { type: 'gemini', text: '', timestamp: Date.now() },
|
|
);
|
|
newLlmMessageBuffer = stripLeadingBlankLines(newLlmMessageBuffer);
|
|
}
|
|
// Split large messages for better rendering performance. Ideally,
|
|
// we should maximize the amount of output sent to <Static />.
|
|
let nextPendingType: 'gemini' | 'gemini_content' =
|
|
pendingHistoryItemRef.current?.type === 'gemini_content'
|
|
? 'gemini_content'
|
|
: pendingHistoryItemRef.current?.type === 'gemini'
|
|
? 'gemini'
|
|
: startAsContinuation
|
|
? 'gemini_content'
|
|
: 'gemini';
|
|
while (newLlmMessageBuffer.length > STREAM_PENDING_ITEM_MAX_CHARS) {
|
|
const splitPoint = findLastSafeSplitPoint(
|
|
newLlmMessageBuffer,
|
|
STREAM_PENDING_ITEM_MAX_CHARS,
|
|
);
|
|
const safeSplitPoint =
|
|
splitPoint > 0 && splitPoint < newLlmMessageBuffer.length
|
|
? splitPoint
|
|
: STREAM_PENDING_ITEM_MAX_CHARS;
|
|
|
|
// This indicates that we need to split up this LLM 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.
|
|
// Repair fences when the split lands inside a code block so the tail
|
|
// does not render as prose (see splitFencedMarkdown).
|
|
const { before: beforeText, after: afterText } = splitFencedMarkdown(
|
|
newLlmMessageBuffer,
|
|
safeSplitPoint,
|
|
);
|
|
commitItemInOrder(
|
|
{
|
|
type: nextPendingType,
|
|
text: beforeText,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
nextPendingType = 'gemini_content';
|
|
newLlmMessageBuffer = afterText;
|
|
}
|
|
// Rendered-height-aware incremental commit. Commit whole chunks to
|
|
// <Static> so the pending (live) item's ESTIMATED rendered height stays
|
|
// within the viewport budget. Uses the SAME accounting as
|
|
// MarkdownDisplay's safety-net slice (fitPendingSlice — tables counted as
|
|
// blocks, wide/CJK lines wrapped) so the two agree and the clip never
|
|
// engages / flickers in step with the commit cycle.
|
|
//
|
|
// - `while`, not `if`: a single throttled update can append many lines, so
|
|
// keep committing until the remainder fits.
|
|
// - Commit ONLY at a blank-line block boundary so a table / list / code
|
|
// block is never cut into a headerless continuation (which would render
|
|
// as raw "| ... |" text). A table that is still streaming (no trailing
|
|
// blank line yet) stays pending — bounded in view by MarkdownDisplay's
|
|
// clamp — until it is complete, then commits whole.
|
|
// - `findLastSafeSplitPoint`: also never cut inside a fenced code block.
|
|
// - Conservative fallback when the content-area height is not yet known
|
|
// (ref is 0 before the first render populates it): derive from
|
|
// terminalHeight so a short terminal does not use an over-large budget.
|
|
const viewportRows =
|
|
(availableTerminalHeightRef?.current ?? 0) > 0
|
|
? availableTerminalHeightRef!.current
|
|
: Math.max(4, terminalHeight - STREAM_PENDING_COMPOSER_RESERVE_ROWS);
|
|
// Read width from the same live ref as the height so a mid-stream resize
|
|
// is handled consistently; fall back to the render-time width.
|
|
const commitWidth =
|
|
(terminalWidthRef?.current ?? 0) > 0
|
|
? terminalWidthRef!.current
|
|
: terminalWidth;
|
|
const commitRowBudget = Math.max(
|
|
4,
|
|
viewportRows - STREAM_PENDING_COMMIT_RESERVE_ROWS,
|
|
);
|
|
const tableClampRows = Math.max(2, viewportRows - 3);
|
|
while (true) {
|
|
const bufferLines = newLlmMessageBuffer.split('\n');
|
|
const { keptLines, clipped } = fitPendingSlice(
|
|
bufferLines,
|
|
commitWidth,
|
|
commitRowBudget,
|
|
tableClampRows,
|
|
);
|
|
if (!clipped) break;
|
|
// Back up to the last blank line at or before the kept prefix — the only
|
|
// place it is safe to end a committed chunk without orphaning a block.
|
|
// Start AT keptLines (not keptLines - 1): when a single block is taller
|
|
// than the budget, fitPendingSlice charges the whole block and returns
|
|
// kept = the block's trailing blank line, so the boundary sits exactly at
|
|
// keptLines. Searching from keptLines - 1 misses it, finds no earlier
|
|
// blank (the block has none, and the blank before it was already
|
|
// committed), and stalls — every later block then appends past keptLines,
|
|
// so nothing ever commits until the stream finalizes and dumps it all at
|
|
// once. Committing an over-tall completed block to <Static> is fine; only
|
|
// the live pending frame must stay within the viewport.
|
|
let boundaryLine = -1;
|
|
for (let k = Math.min(keptLines, bufferLines.length - 1); k >= 0; k--) {
|
|
if (bufferLines[k]!.trim() === '') {
|
|
boundaryLine = k;
|
|
break;
|
|
}
|
|
}
|
|
let target: number;
|
|
if (boundaryLine < 0) {
|
|
// No blank-line boundary at/before the kept prefix. A fenced code
|
|
// block taller than the viewport never provides one mid-block, so the
|
|
// whole block would stay pending — frozen on its head — and only land
|
|
// in scrollback when it finally closes (the "stall then dump" seen on
|
|
// a 100-line code block). Hard-splitting inside a fence is now safe
|
|
// (splitFencedMarkdown closes/re-opens the fence and continues the
|
|
// gutter), so commit the budget-fit prefix and keep streaming. Restrict
|
|
// this to code blocks: other tall blocks (tables/lists) must stay whole
|
|
// and are still kept pending.
|
|
const capIndex = charIndexAfterLine(newLlmMessageBuffer, keptLines);
|
|
const fenceInfo =
|
|
capIndex > 0
|
|
? getEnclosingFenceInfo(newLlmMessageBuffer, capIndex)
|
|
: null;
|
|
// Only hard-split a real code block. Other tall blocks (tables/lists)
|
|
// must stay whole, and mermaid needs its whole source to render a
|
|
// diagram — splitting it mid-block would break the render — so both
|
|
// stay pending until they complete.
|
|
if (!fenceInfo || fenceInfo.lang?.toLowerCase() === 'mermaid') {
|
|
break; // no safe boundary yet → keep pending
|
|
}
|
|
target = capIndex;
|
|
} else {
|
|
target = charIndexAfterLine(newLlmMessageBuffer, boundaryLine + 1);
|
|
if (target <= 0) break;
|
|
}
|
|
const splitPoint = findLastSafeSplitPoint(newLlmMessageBuffer, target);
|
|
if (splitPoint <= 0 || splitPoint >= newLlmMessageBuffer.length) {
|
|
break;
|
|
}
|
|
// Repair fences when the split lands inside a code block so the tail
|
|
// does not render as prose (see splitFencedMarkdown).
|
|
const { before: beforeText, after: afterText } = splitFencedMarkdown(
|
|
newLlmMessageBuffer,
|
|
splitPoint,
|
|
);
|
|
commitItemInOrder(
|
|
{
|
|
type: nextPendingType,
|
|
text: beforeText,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
nextPendingType = 'gemini_content';
|
|
newLlmMessageBuffer = afterText;
|
|
}
|
|
// Update the existing message with accumulated content.
|
|
setPendingHistoryItem((item) => {
|
|
const base: HistoryItemWithoutId = {
|
|
type: nextPendingType,
|
|
text: newLlmMessageBuffer,
|
|
};
|
|
if (item && 'timestamp' in item) {
|
|
(base as HistoryItemLlm).timestamp = (
|
|
item as HistoryItemLlm
|
|
).timestamp;
|
|
}
|
|
return base;
|
|
});
|
|
return newLlmMessageBuffer;
|
|
},
|
|
[
|
|
commitItemInOrder,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
stagePendingAssistantItem,
|
|
terminalWidth,
|
|
terminalHeight,
|
|
availableTerminalHeightRef,
|
|
terminalWidthRef,
|
|
],
|
|
);
|
|
|
|
const mergeThought = useCallback(
|
|
(incoming: ThoughtSummary) => {
|
|
setThought((prev) => {
|
|
const incomingDescription = incoming.description
|
|
? clampLoadingThoughtDescription(incoming.description)
|
|
: incoming.description;
|
|
if (!prev) {
|
|
if (debugLogger.isEnabled()) {
|
|
debugLogger.debug(
|
|
`[THOUGHT_MERGE] New thought: ` +
|
|
`subjectLength=${incoming.subject?.length ?? 0}, ` +
|
|
`description length=${incomingDescription?.length ?? 0}`,
|
|
);
|
|
}
|
|
return {
|
|
...incoming,
|
|
description: incomingDescription,
|
|
};
|
|
}
|
|
const subject = incoming.subject || prev.subject;
|
|
const description = clampLoadingThoughtDescription(
|
|
`${prev.description ?? ''}${incomingDescription ?? ''}`,
|
|
);
|
|
if (debugLogger.isEnabled()) {
|
|
debugLogger.debug(
|
|
`[THOUGHT_MERGE] Accumulating thought: ` +
|
|
`prev length=${prev.description?.length ?? 0}, ` +
|
|
`incoming length=${incomingDescription?.length ?? 0}, ` +
|
|
`total length=${description.length}`,
|
|
);
|
|
}
|
|
return { subject, description };
|
|
});
|
|
},
|
|
[setThought],
|
|
);
|
|
|
|
const handleThoughtEvent = useCallback(
|
|
(
|
|
eventValue: ThoughtSummary,
|
|
currentThoughtBuffer: string,
|
|
userMessageTimestamp: number,
|
|
): string => {
|
|
if (turnCancelledRef.current) {
|
|
return '';
|
|
}
|
|
|
|
const thoughtText = eventValue.description ?? '';
|
|
if (!thoughtText) {
|
|
return currentThoughtBuffer;
|
|
}
|
|
|
|
let newThoughtBuffer = currentThoughtBuffer + thoughtText;
|
|
if (newThoughtBuffer.trim().length === 0) {
|
|
return newThoughtBuffer;
|
|
}
|
|
|
|
streamingResponseLengthRef.current += thoughtText.length;
|
|
const startingNewThought = currentThoughtBuffer.trim().length === 0;
|
|
const description = startingNewThought
|
|
? stripLeadingBlankLines(newThoughtBuffer)
|
|
: thoughtText;
|
|
|
|
if (startingNewThought) {
|
|
thoughtStartTimeRef.current = Date.now();
|
|
newThoughtBuffer = description;
|
|
thoughtGroupIdRef.current = undefined;
|
|
if (pendingAssistantItemsRef.current.length > 0) {
|
|
// Keep fold identity across retries, new turns, and the history commit.
|
|
for (const item of historyRef.current) {
|
|
if (
|
|
(item.type === 'gemini_thought' ||
|
|
item.type === 'gemini_thought_content') &&
|
|
item.thoughtGroupId !== undefined
|
|
) {
|
|
lastThoughtGroupIdRef.current = Math.min(
|
|
lastThoughtGroupIdRef.current,
|
|
item.thoughtGroupId,
|
|
);
|
|
}
|
|
}
|
|
thoughtGroupIdRef.current = --lastThoughtGroupIdRef.current;
|
|
}
|
|
}
|
|
|
|
// Keep the transient `thought` (subject) in sync for the window title.
|
|
mergeThought({
|
|
...eventValue,
|
|
description,
|
|
});
|
|
|
|
// Stream the accumulated reasoning into a pending history item so it
|
|
// renders height-limited above the answer and can later be committed as
|
|
// a collapsible block.
|
|
let pendingThoughtType: 'gemini_thought' | 'gemini_thought_content' =
|
|
startingNewThought
|
|
? 'gemini_thought'
|
|
: pendingThoughtItemRef.current?.type === 'gemini_thought_content'
|
|
? 'gemini_thought_content'
|
|
: 'gemini_thought';
|
|
const getThoughtDurationMs = () =>
|
|
thoughtStartTimeRef.current
|
|
? Date.now() - thoughtStartTimeRef.current
|
|
: 0;
|
|
const buildThoughtItem = (
|
|
type: 'gemini_thought' | 'gemini_thought_content',
|
|
text: string,
|
|
): HistoryItemWithoutId => ({
|
|
type,
|
|
text,
|
|
...(type === 'gemini_thought'
|
|
? { durationMs: getThoughtDurationMs() }
|
|
: {}),
|
|
...(thoughtGroupIdRef.current !== undefined
|
|
? { thoughtGroupId: thoughtGroupIdRef.current, isStreaming: true }
|
|
: {}),
|
|
});
|
|
|
|
let splitPoint = findLastSafeSplitPoint(
|
|
newThoughtBuffer,
|
|
STREAM_PENDING_ITEM_MAX_CHARS,
|
|
);
|
|
while (newThoughtBuffer.length > STREAM_PENDING_ITEM_MAX_CHARS) {
|
|
const safeSplitPoint =
|
|
splitPoint > 0 && splitPoint < newThoughtBuffer.length
|
|
? splitPoint
|
|
: STREAM_PENDING_ITEM_MAX_CHARS;
|
|
// Repair fences when the split lands inside a code block so the tail
|
|
// does not render as prose (see splitFencedMarkdown).
|
|
const { before: beforeText, after: afterText } = splitFencedMarkdown(
|
|
newThoughtBuffer,
|
|
safeSplitPoint,
|
|
);
|
|
const item = buildThoughtItem(pendingThoughtType, beforeText);
|
|
if (pendingAssistantItemsRef.current.length > 0) {
|
|
setPendingAssistantItems((items) => [...items, item]);
|
|
} else {
|
|
addItem(item, userMessageTimestamp);
|
|
}
|
|
pendingThoughtType = 'gemini_thought_content';
|
|
newThoughtBuffer = afterText;
|
|
splitPoint = findLastSafeSplitPoint(
|
|
newThoughtBuffer,
|
|
STREAM_PENDING_ITEM_MAX_CHARS,
|
|
);
|
|
}
|
|
|
|
setPendingThoughtItem(
|
|
buildThoughtItem(pendingThoughtType, newThoughtBuffer),
|
|
);
|
|
|
|
return newThoughtBuffer;
|
|
},
|
|
[
|
|
addItem,
|
|
mergeThought,
|
|
pendingThoughtItemRef,
|
|
setPendingThoughtItem,
|
|
pendingAssistantItemsRef,
|
|
setPendingAssistantItems,
|
|
],
|
|
);
|
|
|
|
const handleUserCancelledEvent = useCallback(
|
|
(userMessageTimestamp: number) => {
|
|
if (turnCancelledRef.current) {
|
|
return;
|
|
}
|
|
|
|
lastPromptErroredRef.current = false;
|
|
// Persist any streamed reasoning (collapsed) above the cancelled answer.
|
|
commitPendingThought(userMessageTimestamp);
|
|
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 {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{ type: MessageType.INFO, text: 'User cancelled the request.' },
|
|
userMessageTimestamp,
|
|
);
|
|
clearRetryCountdown();
|
|
setIsResponding(false);
|
|
setThought(null); // Reset thought when user cancels
|
|
},
|
|
[
|
|
addItem,
|
|
commitPendingThought,
|
|
commitItemInOrder,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
setThought,
|
|
clearRetryCountdown,
|
|
],
|
|
);
|
|
|
|
const handleErrorEvent = useCallback(
|
|
(
|
|
eventValue: LlmErrorEventValue,
|
|
userMessageTimestamp: number,
|
|
submitType: SendMessageType,
|
|
ownsTurn: boolean,
|
|
) => {
|
|
if (ownsTurn) setWorkflowKeywordActive(false);
|
|
if (submitType !== SendMessageType.Goal) {
|
|
lastPromptErroredRef.current = true;
|
|
} else {
|
|
goalTerminalErrorRef.current = true;
|
|
}
|
|
// Persist any streamed reasoning (collapsed) above the error.
|
|
commitPendingThought(userMessageTimestamp);
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
// Only show Ctrl+Y hint if not already showing an auto-retry countdown
|
|
// (auto-retry countdown is shown when retryCountdownTimerRef is active)
|
|
const isShowingAutoRetry = retryCountdownTimerRef.current !== null;
|
|
clearRetryCountdown();
|
|
|
|
const formattedErrorText = parseAndFormatApiError(
|
|
eventValue.error,
|
|
config.getContentGeneratorConfig()?.authType,
|
|
t,
|
|
);
|
|
|
|
if (!isShowingAutoRetry) {
|
|
const retryHint =
|
|
submitType !== SendMessageType.Goal
|
|
? t('Press Ctrl+Y to retry')
|
|
: undefined;
|
|
// Store error with hint as a pending item (not in history).
|
|
// This allows the hint to be removed when the user retries with Ctrl+Y,
|
|
// since pending items are in the dynamic rendering area (not <Static>).
|
|
setPendingRetryErrorItem({
|
|
type: 'error' as const,
|
|
text: formattedErrorText,
|
|
hint: retryHint,
|
|
});
|
|
}
|
|
setThought(null); // Reset thought when there's an error
|
|
|
|
// Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors)
|
|
const errorType = classifyApiError(eventValue.error);
|
|
config
|
|
.getHookSystem()
|
|
?.fireStopFailureEvent(
|
|
errorType,
|
|
eventValue.error.message,
|
|
formattedErrorText,
|
|
)
|
|
.catch((err) => {
|
|
debugLogger.warn(`StopFailure hook failed: ${err}`);
|
|
});
|
|
},
|
|
[
|
|
commitPendingThought,
|
|
commitItemInOrder,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
setPendingRetryErrorItem,
|
|
config,
|
|
setThought,
|
|
clearRetryCountdown,
|
|
],
|
|
);
|
|
|
|
const handleCitationEvent = useCallback(
|
|
(text: string, userMessageTimestamp: number) => {
|
|
if (!showCitations(settings)) {
|
|
return;
|
|
}
|
|
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem({ type: MessageType.INFO, text }, userMessageTimestamp);
|
|
},
|
|
[
|
|
addItem,
|
|
commitItemInOrder,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
settings,
|
|
],
|
|
);
|
|
|
|
const handleFinishedEvent = useCallback(
|
|
(event: ServerLlmFinishedEvent, 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.IMAGE_PROHIBITED_CONTENT]:
|
|
'Response stopped due to image prohibited content.',
|
|
[FinishReason.IMAGE_RECITATION]:
|
|
'Response stopped due to image recitation policy.',
|
|
[FinishReason.IMAGE_OTHER]:
|
|
'Response stopped due to other image-related reasons.',
|
|
[FinishReason.NO_IMAGE]: 'Response stopped due to no image.',
|
|
[FinishReason.UNEXPECTED_TOOL_CALL]:
|
|
'Response stopped due to unexpected tool call.',
|
|
};
|
|
|
|
// `finishMessage` is provider-authored free text; only the safety path
|
|
// curates it (Anthropic refusal details), so other reasons keep their
|
|
// maintained notice even if a provider fills the field (#9005).
|
|
const message =
|
|
(finishReason === FinishReason.SAFETY
|
|
? event.value.finishMessage
|
|
: undefined) ?? finishReasonMessages[finishReason];
|
|
if (message) {
|
|
addItem(
|
|
{
|
|
type: 'info',
|
|
text: `⚠ ${message}`,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
// Only clear auto-retry countdown errors (those with active timer)
|
|
if (retryCountdownTimerRef.current) {
|
|
clearRetryCountdown();
|
|
}
|
|
},
|
|
[addItem, clearRetryCountdown],
|
|
);
|
|
|
|
const autonomousLoopTickResolverRef =
|
|
useRef<AutonomousLoopTickResolver | null>(null);
|
|
|
|
const handleChatCompressionEvent = useCallback(
|
|
(
|
|
eventValue: ServerLlmChatCompressedEvent['value'],
|
|
userMessageTimestamp: number,
|
|
) => {
|
|
autonomousLoopTickResolverRef.current?.resetCache();
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
const activeModel = modelOverrideRef.current ?? config.getModel();
|
|
const reasonClause =
|
|
eventValue?.triggerReason === 'image_overflow'
|
|
? `accumulated enough tool screenshots to trigger compaction for ${activeModel}`
|
|
: eventValue?.triggerReason === 'payload_overflow'
|
|
? `exceeded the endpoint request-body limit for ${activeModel}`
|
|
: `approached the input token limit for ${activeModel}`;
|
|
const warningSuffix = eventValue?.warning
|
|
? `\n⚠️ ${eventValue.warning}`
|
|
: '';
|
|
// Estimated counts (#9309) get a '~' prefix so the notice doesn't read
|
|
// as an API-reported figure on a different scale than a later banner.
|
|
const formatCount = (count?: number, isEstimated?: boolean) =>
|
|
count === undefined
|
|
? 'unknown'
|
|
: isEstimated
|
|
? `~${count}`
|
|
: String(count);
|
|
return addItem(
|
|
{
|
|
type: 'info',
|
|
text:
|
|
`IMPORTANT: This conversation ${reasonClause}. ` +
|
|
`A compressed context will be sent for future messages (compressed from: ` +
|
|
`${formatCount(eventValue?.originalTokenCount, eventValue?.originalTokenCountIsEstimated)} to ` +
|
|
`${formatCount(eventValue?.newTokenCount, eventValue?.newTokenCountIsEstimated)} tokens).` +
|
|
warningSuffix,
|
|
},
|
|
Date.now(),
|
|
);
|
|
},
|
|
[
|
|
addItem,
|
|
commitItemInOrder,
|
|
config,
|
|
pendingHistoryItemRef,
|
|
setPendingHistoryItem,
|
|
],
|
|
);
|
|
|
|
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.`,
|
|
},
|
|
Date.now(),
|
|
),
|
|
[addItem, config],
|
|
);
|
|
|
|
const handleSessionTokenLimitExceededEvent = useCallback(
|
|
(value: { currentTokens: number; limit: number; message: string }) =>
|
|
addItem(
|
|
{
|
|
type: 'error',
|
|
text:
|
|
`✗ Session token limit exceeded: ${value.currentTokens.toLocaleString()} tokens > ${value.limit.toLocaleString()} limit.\n\n` +
|
|
`★ Solutions:\n` +
|
|
` • Start a new session: Use /clear command\n` +
|
|
` • Increase limit: Add "sessionTokenLimit": (e.g., 128000) to your settings.json\n` +
|
|
` • Compress history: Use /compress command to compress history`,
|
|
},
|
|
Date.now(),
|
|
),
|
|
[addItem],
|
|
);
|
|
|
|
const handleLoopDetectionConfirmation = useCallback(
|
|
(
|
|
result: { userSelection: 'disable' | 'keep' },
|
|
stop: LoopStop | undefined,
|
|
) => {
|
|
setLoopDetectionConfirmationRequest(null);
|
|
|
|
if (result.userSelection === 'disable') {
|
|
config.getLlmClient().getLoopDetectionService().disableForSession();
|
|
addItem(
|
|
{
|
|
type: 'info',
|
|
text: `Loop detection has been disabled for this session. Please try your request again.`,
|
|
},
|
|
Date.now(),
|
|
);
|
|
} else {
|
|
addItem(
|
|
{
|
|
type: 'info',
|
|
text: formatInteractiveLoopDetectedMessage(stop),
|
|
},
|
|
Date.now(),
|
|
);
|
|
}
|
|
},
|
|
[config, addItem],
|
|
);
|
|
|
|
const handleLoopDetectedEvent = useCallback(
|
|
(stop: LoopStop | undefined) => {
|
|
// Show the confirmation dialog to choose whether to disable loop detection
|
|
setLoopDetectionConfirmationRequest({
|
|
cause: describeLoopStop(stop),
|
|
onComplete: (result) => handleLoopDetectionConfirmation(result, stop),
|
|
});
|
|
},
|
|
[handleLoopDetectionConfirmation],
|
|
);
|
|
|
|
const handleUserPromptSubmitBlockedEvent = useCallback(
|
|
(
|
|
value: { reason: string; originalPrompt: string },
|
|
userMessageTimestamp: number,
|
|
) => {
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{
|
|
type: 'user_prompt_submit_blocked',
|
|
reason: value.reason,
|
|
originalPrompt: value.originalPrompt,
|
|
} as HistoryItemWithoutId,
|
|
userMessageTimestamp,
|
|
);
|
|
},
|
|
[addItem, commitItemInOrder, pendingHistoryItemRef, setPendingHistoryItem],
|
|
);
|
|
|
|
const handleStopHookLoopEvent = useCallback(
|
|
(
|
|
value: {
|
|
iterationCount: number;
|
|
reasons: string[];
|
|
stopHookCount: number;
|
|
},
|
|
userMessageTimestamp: number,
|
|
) => {
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(pendingHistoryItemRef.current, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{
|
|
type: 'stop_hook_loop',
|
|
iterationCount: value.iterationCount,
|
|
reasons: value.reasons,
|
|
stopHookCount: value.stopHookCount,
|
|
} as HistoryItemWithoutId,
|
|
userMessageTimestamp,
|
|
);
|
|
},
|
|
[addItem, commitItemInOrder, pendingHistoryItemRef, setPendingHistoryItem],
|
|
);
|
|
|
|
const processLlmStreamEvents = useCallback(
|
|
async (
|
|
source: AsyncIterable<LlmEvent> | SharedModelRound,
|
|
userMessageTimestamp: number,
|
|
signal: AbortSignal,
|
|
submitType: SendMessageType,
|
|
turnAdmission?: GoalTurnAdmission,
|
|
promptId?: string,
|
|
trackInteractionOwner = true,
|
|
toolContinuationOwner?: ToolContinuationOwner,
|
|
): Promise<StreamProcessingResult> => {
|
|
let llmMessageBuffer = '';
|
|
let thoughtBuffer = '';
|
|
let scheduledToolContinuation = false;
|
|
let userPromptBlocked = false;
|
|
let assistantOutputStarted =
|
|
pendingHistoryItemRef.current?.type === 'gemini' ||
|
|
pendingHistoryItemRef.current?.type === 'gemini_content';
|
|
let assistantInlineImageCount = [
|
|
...pendingAssistantItemsRef.current,
|
|
pendingHistoryItemRef.current,
|
|
].reduce(
|
|
(count, item) =>
|
|
item?.type === 'gemini' || item?.type === 'gemini_content'
|
|
? count + (item.images?.length ?? 0)
|
|
: count,
|
|
0,
|
|
);
|
|
const sharedRound = 'round' in source;
|
|
// On the shared round, `runModelRound` collects the calls and drops
|
|
// those of an attempt a retry or fallback replaced; the processor's own
|
|
// resets of `toolCallRequests` below matter only on the current path.
|
|
let toolCallRequests: ToolCallRequestInfo[] = [];
|
|
let roundStop: AgentLoopStopReason | undefined;
|
|
let streamInteractionOwner = trackInteractionOwner
|
|
? activeInteractionOwnerRef.current
|
|
: undefined;
|
|
const bufferedEvents: BufferedStreamEvent[] = [];
|
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const discardBufferedStreamEvents = () => {
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
bufferedEvents.length = 0;
|
|
};
|
|
|
|
const flushBufferedStreamEvents = () => {
|
|
if (flushTimer) {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
|
|
if (bufferedEvents.length === 0) {
|
|
return;
|
|
}
|
|
|
|
while (bufferedEvents.length > 0) {
|
|
const nextEvent = bufferedEvents.shift()!;
|
|
|
|
if (nextEvent.kind === 'content') {
|
|
const contentParts = [nextEvent.value];
|
|
|
|
while (bufferedEvents[0]?.kind === 'content') {
|
|
const queuedContent = bufferedEvents.shift();
|
|
if (queuedContent?.kind !== 'content') {
|
|
break;
|
|
}
|
|
contentParts.push(queuedContent.value);
|
|
}
|
|
|
|
llmMessageBuffer = handleContentEvent(
|
|
contentParts.join(''),
|
|
llmMessageBuffer,
|
|
userMessageTimestamp,
|
|
assistantOutputStarted,
|
|
);
|
|
if (contentParts.some((part) => part.trim().length > 0)) {
|
|
assistantOutputStarted = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (nextEvent.kind === 'image') {
|
|
if (turnCancelledRef.current) {
|
|
continue;
|
|
}
|
|
setIsReceivingContent(true);
|
|
turnSawContentEventRef.current = true;
|
|
const pendingItem = pendingHistoryItemRef.current;
|
|
const isOverflowOnlyItem =
|
|
(pendingItem?.type === 'gemini' ||
|
|
pendingItem?.type === 'gemini_content') &&
|
|
pendingItem.text.length === 0 &&
|
|
!pendingItem.images?.length &&
|
|
Boolean(pendingItem.omittedImageCount);
|
|
const shouldDisplayImage =
|
|
assistantInlineImageCount < MAX_INLINE_IMAGES_PER_ITEM;
|
|
|
|
if (!shouldDisplayImage && isOverflowOnlyItem) {
|
|
setPendingHistoryItem({
|
|
...pendingItem,
|
|
omittedImageCount: (pendingItem.omittedImageCount ?? 0) + 1,
|
|
});
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = true;
|
|
continue;
|
|
}
|
|
|
|
if (pendingHistoryItemRef.current) {
|
|
if (!stagePendingAssistantItem()) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
}
|
|
llmMessageBuffer = '';
|
|
if (shouldDisplayImage) {
|
|
setPendingHistoryItem({
|
|
type: assistantOutputStarted ? 'gemini_content' : 'gemini',
|
|
text: '',
|
|
images: [nextEvent.value],
|
|
...(!assistantOutputStarted ? { timestamp: Date.now() } : {}),
|
|
});
|
|
assistantInlineImageCount++;
|
|
} else {
|
|
setPendingHistoryItem({
|
|
type: assistantOutputStarted ? 'gemini_content' : 'gemini',
|
|
text: '',
|
|
omittedImageCount: 1,
|
|
...(!assistantOutputStarted ? { timestamp: Date.now() } : {}),
|
|
});
|
|
}
|
|
assistantOutputStarted = true;
|
|
continue;
|
|
}
|
|
|
|
let subject = nextEvent.value.subject;
|
|
const thoughtDescriptions: string[] = [];
|
|
if (nextEvent.value.description) {
|
|
thoughtDescriptions.push(nextEvent.value.description);
|
|
}
|
|
|
|
while (bufferedEvents[0]?.kind === 'thought') {
|
|
const queuedThought = bufferedEvents.shift();
|
|
if (queuedThought?.kind !== 'thought') {
|
|
break;
|
|
}
|
|
subject = queuedThought.value.subject || subject;
|
|
if (queuedThought.value.description) {
|
|
thoughtDescriptions.push(queuedThought.value.description);
|
|
}
|
|
}
|
|
|
|
const description = thoughtDescriptions.join('');
|
|
if (description.trim().length > 0 && stagePendingAssistantItem()) {
|
|
llmMessageBuffer = '';
|
|
}
|
|
thoughtBuffer = handleThoughtEvent(
|
|
{
|
|
subject,
|
|
description,
|
|
},
|
|
thoughtBuffer,
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
};
|
|
|
|
const scheduleBufferedStreamFlush = () => {
|
|
if (flushTimer) {
|
|
return;
|
|
}
|
|
|
|
flushTimer = setTimeout(() => {
|
|
flushBufferedStreamEvents();
|
|
}, STREAM_UPDATE_THROTTLE_MS);
|
|
};
|
|
|
|
const handleStreamEvent = (
|
|
event: LlmEvent,
|
|
): StreamProcessingResult | undefined => {
|
|
if (!streamInteractionOwner && promptId) {
|
|
streamInteractionOwner = getActiveInteractionSpan(promptId);
|
|
if (trackInteractionOwner && streamInteractionOwner) {
|
|
activeInteractionOwnerRef.current ??= streamInteractionOwner;
|
|
}
|
|
}
|
|
dualOutput?.processEvent(event);
|
|
const modTurn = modTurnRef.current;
|
|
if (trackInteractionOwner && modTurn && modTurn.promptId === promptId)
|
|
modTurn.turn.saw(event);
|
|
switch (event.type) {
|
|
case ServerLlmEventType.Thought:
|
|
// Subject-only chunks are discrete status updates for the
|
|
// loading indicator and render immediately. Anything carrying
|
|
// streamed text (with or without a subject) goes through the
|
|
// throttled buffer so it batches with adjacent reasoning
|
|
// chunks; the flush merger preserves the subject.
|
|
if (event.value.subject && !event.value.description) {
|
|
flushBufferedStreamEvents();
|
|
setThought(event.value);
|
|
} else {
|
|
bufferedEvents.push({ kind: 'thought', value: event.value });
|
|
scheduleBufferedStreamFlush();
|
|
}
|
|
break;
|
|
case ServerLlmEventType.Content: {
|
|
// Thinking is done once the answer starts streaming; reset the
|
|
// title status. On the thinking→answer transition, flush any
|
|
// buffered reasoning so the full thought is captured, then commit
|
|
// it to history (collapsed) above the answer. After that the
|
|
// condition is false, so normal content batching resumes.
|
|
if (
|
|
pendingThoughtItemRef.current ||
|
|
bufferedEvents.some((e) => e.kind === 'thought')
|
|
) {
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp, true);
|
|
thoughtBuffer = '';
|
|
}
|
|
setThought((prev) => (prev ? null : prev));
|
|
const displayParts = event.parts ?? [{ text: event.value }];
|
|
for (const part of displayParts) {
|
|
if ('text' in part) {
|
|
if (part.text.length > 0) {
|
|
bufferedEvents.push({ kind: 'content', value: part.text });
|
|
}
|
|
} else {
|
|
const image = getInlineImageData({
|
|
inlineData: part.inlineData,
|
|
});
|
|
if (image) {
|
|
bufferedEvents.push({ kind: 'image', value: image });
|
|
}
|
|
}
|
|
}
|
|
scheduleBufferedStreamFlush();
|
|
break;
|
|
}
|
|
case ServerLlmEventType.ToolCallRequest:
|
|
// Thinking is done once a tool call is issued; flush buffered
|
|
// reasoning then commit it to history (collapsed) above the tool
|
|
// output.
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp, true);
|
|
thoughtBuffer = '';
|
|
setThought((prev) => (prev ? null : prev));
|
|
if (event.value.goalContext && turnAdmission) {
|
|
bindGoalTurn(
|
|
event.value.goalContext,
|
|
turnAdmission.turnKey,
|
|
turnAdmission.origin,
|
|
turnAdmission.controller,
|
|
);
|
|
}
|
|
if (!sharedRound) toolCallRequests.push(event.value);
|
|
// Count tool call args JSON toward token estimation.
|
|
try {
|
|
const argsJson = JSON.stringify(event.value.args);
|
|
streamingResponseLengthRef.current += argsJson.length;
|
|
} catch {
|
|
// Best-effort — don't block on serialization errors
|
|
}
|
|
break;
|
|
case ServerLlmEventType.UserCancelled:
|
|
flushBufferedStreamEvents();
|
|
toolCallRequests.length = 0;
|
|
handleUserCancelledEvent(userMessageTimestamp);
|
|
return {
|
|
status: StreamProcessingStatus.UserCancelled,
|
|
scheduledToolContinuation: false,
|
|
userPromptBlocked,
|
|
};
|
|
case ServerLlmEventType.Error:
|
|
flushBufferedStreamEvents();
|
|
handleErrorEvent(
|
|
event.value,
|
|
userMessageTimestamp,
|
|
submitType,
|
|
trackInteractionOwner &&
|
|
toolContinuationOwner?.foregroundAbortController !==
|
|
undefined &&
|
|
abortControllerRef.current ===
|
|
toolContinuationOwner.foregroundAbortController,
|
|
);
|
|
break;
|
|
case ServerLlmEventType.ChatCompressed:
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp);
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
handleChatCompressionEvent(event.value, userMessageTimestamp);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
break;
|
|
case ServerLlmEventType.ToolCallConfirmation:
|
|
case ServerLlmEventType.ToolCallResponse:
|
|
flushBufferedStreamEvents();
|
|
break;
|
|
case ServerLlmEventType.ToolCallPreparation:
|
|
break;
|
|
case ServerLlmEventType.MaxSessionTurns:
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp);
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
handleMaxSessionTurnsEvent();
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
break;
|
|
case ServerLlmEventType.SessionTokenLimitExceeded:
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp);
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
handleSessionTokenLimitExceededEvent(event.value);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
break;
|
|
case ServerLlmEventType.Finished:
|
|
flushBufferedStreamEvents();
|
|
// A thinking-only turn (no content/tool) still commits its
|
|
// reasoning so it persists collapsed in history.
|
|
commitPendingThought(userMessageTimestamp);
|
|
// Seal off this turn's UI state before the parent re-enters
|
|
// sendMessageStream for a continuation (Stop-hook block at
|
|
// client.ts:1378 or next-speaker auto-continue at 1444). Both
|
|
// paths yield* a fresh Turn through this same stream processor,
|
|
// so without this seal the next turn's first content/thought
|
|
// chunk appends to this turn's pending item — visible in the UI
|
|
// as "t" → "te" → "tes" cumulative rendering even though each
|
|
// turn is persisted as a clean, separate assistant message.
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
llmMessageBuffer = '';
|
|
thoughtBuffer = '';
|
|
assistantOutputStarted = false;
|
|
assistantInlineImageCount = 0;
|
|
setThought(null);
|
|
handleFinishedEvent(
|
|
event as ServerLlmFinishedEvent,
|
|
userMessageTimestamp,
|
|
);
|
|
break;
|
|
case ServerLlmEventType.Citation:
|
|
flushBufferedStreamEvents();
|
|
if (showCitations(settings)) {
|
|
commitPendingThought(userMessageTimestamp);
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
}
|
|
handleCitationEvent(event.value, userMessageTimestamp);
|
|
break;
|
|
case ServerLlmEventType.LoopDetected:
|
|
flushBufferedStreamEvents();
|
|
// handle later because we want to move pending history to history
|
|
// before we add loop detected message to history
|
|
loopDetectedRef.current = true;
|
|
loopStopRef.current = event.value;
|
|
break;
|
|
case ServerLlmEventType.Retry:
|
|
// On fresh restart (escalation / rate-limit / invalid stream),
|
|
// clear pending content and buffers to discard the failed attempt.
|
|
// On continuation (recovery), keep the pending gemini item AND
|
|
// buffers so the model's continuation text appends to them —
|
|
// otherwise handleContentEvent would see a null pending item,
|
|
// create a fresh one, and reset the buffer to just the new chunk,
|
|
// losing the partial text we meant to preserve.
|
|
if (!event.isContinuation) {
|
|
discardBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp, true);
|
|
setPendingAssistantItems([]);
|
|
if (pendingHistoryItemRef.current) {
|
|
setPendingHistoryItem(null);
|
|
}
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
assistantInlineImageCount = 0;
|
|
} else {
|
|
flushBufferedStreamEvents();
|
|
}
|
|
// Always discard tool call requests from the truncated/failed
|
|
// attempt to prevent duplicate execution after escalation or
|
|
// recovery. The recovery path now skips turns that already
|
|
// contain a functionCall (see llm-chat.ts), so this only
|
|
// clears stale requests from pre-RETRY accumulation.
|
|
toolCallRequests.length = 0;
|
|
// Show retry info if available (rate-limit / throttling errors)
|
|
if (event.retryInfo) {
|
|
startRetryCountdown(event.retryInfo);
|
|
} else {
|
|
// The retry attempt is starting now, so any prior retry UI is stale.
|
|
clearRetryCountdown();
|
|
}
|
|
break;
|
|
case ServerLlmEventType.ModelFallback: {
|
|
// The primary model (or a prior fallback) exhausted its retry
|
|
// budget on a capacity/availability error and the system is
|
|
// switching to the next fallback model. Discard partial content
|
|
// from the failed attempt and show a notification.
|
|
discardBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp, true);
|
|
setPendingAssistantItems([]);
|
|
if (pendingHistoryItemRef.current) {
|
|
setPendingHistoryItem(null);
|
|
}
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
assistantInlineImageCount = 0;
|
|
toolCallRequests.length = 0;
|
|
clearRetryCountdown();
|
|
const fromModel =
|
|
sanitizeDisplayText(event.fromModel) ?? '(unknown)';
|
|
const toModel = sanitizeDisplayText(event.toModel) ?? '(unknown)';
|
|
addItem(
|
|
{
|
|
type: 'notification',
|
|
text: `Model ${fromModel} unavailable, falling back to ${toModel}`,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
break;
|
|
}
|
|
case ServerLlmEventType.HookSystemMessage:
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp);
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
// Display system message from Stop hooks with "Stop says:" prefix
|
|
// First commit any pending AI response to ensure correct ordering
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{
|
|
type: 'stop_hook_system_message',
|
|
message: event.value,
|
|
} as HistoryItemWithoutId,
|
|
userMessageTimestamp,
|
|
);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
break;
|
|
case ServerLlmEventType.GoalSettlementFailed:
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp);
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{ type: 'warning', text: event.value },
|
|
userMessageTimestamp,
|
|
);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
break;
|
|
case ServerLlmEventType.UserPromptSubmitBlocked:
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp);
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
userPromptBlocked = true;
|
|
handleUserPromptSubmitBlockedEvent(
|
|
event.value,
|
|
userMessageTimestamp,
|
|
);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
break;
|
|
case ServerLlmEventType.StopHookLoop:
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp);
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
handleStopHookLoopEvent(event.value, userMessageTimestamp);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
break;
|
|
case ServerLlmEventType.GoalState:
|
|
if (event.cause && shouldDisplayGoalStateCause(event.cause)) {
|
|
flushBufferedStreamEvents();
|
|
commitPendingThought(userMessageTimestamp);
|
|
thoughtBuffer = '';
|
|
setThought(null);
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
addItem(
|
|
{
|
|
type: 'goal_state',
|
|
snapshot: event.value,
|
|
cause: event.cause,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
llmMessageBuffer = '';
|
|
assistantOutputStarted = false;
|
|
}
|
|
break;
|
|
default: {
|
|
// enforces exhaustive switch-case
|
|
const unreachable: never = event;
|
|
return unreachable;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
flushBufferedStreamEventsRef.current.add(flushBufferedStreamEvents);
|
|
dualOutput?.startAssistantMessage();
|
|
try {
|
|
if (sharedRound) {
|
|
debugLogger.debug(
|
|
`[processLlmStreamEvents] model round on the shared agent loop (prompt ${source.round.promptId})`,
|
|
);
|
|
const { calls, stop } = await runModelRound(
|
|
source.client,
|
|
source.round,
|
|
source.signal,
|
|
{
|
|
onModelEvent: (event) => {
|
|
const ended = handleStreamEvent(event);
|
|
if (ended) throw new StreamProcessingEnded(ended);
|
|
},
|
|
},
|
|
);
|
|
toolCallRequests = calls;
|
|
roundStop = stop;
|
|
} else {
|
|
for await (const event of source) {
|
|
const ended = handleStreamEvent(event);
|
|
if (ended) return ended;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof StreamProcessingEnded) return error.result;
|
|
throw error;
|
|
} finally {
|
|
flushBufferedStreamEvents();
|
|
// A thrown stream must leave interleaved output discardable on retry.
|
|
commitPendingThought(userMessageTimestamp, true);
|
|
discardBufferedStreamEvents();
|
|
flushBufferedStreamEventsRef.current.delete(flushBufferedStreamEvents);
|
|
dualOutput?.finalizeAssistantMessage();
|
|
}
|
|
commitPendingAssistantItems(userMessageTimestamp);
|
|
// When a loop was detected, halt without scheduling the calls collected
|
|
// before the guard fired. The core splice/clear only touches
|
|
// turn.pendingToolCalls, which the TUI does not execute from — without
|
|
// this gate the pre-detection (and, for the always-on consecutive guard,
|
|
// potentially repeated) calls would still run before the halt dialog
|
|
// appears. Mirrors the non-interactive runner, which returns on
|
|
// LoopDetected before scheduling.
|
|
if (
|
|
toolCallRequests.length > 0 &&
|
|
!signal.aborted &&
|
|
(sharedRound ? roundStop === undefined : !loopDetectedRef.current)
|
|
) {
|
|
const executableToolCallRequests: ToolCallRequestInfo[] = [];
|
|
const duplicateResponses: Array<{
|
|
request: ToolCallRequestInfo;
|
|
response: ToolCallResponseInfo;
|
|
}> = [];
|
|
let duplicatePromptId: string | undefined;
|
|
// The accessor returns a fresh map per call; copy anyway so a future
|
|
// cached accessor cannot turn per-batch recording into shared-state
|
|
// mutation. In-flight entries from this submit fill ids not already
|
|
// present in history (the history fingerprint for an id wins).
|
|
const handledToolCallFingerprints = new Map(
|
|
llmClient ? llmClient.getHistoryToolCallFingerprints() : [],
|
|
);
|
|
for (const [
|
|
providerCallId,
|
|
fingerprint,
|
|
] of handledToolCallFingerprintsRef.current) {
|
|
if (!handledToolCallFingerprints.has(providerCallId)) {
|
|
handledToolCallFingerprints.set(providerCallId, fingerprint);
|
|
}
|
|
}
|
|
const isReplayOfHandledRequest = (
|
|
request: ToolCallRequestInfo,
|
|
): boolean =>
|
|
request.providerCallId
|
|
? isReplayOfHandledToolCall(
|
|
handledToolCallFingerprints,
|
|
request.providerCallId,
|
|
getCachedToolCallFingerprint(
|
|
request,
|
|
request.name,
|
|
request.args,
|
|
),
|
|
)
|
|
: false;
|
|
const repeatedDuplicateRequest = findRepeatedDuplicateProviderToolCall(
|
|
toolCallRequests,
|
|
(request) => request.providerCallId,
|
|
isReplayOfHandledRequest,
|
|
duplicateProviderToolCallResponseIdsRef.current,
|
|
);
|
|
if (repeatedDuplicateRequest?.providerCallId) {
|
|
debugLogger.debug(
|
|
`[processLlmStreamEvents] Dropping batch after repeated duplicate provider tool-call id: ${repeatedDuplicateRequest.providerCallId} (tool: ${repeatedDuplicateRequest.name})`,
|
|
);
|
|
loopDetectedRef.current = true;
|
|
loopStopRef.current = {
|
|
loopType: LoopType.GLOBAL_TOOL_CALL_DUPLICATE,
|
|
loopReason: LoopReason.PROVIDER_DUPLICATE_ID,
|
|
};
|
|
return {
|
|
status: StreamProcessingStatus.Completed,
|
|
scheduledToolContinuation: false,
|
|
userPromptBlocked,
|
|
};
|
|
}
|
|
|
|
const executableOnSharedLoop = toolCallRequests.filter(
|
|
(request) => !isReplayOfHandledRequest(request),
|
|
);
|
|
// On the shared loop the batch answers replays and records the
|
|
// calls; a response made only of replays keeps the path below.
|
|
if (sharedRound && executableOnSharedLoop.length > 0) {
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
for (const request of executableOnSharedLoop) {
|
|
if (toolContinuationOwner) {
|
|
continuationOwnersByToolCallIdRef.current.set(
|
|
request.callId,
|
|
toolContinuationOwner,
|
|
);
|
|
}
|
|
if (streamInteractionOwner) {
|
|
interactionOwnersByToolCallIdRef.current.set(
|
|
request.callId,
|
|
streamInteractionOwner,
|
|
);
|
|
}
|
|
}
|
|
// As the current path does, the turn's other streams see these
|
|
// calls as handled from the moment they are scheduled.
|
|
for (const request of executableOnSharedLoop) {
|
|
if (!request.providerCallId) continue;
|
|
recordHandledToolCall(
|
|
handledToolCallFingerprintsRef.current,
|
|
request.providerCallId,
|
|
getCachedToolCallFingerprint(request, request.name, request.args),
|
|
);
|
|
}
|
|
registerToolBatch(executableOnSharedLoop);
|
|
const turn: SharedToolBatchTurn = { left: false };
|
|
const done = runSharedToolBatchRef.current(
|
|
{
|
|
calls: toolCallRequests,
|
|
signal,
|
|
modelOverride: modelOverrideRef.current,
|
|
promptId: toolCallRequests[0]?.prompt_id,
|
|
handled: handledToolCallFingerprints,
|
|
},
|
|
turn,
|
|
);
|
|
return {
|
|
status: StreamProcessingStatus.Completed,
|
|
scheduledToolContinuation: true,
|
|
userPromptBlocked,
|
|
sharedToolBatch: {
|
|
done,
|
|
leave: () => {
|
|
turn.left = true;
|
|
const round = turn.round;
|
|
turn.round = undefined;
|
|
return round;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
for (const request of toolCallRequests) {
|
|
const providerCallId = request.providerCallId;
|
|
if (!providerCallId) {
|
|
executableToolCallRequests.push(request);
|
|
continue;
|
|
}
|
|
|
|
if (isReplayOfHandledRequest(request)) {
|
|
markDuplicateProviderToolCallResponseSent(
|
|
providerCallId,
|
|
duplicateProviderToolCallResponseIdsRef.current,
|
|
);
|
|
|
|
const response = createDuplicateProviderToolCallResponse(request);
|
|
debugLogger.debug(
|
|
`[processLlmStreamEvents] Suppressing duplicate provider tool-call id: ${providerCallId} (tool: ${request.name})`,
|
|
);
|
|
dualOutput?.emitToolResult(request, response);
|
|
duplicateResponses.push({ request, response });
|
|
duplicatePromptId ??= request.prompt_id;
|
|
continue;
|
|
}
|
|
|
|
const requestFingerprint = getCachedToolCallFingerprint(
|
|
request,
|
|
request.name,
|
|
request.args,
|
|
);
|
|
recordHandledToolCall(
|
|
handledToolCallFingerprints,
|
|
providerCallId,
|
|
requestFingerprint,
|
|
);
|
|
recordHandledToolCall(
|
|
handledToolCallFingerprintsRef.current,
|
|
providerCallId,
|
|
requestFingerprint,
|
|
);
|
|
executableToolCallRequests.push(request);
|
|
}
|
|
|
|
if (duplicateResponses.length > 0) {
|
|
if (executableToolCallRequests.length > 0) {
|
|
pendingDuplicateToolResponsesRef.current.push({
|
|
executableCallIds: new Set(
|
|
executableToolCallRequests.map((request) => request.callId),
|
|
),
|
|
promptId:
|
|
duplicatePromptId ?? executableToolCallRequests[0]?.prompt_id,
|
|
callOrder: toolCallRequests.map((request) => request.callId),
|
|
duplicateResponses,
|
|
});
|
|
} else {
|
|
immediateDuplicateToolResponsesRef.current = {
|
|
promptId: duplicatePromptId,
|
|
responses: duplicateResponses,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (executableToolCallRequests.length > 0) {
|
|
// The scheduler may complete a fast tool before this stream's caller
|
|
// regains control. Seal streamed assistant text first so the tool
|
|
// group cannot enter static history ahead of it.
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
if (toolContinuationOwner) {
|
|
for (const request of executableToolCallRequests) {
|
|
continuationOwnersByToolCallIdRef.current.set(
|
|
request.callId,
|
|
toolContinuationOwner,
|
|
);
|
|
}
|
|
}
|
|
if (streamInteractionOwner) {
|
|
for (const request of executableToolCallRequests) {
|
|
interactionOwnersByToolCallIdRef.current.set(
|
|
request.callId,
|
|
streamInteractionOwner,
|
|
);
|
|
}
|
|
}
|
|
scheduledToolContinuation = true;
|
|
registerToolBatch(executableToolCallRequests);
|
|
scheduleToolCalls(
|
|
executableToolCallRequests,
|
|
signal,
|
|
modelOverrideRef.current,
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
status: StreamProcessingStatus.Completed,
|
|
scheduledToolContinuation,
|
|
userPromptBlocked,
|
|
};
|
|
},
|
|
[
|
|
handleContentEvent,
|
|
handleThoughtEvent,
|
|
handleUserCancelledEvent,
|
|
handleErrorEvent,
|
|
registerToolBatch,
|
|
scheduleToolCalls,
|
|
llmClient,
|
|
handleChatCompressionEvent,
|
|
handleFinishedEvent,
|
|
handleMaxSessionTurnsEvent,
|
|
handleSessionTokenLimitExceededEvent,
|
|
handleCitationEvent,
|
|
settings,
|
|
startRetryCountdown,
|
|
clearRetryCountdown,
|
|
setThought,
|
|
commitPendingThought,
|
|
commitPendingAssistantItems,
|
|
pendingHistoryItemRef,
|
|
pendingAssistantItemsRef,
|
|
pendingThoughtItemRef,
|
|
setPendingHistoryItem,
|
|
handleUserPromptSubmitBlockedEvent,
|
|
handleStopHookLoopEvent,
|
|
bindGoalTurn,
|
|
addItem,
|
|
commitItemInOrder,
|
|
stagePendingAssistantItem,
|
|
setPendingAssistantItems,
|
|
dualOutput,
|
|
],
|
|
);
|
|
|
|
const resolveSteeredMessages = useCallback(
|
|
async (
|
|
messages: string[],
|
|
signal: AbortSignal,
|
|
): Promise<ResolvedSteerMessages> => {
|
|
const resolvedSegments: Part[][] = [];
|
|
const resolvedForRecording: Array<{
|
|
message: string;
|
|
parts: Part[];
|
|
sideEffects: Array<() => void>;
|
|
}> = [];
|
|
const restoreMessages: string[] = [];
|
|
const timestamp = Date.now();
|
|
|
|
for (let index = 0; index < messages.length; index += 1) {
|
|
if (signal.aborted) {
|
|
restoreMessages.push(...messages.slice(index));
|
|
break;
|
|
}
|
|
|
|
const message = messages[index];
|
|
if (GOAL_COMMAND_RE.test(message)) {
|
|
await handleSlashCommand(message);
|
|
// The command has already taken effect; restoring it after cancelled
|
|
// steering preparation would execute that side effect again.
|
|
continue;
|
|
}
|
|
|
|
restoreMessages.push(message);
|
|
const sideEffects: Array<() => void> = [];
|
|
let resolvedQuery: PartListUnion = [{ text: message }];
|
|
if (isAtCommand(message)) {
|
|
const timeout = new AbortController();
|
|
const atCommandSignal = AbortSignal.any([signal, timeout.signal]);
|
|
// URL media refs are exempt from the fixed mid-turn budget: the
|
|
// omni URL path downloads and uploads media end-to-end inside
|
|
// resolveAtCommandQuery under its own watchdogs (30s header, 60s
|
|
// idle), so a 10s cap would structurally kill every mid-turn
|
|
// @https reference — and inside the resolver the timeout abort is
|
|
// indistinguishable from a user cancel, so the message would be
|
|
// dropped quietly rather than failing with a named error.
|
|
// Filesystem resolution keeps the cap unchanged.
|
|
const hasUrlMediaRef =
|
|
/@https?:\/\//i.test(message) &&
|
|
(config.isOmniEnabled?.() ?? false);
|
|
const timeoutId = hasUrlMediaRef
|
|
? undefined
|
|
: setTimeout(() => {
|
|
timeout.abort(
|
|
new Error(MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MESSAGE),
|
|
);
|
|
}, MID_TURN_AT_COMMAND_RESOLVE_TIMEOUT_MS);
|
|
try {
|
|
const atCommandResult = await resolveWithAbort(
|
|
atCommandSignal,
|
|
() =>
|
|
resolveAtCommandQuery({
|
|
query: message,
|
|
config,
|
|
onDebugMessage,
|
|
messageId: timestamp + index,
|
|
signal: atCommandSignal,
|
|
}),
|
|
);
|
|
const shouldSkipMessage =
|
|
!atCommandResult.shouldProceed &&
|
|
(atCommandResult.toolDisplays?.length ?? 0) > 0;
|
|
if (
|
|
atCommandResult.shouldProceed &&
|
|
atCommandResult.processedQuery !== null
|
|
) {
|
|
resolvedQuery = atCommandResult.processedQuery;
|
|
} else if (atCommandResult.toolDisplays?.length) {
|
|
const toolDisplays = atCommandResult.toolDisplays;
|
|
const showToolDisplays = () =>
|
|
addItem(
|
|
{ type: 'tool_group', tools: toolDisplays },
|
|
timestamp + index,
|
|
);
|
|
if (shouldSkipMessage) showToolDisplays();
|
|
else sideEffects.push(showToolDisplays);
|
|
}
|
|
if (atCommandResult.recording) {
|
|
const recordAtCommand = () =>
|
|
config.getChatRecordingService?.()?.recordAtCommand?.({
|
|
filesRead: atCommandResult.recording!.filesRead,
|
|
status: atCommandResult.recording!.status,
|
|
...(atCommandResult.recording!.message
|
|
? { message: atCommandResult.recording!.message }
|
|
: {}),
|
|
userText: message,
|
|
});
|
|
if (shouldSkipMessage) recordAtCommand();
|
|
else sideEffects.push(recordAtCommand);
|
|
}
|
|
if (shouldSkipMessage) continue;
|
|
} catch (error) {
|
|
const errorMessage = getErrorMessage(error);
|
|
onDebugMessage(
|
|
`Failed to resolve mid-turn @ command: ${errorMessage}`,
|
|
);
|
|
if (!signal.aborted) {
|
|
addItem(
|
|
{
|
|
type: MessageType.WARNING,
|
|
text: `Could not attach file: ${errorMessage}`,
|
|
},
|
|
Date.now(),
|
|
);
|
|
}
|
|
continue;
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
if (signal.aborted) {
|
|
restoreMessages.push(...messages.slice(index + 1));
|
|
break;
|
|
}
|
|
}
|
|
|
|
const bridgeResult = await applyVisionBridgeIfNeeded(
|
|
resolvedQuery,
|
|
timestamp + index,
|
|
signal,
|
|
);
|
|
if (!bridgeResult.shouldProceed) {
|
|
if (signal.aborted) {
|
|
restoreMessages.push(...messages.slice(index + 1));
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const messageParts = normalizePartList(
|
|
bridgeResult.parts ?? resolvedQuery,
|
|
);
|
|
const formatCheck = checkImageFormatsSupport(messageParts);
|
|
if (formatCheck.hasUnsupportedFormats) {
|
|
sideEffects.push(() =>
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: getUnsupportedImageFormatWarning(),
|
|
},
|
|
Date.now(),
|
|
),
|
|
);
|
|
}
|
|
|
|
resolvedSegments.push(messageParts);
|
|
resolvedForRecording.push({
|
|
message,
|
|
parts: messageParts,
|
|
sideEffects,
|
|
});
|
|
}
|
|
|
|
const resolvedMessages: Part[] = [];
|
|
for (const segment of resolvedSegments) {
|
|
if (segment.length === 0) continue;
|
|
if (resolvedMessages.length > 0) {
|
|
resolvedMessages.push({ text: '\n\n' });
|
|
}
|
|
resolvedMessages.push(...segment);
|
|
}
|
|
|
|
return {
|
|
parts: resolvedMessages,
|
|
restoreMessages,
|
|
accept: () => {
|
|
for (const { message, parts, sideEffects } of resolvedForRecording) {
|
|
for (const sideEffect of sideEffects) sideEffect();
|
|
const recorder = config.getChatRecordingService?.();
|
|
const goalPermit = activeGoalTurnRef.current?.permit;
|
|
if (goalPermit) {
|
|
recorder?.recordMidTurnUserMessage(parts, message, goalPermit);
|
|
} else {
|
|
recorder?.recordMidTurnUserMessage(parts, message);
|
|
}
|
|
addItem(
|
|
{
|
|
type: MessageType.USER,
|
|
text: message,
|
|
// Intentionally false: preserves isRealUserTurn/rewind semantics (steer is not a standalone user turn).
|
|
sentToModel: false,
|
|
},
|
|
Date.now(),
|
|
);
|
|
}
|
|
},
|
|
};
|
|
},
|
|
[
|
|
addItem,
|
|
applyVisionBridgeIfNeeded,
|
|
config,
|
|
handleSlashCommand,
|
|
onDebugMessage,
|
|
],
|
|
);
|
|
|
|
const resolveDrainedSteerMessages = useCallback(
|
|
async (
|
|
messages: string[],
|
|
signal: AbortSignal,
|
|
): Promise<SteerInput | undefined> => {
|
|
try {
|
|
const resolved = await resolveSteeredMessages(messages, signal);
|
|
if (signal.aborted) {
|
|
if (resolved.restoreMessages.length > 0) {
|
|
midTurnRestoreRef?.current?.(resolved.restoreMessages);
|
|
}
|
|
return undefined;
|
|
}
|
|
if (resolved.parts.length === 0) return undefined;
|
|
let settled = false;
|
|
return {
|
|
parts: resolved.parts,
|
|
accept: () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolved.accept();
|
|
},
|
|
restore: () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (resolved.restoreMessages.length > 0) {
|
|
midTurnRestoreRef?.current?.(resolved.restoreMessages);
|
|
}
|
|
},
|
|
};
|
|
} catch (error) {
|
|
midTurnRestoreRef?.current?.(messages);
|
|
onDebugMessage(
|
|
`Failed to prepare steer input: ${getErrorMessage(error)}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
},
|
|
[midTurnRestoreRef, onDebugMessage, resolveSteeredMessages],
|
|
);
|
|
|
|
const drainSteerAtBoundary = useCallback(
|
|
async (signal: AbortSignal): Promise<SteerInput | undefined> => {
|
|
const messages =
|
|
midTurnDrainRef?.current?.(
|
|
false,
|
|
Boolean(activeGoalAdmissionRef.current),
|
|
) ?? [];
|
|
if (messages.length === 0) return undefined;
|
|
return resolveDrainedSteerMessages(messages, signal);
|
|
},
|
|
[midTurnDrainRef, resolveDrainedSteerMessages],
|
|
);
|
|
|
|
const submitQuery = useCallback(
|
|
async (
|
|
query: PartListUnion,
|
|
submitType: SendMessageType = SendMessageType.UserQuery,
|
|
prompt_id?: string,
|
|
metadata?: {
|
|
notificationDisplayText?: string;
|
|
todoWorkChainId?: string;
|
|
onDelivered?: () => void;
|
|
onDeliveryFailed?: () => void;
|
|
onAdmissionFailed?: () => void;
|
|
onGoalClaimDeferred?: () => void;
|
|
onRequestStarted?: () => void;
|
|
steerInput?: SteerInput;
|
|
submittedPrompt?: string;
|
|
displayText?: string;
|
|
humanInput?: HumanPromptInput;
|
|
hasUnknownInput?: boolean;
|
|
pasteExpansions?: ReadonlyMap<string, string>;
|
|
/**
|
|
* Shell intent recorded when the user submitted this query
|
|
* (queued submissions carry it from the message queue). When set,
|
|
* it overrides the live `shellModeActive` flag for shell routing,
|
|
* so a flip after enqueue cannot misroute the entry (#11626).
|
|
*/
|
|
shellMode?: boolean;
|
|
goal?: QueuedGoalTurn;
|
|
claimGoalTurn?: () => QueuedGoalTurn | undefined;
|
|
userAdmission?: DirectUserAdmission;
|
|
goalBinding?: GoalTurnBinding;
|
|
toolContinuationOwner?: ToolContinuationOwner;
|
|
/** A prompt a Mod submitted, which `query` frames. */
|
|
modPrompt?: ModQueuedPrompt;
|
|
},
|
|
) => {
|
|
const mods = getInteractiveModSession(config);
|
|
const modPrompt =
|
|
submitType === SendMessageType.UserQuery
|
|
? metadata?.modPrompt
|
|
: undefined;
|
|
const dispatchesSlashCommand =
|
|
!modPrompt &&
|
|
(submitType === SendMessageType.UserQuery ||
|
|
submitType === SendMessageType.Cron) &&
|
|
typeof query === 'string' &&
|
|
isSlashCommand(query.trim());
|
|
// Whether the Mods started. When they failed, the prompt is refused
|
|
// with the reason, and the caller releases what it holds.
|
|
const modsStarted = async (): Promise<boolean> => {
|
|
try {
|
|
await mods?.ready;
|
|
return true;
|
|
} catch {
|
|
addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: MODS_FAILED_TO_INITIALIZE,
|
|
},
|
|
Date.now(),
|
|
);
|
|
await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey);
|
|
return false;
|
|
}
|
|
};
|
|
if (mods && !dispatchesSlashCommand && !(await modsStarted())) {
|
|
metadata?.onAdmissionFailed?.();
|
|
metadata?.onDeliveryFailed?.();
|
|
return;
|
|
}
|
|
const allowConcurrentBtwDuringResponse =
|
|
submitType === SendMessageType.UserQuery &&
|
|
streamingState === StreamingState.Responding &&
|
|
activeModelStreamsRef.current > 0 &&
|
|
typeof query === 'string' &&
|
|
isBtwCommand(query) &&
|
|
!activeGoalAdmissionRef.current;
|
|
let ownsSubmissionLease = false;
|
|
let submissionLeaseGeneration: number | undefined;
|
|
const acquireSubmissionLease = () => {
|
|
if (isSubmittingQueryRef.current) return;
|
|
ownsSubmissionLease = true;
|
|
submissionLeaseGeneration = submissionLeaseGenerationRef.current + 1;
|
|
submissionLeaseGenerationRef.current = submissionLeaseGeneration;
|
|
setSubmissionInFlight(true);
|
|
};
|
|
const releaseSubmissionLease = () => {
|
|
if (!ownsSubmissionLease) return;
|
|
ownsSubmissionLease = false;
|
|
if (
|
|
submissionLeaseGeneration !== submissionLeaseGenerationRef.current
|
|
) {
|
|
return;
|
|
}
|
|
setSubmissionInFlight(false);
|
|
};
|
|
const isTurnContinuation =
|
|
submitType === SendMessageType.ToolResult ||
|
|
submitType === SendMessageType.Steer;
|
|
const submittedPrompt =
|
|
submitType === SendMessageType.UserQuery
|
|
? metadata?.submittedPrompt
|
|
: undefined;
|
|
|
|
// Prevent concurrent executions of submitQuery, but allow continuations
|
|
// which are part of the same logical flow (tool responses)
|
|
if (
|
|
isSubmittingQueryRef.current &&
|
|
!isTurnContinuation &&
|
|
!allowConcurrentBtwDuringResponse
|
|
) {
|
|
await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey);
|
|
metadata?.onAdmissionFailed?.();
|
|
metadata?.onDeliveryFailed?.();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(streamingState === StreamingState.Responding ||
|
|
streamingState === StreamingState.WaitingForConfirmation) &&
|
|
!isTurnContinuation &&
|
|
!allowConcurrentBtwDuringResponse
|
|
) {
|
|
await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey);
|
|
metadata?.onAdmissionFailed?.();
|
|
metadata?.onDeliveryFailed?.();
|
|
return;
|
|
}
|
|
|
|
// The person's own prompt, a slash command too, lets the Mods' prompts
|
|
// start turns again.
|
|
if (submitType === SendMessageType.UserQuery && !modPrompt)
|
|
mods?.notePersonPrompt();
|
|
|
|
// Set the flag to indicate we're now executing
|
|
acquireSubmissionLease();
|
|
const submissionGeneration = submissionLeaseGenerationRef.current;
|
|
|
|
// loopDetectedRef now gates tool-call scheduling (see processLlmStream
|
|
// events), so it must reflect only this turn's state. Reset it
|
|
// unconditionally at entry: if the previous turn detected a loop but threw
|
|
// before its own post-stream reset, a stuck `true` would otherwise make
|
|
// every later turn silently drop its tool calls. A ToolResult/btw
|
|
// continuation never carries a pending loop (a detected loop schedules
|
|
// nothing), so clearing it here is a no-op for those paths.
|
|
loopDetectedRef.current = false;
|
|
loopStopRef.current = undefined;
|
|
|
|
// Reset turn-local ownership trackers at the very top of every
|
|
// top-level submit (UserQuery, Retry, Cron, Notification, etc.).
|
|
// `prepareQueryForLlm` also resets `lastTurnUserItemRef`, but
|
|
// Retry skips that path — without this earlier reset, a stale
|
|
// ownership snapshot from the prior UserQuery would survive into
|
|
// the retry's cancel info and let auto-restore wrongly truncate
|
|
// the original prompt.
|
|
//
|
|
// ToolResult continuations and same-turn btw concurrencies keep
|
|
// the trackers untouched — they're piggybacking on an in-flight
|
|
// turn that already owns its own snapshot.
|
|
if (!isTurnContinuation && !allowConcurrentBtwDuringResponse) {
|
|
lastTurnUserItemRef.current = null;
|
|
modPromptTurnRef.current = modPrompt !== undefined;
|
|
canUndoLastLoggedUserMessageRef.current = false;
|
|
turnSawContentEventRef.current = false;
|
|
handledToolCallFingerprintsRef.current.clear();
|
|
duplicateProviderToolCallResponseIdsRef.current.clear();
|
|
pendingDuplicateToolResponsesRef.current = [];
|
|
immediateDuplicateToolResponsesRef.current = null;
|
|
if (
|
|
submitType !== SendMessageType.Retry &&
|
|
submitType !== SendMessageType.Notification &&
|
|
submitType !== SendMessageType.Goal
|
|
) {
|
|
refreshContextFilesOnWriteRef.current = false;
|
|
}
|
|
}
|
|
|
|
const userMessageTimestamp = Date.now();
|
|
|
|
// A thrown stream can leave partial assistant runs in the dynamic
|
|
// region. An explicit Ctrl+Y retry is a fresh attempt, matching a core
|
|
// non-continuation Retry event, so discard every run from the failed
|
|
// attempt before the replacement stream starts. A different top-level
|
|
// turn preserves what the user already saw, but must commit it before
|
|
// prepareQueryForLlm appends the next user item.
|
|
if (submitType === SendMessageType.Retry) {
|
|
setPendingAssistantItems([]);
|
|
const pendingItem = pendingHistoryItemRef.current;
|
|
if (
|
|
pendingItem?.type === 'gemini' ||
|
|
pendingItem?.type === 'gemini_content'
|
|
) {
|
|
setPendingHistoryItem(null);
|
|
}
|
|
} else if (!isTurnContinuation && !allowConcurrentBtwDuringResponse) {
|
|
const pendingItem = pendingHistoryItemRef.current;
|
|
if (
|
|
pendingItem?.type === 'gemini' ||
|
|
pendingItem?.type === 'gemini_content'
|
|
) {
|
|
commitItemInOrder(pendingItem, userMessageTimestamp);
|
|
setPendingHistoryItem(null);
|
|
} else {
|
|
commitPendingAssistantItems(userMessageTimestamp);
|
|
}
|
|
}
|
|
|
|
// Reset quota error flag when starting a new query (not a continuation).
|
|
// Notifications (background agent/shell/monitor completions) are system
|
|
// events, not new user turns: they must not clear the user's model
|
|
// override or an in-flight retry countdown — clearing the override here
|
|
// silently reverted the session to the default model whenever a
|
|
// background agent finished, and a long history could then overflow the
|
|
// default model's smaller context window (#7114).
|
|
if (
|
|
!isTurnContinuation &&
|
|
submitType !== SendMessageType.Notification &&
|
|
submitType !== SendMessageType.Goal &&
|
|
!allowConcurrentBtwDuringResponse
|
|
) {
|
|
setModelSwitchedFromQuotaError(false);
|
|
// Clear model override for new user turns. On retry, preserve a
|
|
// skill-selected override so the same model is used again, but drop an
|
|
// explicit inline `/model <id> <prompt>` override: that is a one-off
|
|
// for the original prompt, so a retry reverts to the session model and
|
|
// lets skill-tool overrides apply again.
|
|
const droppingInlineOverrideOnRetry =
|
|
submitType === SendMessageType.Retry &&
|
|
inlineModelOverrideActiveRef.current;
|
|
if (
|
|
submitType !== SendMessageType.Retry ||
|
|
inlineModelOverrideActiveRef.current
|
|
) {
|
|
// The retry re-sends the same prompt on the session model, which may
|
|
// differ from the one-shot override. Tell the user so the model
|
|
// switch isn't silent.
|
|
if (droppingInlineOverrideOnRetry) {
|
|
addItem(
|
|
{
|
|
type: 'info',
|
|
text: `Inline model override cleared on retry — retrying on the session model (${config.getModel()}).`,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
clearModelOverride(modelOverrideRef, inlineModelOverrideActiveRef);
|
|
}
|
|
// Commit any pending retry error to history (without hint) since the
|
|
// user is starting a new conversation turn.
|
|
// Clear both countdown-based errors AND static errors (those without
|
|
// an active countdown timer, e.g. "Press Ctrl+Y to retry").
|
|
if (
|
|
pendingRetryCountdownItemRef.current ||
|
|
pendingRetryErrorItemRef.current
|
|
) {
|
|
const pendingError = pendingRetryErrorItemRef.current;
|
|
if (pendingError && pendingError.type === 'error') {
|
|
const { hint: _hint, ...errorWithoutHint } = pendingError;
|
|
addItem(errorWithoutHint, userMessageTimestamp);
|
|
}
|
|
clearRetryCountdown();
|
|
}
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
const abortSignal = abortController.signal;
|
|
const inheritedToolContinuationOwner = metadata?.toolContinuationOwner;
|
|
const isDetachedToolContinuation =
|
|
inheritedToolContinuationOwner?.survivesGenerationChange === true;
|
|
const detachedAbortController =
|
|
inheritedToolContinuationOwner?.detachedAbortController ??
|
|
(allowConcurrentBtwDuringResponse ? abortController : undefined);
|
|
const foregroundAbortController =
|
|
allowConcurrentBtwDuringResponse || isDetachedToolContinuation
|
|
? undefined
|
|
: abortController;
|
|
let keepToolContinuationAbortController = false;
|
|
let sharedToolBatch: SharedToolBatchHandle | undefined;
|
|
if (detachedAbortController) {
|
|
detachedToolContinuationAbortControllersRef.current.add(
|
|
detachedAbortController,
|
|
);
|
|
}
|
|
|
|
// Keep the main stream's cancellation state intact while /btw is handled
|
|
// in parallel. The side-question can use its own local abort signal.
|
|
if (!allowConcurrentBtwDuringResponse && !isDetachedToolContinuation) {
|
|
abortControllerRef.current = abortController;
|
|
turnCancelledRef.current = false;
|
|
}
|
|
|
|
if (!prompt_id) {
|
|
prompt_id = config.getSessionId() + '########' + getPromptCount();
|
|
}
|
|
if (!allowConcurrentBtwDuringResponse) {
|
|
activeInteractionPromptIdRef.current = prompt_id;
|
|
if (
|
|
submitType !== SendMessageType.ToolResult &&
|
|
submitType !== SendMessageType.Hook &&
|
|
submitType !== SendMessageType.Steer
|
|
) {
|
|
activeInteractionOwnerRef.current = undefined;
|
|
}
|
|
}
|
|
|
|
const releaseSubmissionActivity =
|
|
retainSubmissionActivity(submissionGeneration);
|
|
if (
|
|
submitType === SendMessageType.UserQuery &&
|
|
!allowConcurrentBtwDuringResponse &&
|
|
!isDetachedToolContinuation
|
|
) {
|
|
// Media preparation can upload before the model stream starts.
|
|
// Submission activity owns clearing this state on every exit path.
|
|
setIsResponding(true);
|
|
}
|
|
const submission = promptIdContext.run(prompt_id, async () => {
|
|
let queuedGoal = metadata?.goal;
|
|
let preparedQuery: {
|
|
queryToSend: PartListUnion | null;
|
|
shouldProceed: boolean;
|
|
scheduledClientTool?: {
|
|
request: ToolCallRequestInfo;
|
|
restoreResponseBinding?: RestoreToolResponseBinding;
|
|
};
|
|
promptSubmitted?: PromptSubmitted;
|
|
};
|
|
try {
|
|
preparedQuery =
|
|
submitType === SendMessageType.Goal
|
|
? queuedGoal
|
|
? {
|
|
queryToSend: renderGoalContinuationTurn(
|
|
queuedGoal,
|
|
config.getToolRegistry?.(),
|
|
),
|
|
shouldProceed: true,
|
|
}
|
|
: { queryToSend: null, shouldProceed: false }
|
|
: submitType === SendMessageType.Retry
|
|
? { queryToSend: query, shouldProceed: true }
|
|
: await prepareQueryForLlm(
|
|
query,
|
|
userMessageTimestamp,
|
|
abortSignal,
|
|
prompt_id!,
|
|
submitType,
|
|
submittedPrompt,
|
|
allowConcurrentBtwDuringResponse ||
|
|
isDetachedToolContinuation,
|
|
metadata?.shellMode,
|
|
metadata?.displayText,
|
|
metadata?.humanInput,
|
|
metadata?.pasteExpansions,
|
|
metadata?.hasUnknownInput,
|
|
() =>
|
|
streamingState === StreamingState.Idle &&
|
|
workflowConfigRef.current === config &&
|
|
!abortSignal.aborted &&
|
|
foregroundAbortController !== undefined &&
|
|
abortControllerRef.current ===
|
|
foregroundAbortController &&
|
|
submissionGeneration ===
|
|
submissionLeaseGenerationRef.current &&
|
|
activeModelStreamsRef.current === 0,
|
|
modPrompt,
|
|
);
|
|
} catch (error) {
|
|
await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey);
|
|
releaseSubmissionLease();
|
|
metadata?.onAdmissionFailed?.();
|
|
throw error;
|
|
}
|
|
const { queryToSend, shouldProceed, scheduledClientTool } =
|
|
preparedQuery;
|
|
// A rewrite from the Mods' `prompt.submit` hooks is the prompt from
|
|
// here on: what the transcript shows, what `turn.start` hears and
|
|
// what workflow keywords are read from.
|
|
const promptRewrite = preparedQuery.promptSubmitted?.rewritten;
|
|
const promptContext =
|
|
preparedQuery.promptSubmitted?.context ??
|
|
(modPrompt?.context?.length ? modPrompt.context : undefined);
|
|
const sentPrompt = promptRewrite?.display ?? submittedPrompt;
|
|
const typedInput =
|
|
promptRewrite === undefined || !metadata?.humanInput
|
|
? metadata?.humanInput
|
|
: { text: promptRewrite.text };
|
|
|
|
if (scheduledClientTool && foregroundAbortController) {
|
|
if (scheduledClientTool.restoreResponseBinding) {
|
|
scheduledClientTool.restoreResponseBinding.executionCallId =
|
|
scheduledClientTool.request.callId;
|
|
}
|
|
keepToolContinuationAbortController = true;
|
|
continuationOwnersByToolCallIdRef.current.set(
|
|
scheduledClientTool.request.callId,
|
|
{
|
|
...(scheduledClientTool.restoreResponseBinding
|
|
? {
|
|
restoreResponseBinding:
|
|
scheduledClientTool.restoreResponseBinding,
|
|
}
|
|
: {}),
|
|
promptId: prompt_id!,
|
|
signal: abortSignal,
|
|
survivesGenerationChange: false,
|
|
foregroundAbortController,
|
|
},
|
|
);
|
|
}
|
|
|
|
if (scheduledClientTool) {
|
|
registerToolBatch(scheduledClientTool.request);
|
|
scheduleToolCalls([scheduledClientTool.request], abortSignal);
|
|
}
|
|
|
|
if (!shouldProceed || queryToSend === null) {
|
|
await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey);
|
|
releaseSubmissionLease();
|
|
metadata?.onDeliveryFailed?.();
|
|
return;
|
|
}
|
|
// A slash command that reaches the model waits for the Mods as a
|
|
// typed prompt does.
|
|
if (mods && dispatchesSlashCommand && !(await modsStarted())) {
|
|
releaseSubmissionLease();
|
|
metadata?.onDeliveryFailed?.();
|
|
return;
|
|
}
|
|
|
|
await goalQueueRef?.current?.waitForReservationSettlement?.();
|
|
|
|
if (!queuedGoal && metadata?.claimGoalTurn) {
|
|
queuedGoal = metadata.claimGoalTurn();
|
|
if (!queuedGoal) {
|
|
releaseSubmissionLease();
|
|
metadata.onGoalClaimDeferred?.();
|
|
return;
|
|
}
|
|
}
|
|
|
|
let userAdmission: DirectUserAdmission | undefined;
|
|
if (submitType === SendMessageType.UserQuery) {
|
|
if (metadata?.userAdmission) {
|
|
const goal =
|
|
metadata.userAdmission.goal ??
|
|
goalQueueRef?.current?.claimGoalTurn?.();
|
|
userAdmission = {
|
|
turnKey: metadata.userAdmission.turnKey,
|
|
...(goal ? { goal } : {}),
|
|
};
|
|
} else {
|
|
userAdmission =
|
|
goalQueueRef?.current?.claimDirectUserAdmission?.() ?? {
|
|
turnKey: prompt_id!,
|
|
};
|
|
}
|
|
}
|
|
const goal = queuedGoal ?? userAdmission?.goal;
|
|
let goalBinding =
|
|
metadata?.goalBinding ??
|
|
(goal
|
|
? bindGoalTurn(
|
|
goal.permit,
|
|
goal.turnKey,
|
|
submitType === SendMessageType.UserQuery ? 'user' : 'runtime',
|
|
)
|
|
: undefined);
|
|
const turnKey = goalBinding?.turnKey ?? userAdmission?.turnKey;
|
|
const turnController =
|
|
goalBinding?.controller ??
|
|
(turnKey ? new AbortController() : undefined);
|
|
const processingSignals = [abortSignal];
|
|
if (turnController) {
|
|
processingSignals.push(turnController.signal);
|
|
}
|
|
if (inheritedToolContinuationOwner) {
|
|
processingSignals.push(inheritedToolContinuationOwner.signal);
|
|
}
|
|
const processingSignal =
|
|
processingSignals.length > 1
|
|
? AbortSignal.any(processingSignals)
|
|
: abortSignal;
|
|
const toolContinuationOwner: ToolContinuationOwner = {
|
|
promptId: prompt_id!,
|
|
signal: processingSignal,
|
|
survivesGenerationChange:
|
|
allowConcurrentBtwDuringResponse ||
|
|
inheritedToolContinuationOwner?.survivesGenerationChange === true,
|
|
detachedAbortController,
|
|
foregroundAbortController,
|
|
};
|
|
const turnAdmission =
|
|
turnKey && turnController
|
|
? {
|
|
turnKey,
|
|
controller: turnController,
|
|
origin: goalBinding?.origin ?? ('user' as const),
|
|
}
|
|
: undefined;
|
|
if (
|
|
turnAdmission &&
|
|
!goalBinding &&
|
|
submitType === SendMessageType.UserQuery &&
|
|
!allowConcurrentBtwDuringResponse &&
|
|
!activeGoalAdmissionRef.current
|
|
) {
|
|
try {
|
|
if (
|
|
config.getGoalRuntime().getSnapshot().goal?.status === 'active'
|
|
) {
|
|
activeGoalAdmissionRef.current = turnAdmission;
|
|
}
|
|
} catch {
|
|
// Goal runtime is optional during early initialization.
|
|
}
|
|
}
|
|
|
|
// Check image format support for non-continuations
|
|
if (
|
|
submitType === SendMessageType.UserQuery ||
|
|
submitType === SendMessageType.Cron ||
|
|
submitType === SendMessageType.Teammate
|
|
) {
|
|
const formatCheck = checkImageFormatsSupport(queryToSend);
|
|
if (formatCheck.hasUnsupportedFormats) {
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: getUnsupportedImageFormatWarning(),
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
}
|
|
|
|
const finalQueryToSend = queryToSend;
|
|
goalTerminalErrorRef.current = false;
|
|
if (submitType !== SendMessageType.Goal) {
|
|
lastPromptRef.current = finalQueryToSend;
|
|
lastPromptErroredRef.current = false;
|
|
}
|
|
|
|
if (
|
|
submitType === SendMessageType.UserQuery ||
|
|
submitType === SendMessageType.Cron ||
|
|
submitType === SendMessageType.Teammate
|
|
) {
|
|
// trigger new prompt event for session stats in CLI
|
|
startNewPrompt();
|
|
|
|
// log user prompt event for telemetry, only text prompts for now
|
|
if (typeof queryToSend === 'string') {
|
|
logUserPrompt(
|
|
config,
|
|
new UserPromptEvent(
|
|
queryToSend.length,
|
|
prompt_id,
|
|
config.getContentGeneratorConfig()?.authType,
|
|
queryToSend,
|
|
modelOverrideRef.current ?? config.getModel(),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Reset thought when starting a new prompt
|
|
setThought(null);
|
|
setPendingThoughtItem(null);
|
|
}
|
|
|
|
if (submitType === SendMessageType.Retry) {
|
|
logUserRetry(config, new UserRetryEvent(prompt_id));
|
|
}
|
|
|
|
activeModelStreamsRef.current += 1;
|
|
setIsResponding(true);
|
|
setInitError(null);
|
|
// Entering "requesting" phase — no content yet for this API call.
|
|
setIsReceivingContent(false);
|
|
// Reset char counter only on new user queries; tool-result continuations
|
|
// keep accumulating so the token count only goes up within a turn.
|
|
if (!isTurnContinuation) {
|
|
streamingResponseLengthRef.current = 0;
|
|
}
|
|
|
|
let cleanupReviewLease = false;
|
|
let keepGoalBinding = false;
|
|
try {
|
|
// Emit user message to dual output sidecar (if enabled).
|
|
// Skip for tool-result submissions — those are emitted separately
|
|
// when the tool completes.
|
|
if (
|
|
dualOutput &&
|
|
submitType !== SendMessageType.ToolResult &&
|
|
submitType !== SendMessageType.Goal
|
|
) {
|
|
// A Mod's prompt reaches the sidecar as in a headless run: its
|
|
// text, not the frame the model reads, then who submitted it.
|
|
if (modPrompt) {
|
|
dualOutput.emitUserMessage([{ text: modPrompt.text }]);
|
|
dualOutput.emitSystemMessage('mod_prompt', {
|
|
mod: modPrompt.plugin,
|
|
});
|
|
} else {
|
|
const rawParts =
|
|
typeof finalQueryToSend === 'string'
|
|
? [finalQueryToSend]
|
|
: Array.isArray(finalQueryToSend)
|
|
? finalQueryToSend
|
|
: [finalQueryToSend];
|
|
const userParts: Part[] = rawParts.map((p) =>
|
|
typeof p === 'string' ? { text: p } : p,
|
|
);
|
|
dualOutput.emitUserMessage(userParts);
|
|
}
|
|
}
|
|
|
|
const workflowSessionId = config.getSessionId();
|
|
const workflowModeEligible =
|
|
submitType === SendMessageType.UserQuery &&
|
|
!modPrompt &&
|
|
!allowConcurrentBtwDuringResponse &&
|
|
!isDetachedToolContinuation &&
|
|
!(metadata?.shellMode ?? shellModeActive) &&
|
|
!dispatchesSlashCommand &&
|
|
!(typeof query === 'string' && isBtwCommand(query));
|
|
const humanInput = workflowModeEligible ? typedInput : undefined;
|
|
// A prompt the person submitted or retried is a turn the Mods hear.
|
|
const startsModTurn =
|
|
mods !== undefined &&
|
|
!isTurnContinuation &&
|
|
!allowConcurrentBtwDuringResponse &&
|
|
!isDetachedToolContinuation &&
|
|
(submitType === SendMessageType.UserQuery ||
|
|
submitType === SendMessageType.Retry);
|
|
const sendOptions = {
|
|
type: submitType,
|
|
...(workflowModeEligible ? { workflowModeEligible: true } : {}),
|
|
...(startsModTurn
|
|
? {
|
|
onPromptAdmitted: () => {
|
|
// One the stream never left idle for ends first.
|
|
finishModTurn();
|
|
// Each tool round has its own controller; this one
|
|
// spans the turn.
|
|
const cancel = new AbortController();
|
|
// A retry has no typed prompt. A prompt whose composer
|
|
// provenance was lost is heard as it is sent.
|
|
const turn = mods?.startTurn(
|
|
submitType === SendMessageType.Retry
|
|
? ''
|
|
: (typedInput?.text ??
|
|
promptRewrite?.text ??
|
|
sentPrompt ??
|
|
(typeof query === 'string' ? query : '')),
|
|
AbortSignal.any([abortSignal, cancel.signal]),
|
|
() => cancelForModRef.current(),
|
|
prompt_id,
|
|
);
|
|
if (!turn) return;
|
|
modTurnRef.current = { turn, promptId: prompt_id!, cancel };
|
|
setModTurnId(turn.id);
|
|
},
|
|
}
|
|
: {}),
|
|
notificationDisplayText: metadata?.notificationDisplayText,
|
|
todoWorkChainId: metadata?.todoWorkChainId,
|
|
modelOverride: modelOverrideRef.current,
|
|
steerInput: metadata?.steerInput,
|
|
...(allowConcurrentBtwDuringResponse
|
|
? { isConcurrentSideQuery: true }
|
|
: {}),
|
|
...(sentPrompt !== undefined
|
|
? { submittedPrompt: sentPrompt }
|
|
: {}),
|
|
...(promptContext ? { promptContext } : {}),
|
|
...(modPrompt
|
|
? {
|
|
submittedByMod: modPrompt.plugin,
|
|
...(modPrompt.asUser && { submittedAsUser: true as const }),
|
|
// Recall looks for what the Mod asked, not the frame.
|
|
memoryRecallQuery: modPrompt.text,
|
|
}
|
|
: {}),
|
|
...(humanInput === undefined
|
|
? {}
|
|
: {
|
|
humanInput,
|
|
onWorkflowIntentApplied: () => {
|
|
if (
|
|
workflowConfigRef.current === config &&
|
|
!abortSignal.aborted &&
|
|
foregroundAbortController !== undefined &&
|
|
abortControllerRef.current ===
|
|
foregroundAbortController &&
|
|
submissionGeneration ===
|
|
submissionLeaseGenerationRef.current &&
|
|
config.getSessionId() === workflowSessionId
|
|
) {
|
|
setWorkflowKeywordActive(true);
|
|
}
|
|
},
|
|
}),
|
|
...(!allowConcurrentBtwDuringResponse &&
|
|
!isDetachedToolContinuation &&
|
|
midTurnDrainRef
|
|
? { getSteerInput: drainSteerAtBoundary }
|
|
: {}),
|
|
};
|
|
if (submitType === SendMessageType.Goal && goalBinding) {
|
|
try {
|
|
config.getGoalRuntime().markTurnDelivered(goalBinding.turnKey);
|
|
} catch {
|
|
// Goal runtime is optional during early initialization.
|
|
}
|
|
}
|
|
const providerSignal = inheritedToolContinuationOwner
|
|
? processingSignal
|
|
: abortSignal;
|
|
const roundOptions: SendMessageOptions = {
|
|
...sendOptions,
|
|
...(goalBinding
|
|
? {
|
|
goalPermit: goalBinding.permit,
|
|
goalTurnKey: goalBinding.turnKey,
|
|
goalSignal: goalBinding.controller.signal,
|
|
goalOrigin: goalBinding.origin,
|
|
getQueuedGoalTurnKey: () =>
|
|
goalQueueRef?.current?.peekNextUserBatchKey(true),
|
|
}
|
|
: userAdmission
|
|
? {
|
|
goalTurnKey: userAdmission.turnKey,
|
|
goalSignal: turnController!.signal,
|
|
goalOrigin: 'user' as const,
|
|
getQueuedGoalTurnKey: () =>
|
|
goalQueueRef?.current?.peekNextUserBatchKey(true),
|
|
}
|
|
: {}),
|
|
};
|
|
const source = config.isUnifiedAgentLoopEnabled?.()
|
|
? {
|
|
client: llmClient,
|
|
signal: providerSignal,
|
|
round: {
|
|
parts: finalQueryToSend,
|
|
promptId: prompt_id!,
|
|
options: roundOptions,
|
|
},
|
|
}
|
|
: llmClient.sendMessageStream(
|
|
finalQueryToSend,
|
|
providerSignal,
|
|
prompt_id!,
|
|
roundOptions,
|
|
);
|
|
metadata?.onRequestStarted?.();
|
|
|
|
const processingResult = await processLlmStreamEvents(
|
|
source,
|
|
userMessageTimestamp,
|
|
processingSignal,
|
|
submitType,
|
|
turnAdmission,
|
|
prompt_id,
|
|
!allowConcurrentBtwDuringResponse,
|
|
toolContinuationOwner,
|
|
);
|
|
if (
|
|
!goalBinding &&
|
|
turnAdmission &&
|
|
activeGoalTurnRef.current?.controller ===
|
|
turnAdmission.controller &&
|
|
activeGoalTurnRef.current.turnKey === turnAdmission.turnKey
|
|
) {
|
|
goalBinding = activeGoalTurnRef.current;
|
|
}
|
|
keepGoalBinding = processingResult.scheduledToolContinuation;
|
|
keepToolContinuationAbortController =
|
|
processingResult.scheduledToolContinuation;
|
|
sharedToolBatch = processingResult.sharedToolBatch;
|
|
|
|
if (
|
|
processingResult.status === StreamProcessingStatus.UserCancelled
|
|
) {
|
|
cleanupReviewLease = true;
|
|
submitPromptOnCompleteRef.current = null;
|
|
// A Goal preemption cancels the turn without an Esc.
|
|
recordModTurnEnding(modTurnRef.current, prompt_id, 'aborted');
|
|
metadata?.onDeliveryFailed?.();
|
|
return;
|
|
}
|
|
|
|
if (pendingHistoryItemRef.current) {
|
|
commitItemInOrder(
|
|
pendingHistoryItemRef.current,
|
|
userMessageTimestamp,
|
|
);
|
|
setPendingHistoryItem(null);
|
|
}
|
|
|
|
const immediateDuplicateToolResponses =
|
|
immediateDuplicateToolResponsesRef.current;
|
|
if (immediateDuplicateToolResponses) {
|
|
immediateDuplicateToolResponsesRef.current = null;
|
|
const finalized = await finalizeToolResponses(
|
|
config,
|
|
immediateDuplicateToolResponses.responses.map(
|
|
({ request, response }) => ({
|
|
callId: request.callId,
|
|
toolName: request.name,
|
|
responseParts: response.responseParts,
|
|
persistedOutputFiles: response.persistedOutputFiles,
|
|
artifacts: response.artifacts,
|
|
}),
|
|
),
|
|
new Map(
|
|
immediateDuplicateToolResponses.responses.flatMap(
|
|
({ request }) => {
|
|
const promptId =
|
|
request.prompt_id ??
|
|
immediateDuplicateToolResponses.promptId;
|
|
return promptId
|
|
? [[request.callId, promptId] as const]
|
|
: [];
|
|
},
|
|
),
|
|
),
|
|
);
|
|
const responseParts = finalized.flatMap(
|
|
(entry) => entry.responseParts,
|
|
);
|
|
immediateDuplicateToolResponses.responses.forEach(
|
|
({ request, response }, index) => {
|
|
config.getChatRecordingService?.()?.recordToolResult?.(
|
|
finalized[index].responseParts,
|
|
{
|
|
callId: request.callId,
|
|
status: response.error ? 'error' : 'success',
|
|
resultDisplay: response.resultDisplay,
|
|
persistedOutputFiles: finalized[index].persistedOutputFiles,
|
|
artifacts: finalized[index].artifacts,
|
|
error: response.error,
|
|
errorType: response.errorType,
|
|
executionStatus: response.executionStatus,
|
|
},
|
|
goalToolResultProvenance(
|
|
request,
|
|
finalized[index].responseParts,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
await submitQuery(
|
|
responseParts,
|
|
SendMessageType.ToolResult,
|
|
immediateDuplicateToolResponses.promptId,
|
|
{ goalBinding },
|
|
);
|
|
if (
|
|
goalBinding &&
|
|
!turnCancelledRef.current &&
|
|
!abortControllerRef.current?.signal.aborted &&
|
|
goalTurnBindingsRef.current.get(goalBinding.permit.turnId) ===
|
|
goalBinding &&
|
|
!goalBinding.controller.signal.aborted
|
|
) {
|
|
keepGoalBinding = true;
|
|
}
|
|
}
|
|
// Only clear auto-retry countdown errors (those with an active timer).
|
|
// Do NOT clear static error+hint from handleErrorEvent — those should
|
|
// remain visible until the user presses Ctrl+Y to retry or starts
|
|
// a new conversation turn (cleared in submitQuery).
|
|
if (retryCountdownTimerRef.current) {
|
|
clearRetryCountdown();
|
|
} else if (
|
|
pendingRetryErrorItemRef.current &&
|
|
!lastPromptErroredRef.current &&
|
|
!goalTerminalErrorRef.current
|
|
) {
|
|
// A countdown-originated error item lingers after the timer
|
|
// expired and the retry succeeded. Clear it so it does not
|
|
// stay on screen. Terminal errors (handleErrorEvent) set
|
|
// lastPromptErroredRef (or goalTerminalErrorRef for Goal turns)
|
|
// and are intentionally left visible.
|
|
clearRetryCountdown();
|
|
}
|
|
const loopDetected = loopDetectedRef.current;
|
|
if (loopDetected) {
|
|
recordModTurnEnding(modTurnRef.current, prompt_id, 'error');
|
|
cleanupReviewLease = true;
|
|
loopDetectedRef.current = false;
|
|
const loopStop = loopStopRef.current;
|
|
loopStopRef.current = undefined;
|
|
handleLoopDetectedEvent(loopStop);
|
|
}
|
|
|
|
if (
|
|
lastPromptErroredRef.current ||
|
|
goalTerminalErrorRef.current ||
|
|
processingResult.userPromptBlocked
|
|
) {
|
|
metadata?.onDeliveryFailed?.();
|
|
} else {
|
|
metadata?.onDelivered?.();
|
|
}
|
|
|
|
// If the turn was initiated by a submit_prompt with an onComplete
|
|
// callback (e.g. /dream recording lastDreamAt), fire it now.
|
|
const onComplete = submitPromptOnCompleteRef.current;
|
|
if (onComplete) {
|
|
submitPromptOnCompleteRef.current = null;
|
|
void onComplete().catch((err) => {
|
|
debugLogger.error('onComplete callback failed:', err);
|
|
});
|
|
}
|
|
|
|
// After the turn completes, wire up notifications for any background
|
|
// dream / extraction tasks that were kicked off by the client.
|
|
if (llmClient) {
|
|
const memoryTaskPromises =
|
|
llmClient.consumePendingMemoryTaskPromises();
|
|
for (const p of memoryTaskPromises) {
|
|
void p.then((count) => {
|
|
if (count > 0) {
|
|
addItem(
|
|
{
|
|
type: 'memory_saved',
|
|
writtenCount: count,
|
|
verb: 'Updated',
|
|
} as HistoryItemWithoutId,
|
|
Date.now(),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (error: unknown) {
|
|
cleanupReviewLease = true;
|
|
// Before the delivery callback, which can record an error.
|
|
if (!allowConcurrentBtwDuringResponse)
|
|
recordModTurnEnding(
|
|
modTurnRef.current,
|
|
prompt_id,
|
|
isNodeError(error) && error.name === 'AbortError'
|
|
? 'aborted'
|
|
: 'error',
|
|
);
|
|
metadata?.onDeliveryFailed?.();
|
|
if (error instanceof UnauthorizedError) {
|
|
onAuthError('Session expired or is unauthorized.');
|
|
} else if (!isNodeError(error) || error.name !== 'AbortError') {
|
|
if (submitType !== SendMessageType.Goal) {
|
|
lastPromptErroredRef.current = true;
|
|
}
|
|
const retryHint =
|
|
submitType !== SendMessageType.Goal
|
|
? t('Press Ctrl+Y to retry')
|
|
: undefined;
|
|
// Store error with hint as a pending item (same as handleErrorEvent)
|
|
setPendingRetryErrorItem({
|
|
type: 'error' as const,
|
|
text: parseAndFormatApiError(
|
|
error ?? 'Unknown error',
|
|
config.getContentGeneratorConfig()?.authType,
|
|
t,
|
|
),
|
|
hint: retryHint,
|
|
});
|
|
}
|
|
} finally {
|
|
if (cleanupReviewLease && !config.getShellExecutionSandbox?.()) {
|
|
cleanupReviewWorktreeLeases({
|
|
sessionId: config.getSessionId(),
|
|
promptId: prompt_id!,
|
|
repositoryRoot: config.getProjectRoot(),
|
|
});
|
|
}
|
|
submitPromptOnCompleteRef.current = null;
|
|
activeModelStreamsRef.current = Math.max(
|
|
0,
|
|
activeModelStreamsRef.current - 1,
|
|
);
|
|
const shouldDrainCompletedToolBatches =
|
|
activeModelStreamsRef.current === 0;
|
|
if (goalBinding) {
|
|
let retainGoalBinding =
|
|
keepGoalBinding && !goalBinding.controller.signal.aborted;
|
|
if (retainGoalBinding) {
|
|
try {
|
|
const currentPermit = config
|
|
.getGoalRuntime()
|
|
.permitForTurn(goalBinding.turnKey);
|
|
retainGoalBinding =
|
|
currentPermit !== undefined &&
|
|
sameGoalPermit(currentPermit, goalBinding.permit);
|
|
} catch {
|
|
// Tests and early initialization may not expose a ready runtime.
|
|
}
|
|
}
|
|
if (!retainGoalBinding) {
|
|
await failClosedGoalTurn(
|
|
goalBinding,
|
|
'Goal turn ended without a valid continuation',
|
|
{ userCancelled: turnCancelledRef.current },
|
|
);
|
|
}
|
|
}
|
|
if (
|
|
turnAdmission &&
|
|
!goalBinding &&
|
|
activeGoalAdmissionRef.current?.controller ===
|
|
turnAdmission.controller &&
|
|
activeGoalAdmissionRef.current.turnKey === turnAdmission.turnKey
|
|
) {
|
|
activeGoalAdmissionRef.current = null;
|
|
}
|
|
// A stream that started since leaves the batches to its own drain.
|
|
if (
|
|
shouldDrainCompletedToolBatches &&
|
|
activeModelStreamsRef.current === 0
|
|
) {
|
|
const resumed = modelStreamsIdleWaitersRef.current.splice(0);
|
|
if (resumed.length > 0) {
|
|
completedToolBatchDrainHoldsRef.current += 1;
|
|
try {
|
|
// One at a time, in the order they settled, so their
|
|
// results join the queue, and go out, in that order.
|
|
for (const { resume, done } of resumed) {
|
|
resume();
|
|
await done;
|
|
}
|
|
} finally {
|
|
completedToolBatchDrainHoldsRef.current -= 1;
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
shouldDrainCompletedToolBatches &&
|
|
activeModelStreamsRef.current === 0
|
|
) {
|
|
const pendingCompletedToolBatches =
|
|
pendingCompletedToolBatchesRef.current.splice(0);
|
|
const pendingCompletedTools = new Map<string, TrackedToolCall>();
|
|
let pendingShared: SharedToolBatchResults | undefined;
|
|
for (const { tools, shared } of pendingCompletedToolBatches) {
|
|
for (const toolCall of tools) {
|
|
pendingCompletedTools.set(toolCall.request.callId, toolCall);
|
|
}
|
|
// The shared batches' results go out in the same continuation,
|
|
// one per call id as the calls above.
|
|
if (shared) {
|
|
pendingShared = pendingShared
|
|
? {
|
|
responses: [
|
|
...new Map(
|
|
[...pendingShared.responses, ...shared.responses].map(
|
|
(entry) => [entry.callId, entry],
|
|
),
|
|
).values(),
|
|
],
|
|
ranCallIds: new Set([
|
|
...pendingShared.ranCallIds,
|
|
...shared.ranCallIds,
|
|
]),
|
|
promptId: pendingShared.promptId ?? shared.promptId,
|
|
// The earliest batch's session: results of a session
|
|
// swapped away never go out in its successor.
|
|
owner: pendingShared.owner,
|
|
backgroundLaunchCallIds: new Set([
|
|
...pendingShared.backgroundLaunchCallIds,
|
|
...shared.backgroundLaunchCallIds,
|
|
]),
|
|
}
|
|
: shared;
|
|
}
|
|
}
|
|
if (pendingCompletedTools.size > 0) {
|
|
const nextRound = await handleCompletedToolsRef.current(
|
|
[...pendingCompletedTools.values()],
|
|
pendingShared,
|
|
);
|
|
await nextRound?.();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return submission
|
|
.finally(() => {
|
|
releaseSubmissionActivity();
|
|
if (detachedAbortController && !keepToolContinuationAbortController) {
|
|
detachedToolContinuationAbortControllersRef.current.delete(
|
|
detachedAbortController,
|
|
);
|
|
}
|
|
if (
|
|
foregroundAbortController &&
|
|
!keepToolContinuationAbortController &&
|
|
abortControllerRef.current === foregroundAbortController
|
|
) {
|
|
abortControllerRef.current = null;
|
|
}
|
|
})
|
|
.finally(async () => {
|
|
if (!sharedToolBatch) return;
|
|
// The turn awaits its shared batch once its own stream and
|
|
// activity ended, and sends the next round the batch's completion
|
|
// step decided. While another stream is active, which may be the
|
|
// stream that sent this round and waits for it, the turn does not
|
|
// wait: the batch waits for that stream to end and sends its next
|
|
// round itself.
|
|
const nextRound =
|
|
activeModelStreamsRef.current === 0
|
|
? await sharedToolBatch.done
|
|
: sharedToolBatch.leave();
|
|
await nextRound?.();
|
|
});
|
|
},
|
|
[
|
|
streamingState,
|
|
shellModeActive,
|
|
setModelSwitchedFromQuotaError,
|
|
prepareQueryForLlm,
|
|
registerToolBatch,
|
|
scheduleToolCalls,
|
|
processLlmStreamEvents,
|
|
pendingHistoryItemRef,
|
|
addItem,
|
|
commitPendingAssistantItems,
|
|
commitItemInOrder,
|
|
setPendingAssistantItems,
|
|
setPendingHistoryItem,
|
|
setInitError,
|
|
llmClient,
|
|
onAuthError,
|
|
config,
|
|
startNewPrompt,
|
|
getPromptCount,
|
|
handleLoopDetectedEvent,
|
|
clearRetryCountdown,
|
|
pendingRetryCountdownItemRef,
|
|
pendingRetryErrorItemRef,
|
|
setPendingRetryErrorItem,
|
|
setPendingThoughtItem,
|
|
dualOutput,
|
|
drainSteerAtBoundary,
|
|
midTurnDrainRef,
|
|
goalQueueRef,
|
|
bindGoalTurn,
|
|
failClosedGoalTurn,
|
|
releaseUndeliveredGoalTurn,
|
|
retainSubmissionActivity,
|
|
setSubmissionInFlight,
|
|
finishModTurn,
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Remove trailing parts from `lastPromptRef` whose texts match the given
|
|
* envelope texts in order. Shared by the boundary settlement and the
|
|
* Ctrl+Y retry carrier, which both need to un-bake reattached envelopes
|
|
* from the stored retry payload. No-op unless the stored payload is an
|
|
* array actually ending with those parts (a later submission may have
|
|
* overwritten it).
|
|
*/
|
|
const stripTrailingTextsFromLastPrompt = useCallback((texts: string[]) => {
|
|
if (texts.length === 0) return;
|
|
const lastPrompt = lastPromptRef.current;
|
|
if (!Array.isArray(lastPrompt)) return;
|
|
const cut = lastPrompt.length - texts.length;
|
|
if (
|
|
cut >= 0 &&
|
|
texts.every((text, i) => {
|
|
const part = lastPrompt[cut + i];
|
|
return (
|
|
typeof part === 'object' &&
|
|
part !== null &&
|
|
'text' in part &&
|
|
part.text === text
|
|
);
|
|
})
|
|
) {
|
|
lastPromptRef.current = cut > 0 ? lastPrompt.slice(0, cut) : null;
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Identity fingerprint for envelope retry debt: capture the pushed
|
|
* history entry carrying these envelope parts (accept fires after the
|
|
* push landed). The youngest entry containing every envelope text is the
|
|
* one just pushed — a concurrent push can only displace the scan when it
|
|
* carries byte-identical envelope texts, which the fingerprint's
|
|
* tool-response parts then still distinguish at retry time. History
|
|
* unreadable ⇒ fall back to the envelope parts alone (the
|
|
* pre-fingerprint containment match).
|
|
*/
|
|
const capturePushedTeammateEntry = useCallback(
|
|
(envelopeParts: Part[]): Part[] => {
|
|
try {
|
|
const history = llmClient?.getHistoryShallow?.() ?? [];
|
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
const candidate = history[i]?.parts ?? [];
|
|
if (
|
|
envelopeParts.every((part) =>
|
|
candidate.some((p) => p.text === part.text),
|
|
)
|
|
) {
|
|
return candidate;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
debugLogger.warn(
|
|
`Failed to capture pushed teammate entry for retry debt: ${error}`,
|
|
);
|
|
}
|
|
return envelopeParts;
|
|
},
|
|
[llmClient],
|
|
);
|
|
|
|
/**
|
|
* Re-attach accepted-boundary envelope parts to a Ctrl+Y retry payload
|
|
* when the history entry carrying them is about to be popped by the
|
|
* Retry path.
|
|
*
|
|
* Settlement stripped those parts from `lastPromptRef` on accept because
|
|
* the push put them in the session history. But an accepted round can
|
|
* still fail terminally BEFORE producing content (e.g. a 503 after
|
|
* exhausted retries), leaving the pushed entry as a trailing orphan that
|
|
* `sendMessageStream` pops for a Retry before re-pushing the stored
|
|
* payload — and a landing push suppresses `restoreStrippedRetryEntries`.
|
|
* Re-sending the payload without the envelopes would then silently lose
|
|
* them while the delivery journal claims delivered.
|
|
*
|
|
* Mirror the pop's walk (`GeminiChat.stripOrphanedUserEntriesFromHistory`):
|
|
* trailing user entries, stopping at the first model entry or a *pure*
|
|
* system-reminder entry (which the pop preserves). A debt batch is
|
|
* re-appended only when its pushed entry is one of those trailing
|
|
* orphans — exactly the case where the pop is about to drop it. If the
|
|
* accepted round produced content instead, the entry is not a trailing
|
|
* orphan, nothing matches, and the payload stays stripped so the leader
|
|
* does not see the same report twice.
|
|
*
|
|
* The match keys on the debt record's `pushedEntryParts` fingerprint
|
|
* (the pushed entry captured at accept time), not on envelope text
|
|
* alone: teammate envelopes are deterministic machine text, and a
|
|
* byte-identical resend can orphan a YOUNGER entry while the debt's own
|
|
* entry sits mid-history. The fingerprint's tool-response parts (unique
|
|
* callIds) keep a colliding younger entry from claiming the debt.
|
|
*
|
|
* String payloads are retried too (Idle Teammate/Notification drains and
|
|
* plain user prompts store strings in `lastPromptRef`), so debt is
|
|
* evaluated for ANY payload shape; a string query is wrapped into its
|
|
* single text part only when something is actually re-attached.
|
|
*
|
|
* Returns the (possibly extended) query plus the CONSUMED debt records —
|
|
* the ones whose pushed entry matched a trailing orphan and whose
|
|
* envelopes were re-attached. `retryLastPrompt` transfers those records
|
|
* into a settlement carrier on the retry's own submission so the
|
|
* protection follows the envelopes into the retry's re-pushed entry;
|
|
* the debt ref itself is cleared here, which is safe because the call
|
|
* site runs only after the submission admission gate is known to pass
|
|
* (a gate-rejected retry must not discard debt). Unmatched records are
|
|
* dropped: their pushed entry is no longer a trailing orphan, so the
|
|
* pop can never drop it and its protection expires. When the history
|
|
* scan fails the debt stays untouched so a later retry can still
|
|
* evaluate it.
|
|
*/
|
|
const reattachOrphanedRetryEnvelopes = useCallback(
|
|
(
|
|
query: PartListUnion,
|
|
): {
|
|
query: PartListUnion;
|
|
consumed: Array<{ envelopeParts: Part[]; pushedEntryParts: Part[] }>;
|
|
} => {
|
|
const debt = boundaryEnvelopeRetryDebtRef.current;
|
|
if (debt.length === 0) {
|
|
return { query, consumed: [] };
|
|
}
|
|
const samePart = (a: Part, b: Part): boolean => {
|
|
if (typeof a.text === 'string' || typeof b.text === 'string') {
|
|
return typeof a.text === 'string' && a.text === b.text;
|
|
}
|
|
try {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
// Contiguous-subsequence match: the fingerprint is the pushed entry
|
|
// as captured at accept time; the candidate is an orphan entry the
|
|
// pop is about to drop. Subsequence (not full-array equality) keeps
|
|
// the match tolerant of parts core appends around the fingerprint
|
|
// (e.g. plan-exit notices) and degrades to an envelope-text
|
|
// containment match when accept-time history was unreadable and the
|
|
// fingerprint fell back to the envelope parts only.
|
|
const entryCarriesFingerprint = (
|
|
candidate: Part[],
|
|
fingerprint: Part[],
|
|
): boolean => {
|
|
if (fingerprint.length === 0 || candidate.length < fingerprint.length) {
|
|
return false;
|
|
}
|
|
for (
|
|
let start = 0;
|
|
start + fingerprint.length <= candidate.length;
|
|
start++
|
|
) {
|
|
if (
|
|
fingerprint.every((part, offset) =>
|
|
samePart(part, candidate[start + offset]!),
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
const orphanedEntries: Part[][] = [];
|
|
try {
|
|
const history =
|
|
llmClient?.getChat?.()?.getHistoryForRecovery?.() ??
|
|
llmClient?.getHistoryShallow?.() ??
|
|
[];
|
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
const entry = history[i];
|
|
if (!entry || entry.role !== 'user') break;
|
|
const parts = entry.parts ?? [];
|
|
// Structural-guard mirror of core's `isSystemReminderContent`:
|
|
// a pure system-reminder entry terminates the pop, so nothing
|
|
// behind it is orphaned.
|
|
const pureSystemReminder =
|
|
parts.length > 0 &&
|
|
parts.every(
|
|
(part) =>
|
|
typeof part.text === 'string' &&
|
|
part.text.startsWith('<system-reminder>') &&
|
|
part.text.trimEnd().endsWith('</system-reminder>'),
|
|
);
|
|
if (pureSystemReminder) break;
|
|
orphanedEntries.push(parts);
|
|
}
|
|
} catch (error) {
|
|
// History unavailable: keep the stripped payload rather than fail
|
|
// the retry, and leave the debt untouched so a later retry can
|
|
// still evaluate it.
|
|
debugLogger.warn(
|
|
`Failed to scan history for orphaned teammate envelopes: ${error}`,
|
|
);
|
|
return { query, consumed: [] };
|
|
}
|
|
// Consume the debt now: the caller has already verified that the
|
|
// submission admission gate will pass, so the only outcomes are a
|
|
// landing push (the consumed records transfer into the retry's
|
|
// settlement carrier) or a pre-push exit (the carrier restores the
|
|
// records). Either way no protection is dropped on the floor.
|
|
boundaryEnvelopeRetryDebtRef.current = [];
|
|
const consumed: Array<{
|
|
envelopeParts: Part[];
|
|
pushedEntryParts: Part[];
|
|
}> = [];
|
|
const reattach: Part[] = [];
|
|
for (const record of debt) {
|
|
if (
|
|
orphanedEntries.some((parts) =>
|
|
entryCarriesFingerprint(parts, record.pushedEntryParts),
|
|
)
|
|
) {
|
|
reattach.push(...record.envelopeParts);
|
|
consumed.push(record);
|
|
}
|
|
// Unmatched records expire: their pushed entry is no longer a
|
|
// trailing orphan (model content landed after it, or it is gone),
|
|
// so the Retry path's pop can never drop it.
|
|
}
|
|
if (reattach.length === 0) {
|
|
return { query, consumed };
|
|
}
|
|
// `query` may be a string or contain string parts (Idle Teammate/
|
|
// Notification drains and plain prompts); normalize to Part[] only
|
|
// when something is actually re-attached.
|
|
const base: Part[] = (Array.isArray(query) ? query : [query]).map(
|
|
(part) => (typeof part === 'string' ? { text: part } : part),
|
|
);
|
|
return { query: [...base, ...reattach], consumed };
|
|
},
|
|
[llmClient],
|
|
);
|
|
|
|
/**
|
|
* Retries the last failed prompt when the user presses Ctrl+Y.
|
|
*
|
|
* Activation conditions for Ctrl+Y shortcut:
|
|
* 1. ✓ The last request must have failed (lastPromptErroredRef.current === true)
|
|
* 2. ✓ Current streaming state must NOT be "Responding" (avoid interrupting ongoing stream)
|
|
* 3. ✓ Current streaming state must NOT be "WaitingForConfirmation" (avoid conflicting with tool confirmation flow)
|
|
* 4. ✓ There must be a stored lastPrompt in lastPromptRef.current
|
|
*
|
|
* When conditions are not met:
|
|
* - If streaming is active (Responding/WaitingForConfirmation): silently return without action
|
|
* - If no failed request exists: display "No failed request to retry." info message
|
|
*
|
|
* When conditions are met:
|
|
* - Clears any pending auto-retry countdown to avoid duplicate retries
|
|
* - Re-submits the last query with isRetry: true, reusing the same prompt_id
|
|
*
|
|
* This function is exposed via UIActionsContext and triggered by InputPrompt
|
|
* when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts).
|
|
*/
|
|
const retryLastPrompt = useCallback(async () => {
|
|
// During a rate-limit retry countdown, skip the delay so the generator
|
|
// retries immediately — no abort/re-submit needed.
|
|
if (skipRetryDelayRef.current) {
|
|
skipRetryDelayRef.current();
|
|
skipRetryDelayRef.current = null;
|
|
clearRetryCountdown();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
streamingState === StreamingState.Responding ||
|
|
streamingState === StreamingState.WaitingForConfirmation
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Admission-gate pre-check. The debt evaluation below CONSUMES the
|
|
// retry-debt records, but `submitQuery` early-returns at its admission
|
|
// gate when a submission is already in flight — a Retry is never a
|
|
// turn continuation nor a concurrent /btw, so for this submit type the
|
|
// gate rejects exactly when `isSubmittingQueryRef` is set. Consuming
|
|
// the debt before that gate (as argument evaluation) would permanently
|
|
// discard it for a lease-rejected Ctrl+Y; bail first and keep the debt
|
|
// for the next attempt. The check is synchronous with the gate inside
|
|
// `submitQuery` (no await in between), so nothing can flip the lease
|
|
// in the window.
|
|
if (isSubmittingQueryRef.current) {
|
|
return;
|
|
}
|
|
|
|
const lastPrompt = lastPromptRef.current;
|
|
if (!lastPrompt || !lastPromptErroredRef.current) {
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: t('No failed request to retry.'),
|
|
},
|
|
Date.now(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
clearRetryCountdown();
|
|
|
|
const { query: retryQuery, consumed } =
|
|
reattachOrphanedRetryEnvelopes(lastPrompt);
|
|
// The re-attached envelopes are baked into THIS retry's push. Without
|
|
// protecting the retry's own entry the same loss shape repeats one
|
|
// retry later: the retry can also fail terminally before content, and
|
|
// a later retry of a DIFFERENT payload then pops the retry's orphaned
|
|
// entry — dropping the envelopes while the journal claims delivered.
|
|
// The attached carrier is settled by GeminiClient right next to the
|
|
// push (the same protocol the boundary settlement uses): accept
|
|
// records debt for the retry's re-pushed entry; restore re-records
|
|
// the consumed records under their original fingerprints after
|
|
// stripping the envelopes back out of `lastPromptRef` (core re-adds
|
|
// popped orphan entries as-is when the push never landed, so the
|
|
// original fingerprints stay valid, while the stored retry payload
|
|
// must not carry the envelopes twice).
|
|
const retryEnvelopeSettlement: SteerInput | undefined =
|
|
consumed.length === 0
|
|
? undefined
|
|
: {
|
|
parts: [],
|
|
accept: () => {
|
|
// Mirror the boundary settlement and this carrier's own
|
|
// restore: the re-attached envelopes landed in the session
|
|
// history with the retry's push, so un-bake them from the
|
|
// stored payload before re-recording debt. Without the
|
|
// strip, each accept→fail-before-content→Ctrl+Y cycle
|
|
// re-attaches the envelopes onto a base that still carries
|
|
// them, appending one duplicate copy per cycle.
|
|
stripTrailingTextsFromLastPrompt(
|
|
consumed.flatMap((record) =>
|
|
record.envelopeParts.map((part) => part.text ?? ''),
|
|
),
|
|
);
|
|
for (const record of consumed) {
|
|
boundaryEnvelopeRetryDebtRef.current.push({
|
|
envelopeParts: record.envelopeParts,
|
|
pushedEntryParts: capturePushedTeammateEntry(
|
|
record.envelopeParts,
|
|
),
|
|
});
|
|
}
|
|
},
|
|
restore: () => {
|
|
stripTrailingTextsFromLastPrompt(
|
|
consumed.flatMap((record) =>
|
|
record.envelopeParts.map((part) => part.text ?? ''),
|
|
),
|
|
);
|
|
for (const record of consumed) {
|
|
boundaryEnvelopeRetryDebtRef.current.push(record);
|
|
}
|
|
},
|
|
};
|
|
await submitQuery(
|
|
retryQuery,
|
|
SendMessageType.Retry,
|
|
undefined,
|
|
retryEnvelopeSettlement
|
|
? { steerInput: retryEnvelopeSettlement }
|
|
: undefined,
|
|
);
|
|
}, [
|
|
streamingState,
|
|
addItem,
|
|
clearRetryCountdown,
|
|
submitQuery,
|
|
reattachOrphanedRetryEnvelopes,
|
|
capturePushedTeammateEntry,
|
|
stripTrailingTextsFromLastPrompt,
|
|
]);
|
|
|
|
const preemptGoalTurn = useCallback((reason: string) => {
|
|
const active = activeGoalAdmissionRef.current;
|
|
if (!active || active.controller.signal.aborted) return;
|
|
active.controller.abort(reason);
|
|
}, []);
|
|
|
|
const handleApprovalModeChange = useCallback(
|
|
async (newApprovalMode: ApprovalMode) => {
|
|
// 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 => {
|
|
if (call.status !== 'awaiting_approval') {
|
|
return false;
|
|
}
|
|
const { confirmationDetails } = call;
|
|
return !(
|
|
confirmationDetails &&
|
|
'hideAlwaysAllow' in confirmationDetails &&
|
|
confirmationDetails.hideAlwaysAllow === true
|
|
);
|
|
},
|
|
);
|
|
|
|
// For AUTO_EDIT mode, only approve edit tools (edit/replace, write_file, notebook_edit)
|
|
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.confirmationDetails?.onConfirm) {
|
|
try {
|
|
await call.confirmationDetails.onConfirm(
|
|
ToolConfirmationOutcome.ProceedOnce,
|
|
);
|
|
} catch (error) {
|
|
debugLogger.error(
|
|
`Failed to auto-approve tool call ${call.request.callId}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[toolCalls],
|
|
);
|
|
|
|
const handleCompletedTools = useCallback(
|
|
async (
|
|
completedToolCallsFromScheduler: TrackedToolCall[],
|
|
shared?: SharedToolBatchResults,
|
|
) => {
|
|
// A shared batch was finalized after the scheduler completed it; a
|
|
// swap during that await must not be taken as the current session.
|
|
const sessionId = shared ? shared.owner.sessionId : config.getSessionId();
|
|
const chat: unknown = shared ? shared.owner.chat : llmClient?.getChat();
|
|
const isCurrentSession = () =>
|
|
config.getSessionId() === sessionId && llmClient?.getChat() === chat;
|
|
// The shared batch finalized these results and counted these calls.
|
|
const sharedResultsByCallId = new Map(
|
|
shared?.responses.map((entry) => [entry.callId, entry]) ?? [],
|
|
);
|
|
const countedByBatch = (toolCall: TrackedToolCall) =>
|
|
shared?.ranCallIds.has(toolCall.request.callId) === true;
|
|
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;
|
|
},
|
|
);
|
|
// History-based dedup MUST run before the active-stream early-return.
|
|
// If a synthetic `functionResponse` for this callId is already in
|
|
// chat.history (planted on session-load by
|
|
// `client.repairOrphanedToolUseTurnsInHistory` or on every
|
|
// `chat.sendMessageStream` push by the inline repair pass), the
|
|
// in-flight scheduler result must be marked submitted NOW —
|
|
// `useReactToolScheduler.allToolCallsCompleteHandler` is single-shot
|
|
// per batch, so a later active-stream early-return would leave
|
|
// the tool stuck in `completed-but-not-submitted` forever (Race A
|
|
// surfaced in PR #4176 review). When the existing response is one
|
|
// of those synthesized placeholders, the real result REPLACES it
|
|
// in place (and is recorded to the transcript) so a tool that
|
|
// actually finished is not reported to the model as "not
|
|
// recorded" (issue #15297); only a genuine duplicate — a real
|
|
// response already present for the same id — is dropped on the
|
|
// wire, the trade-off upstream Claude Code makes when its
|
|
// `StreamingToolExecutor.discard()` follows a
|
|
// `yieldMissingToolResultBlocks` synthesis (`query.ts:733` + `:984`).
|
|
// Walk raw history WITHOUT cloning — `llmClient.getHistory()`
|
|
// returns `structuredClone(this.history)`, which on long sessions
|
|
// (200+ entries with sizable tool outputs) costs several ms on
|
|
// the React UI thread and visibly stalls streaming when the
|
|
// dedup pass runs on every tool-completion batch.
|
|
// `getHistoryFunctionResponseIds` walks history in place and
|
|
// returns only the id Set this dispatcher needs. The
|
|
// LlmClient implementation is mandatory — production and
|
|
// test mocks both expose it. Skip the dedup pass entirely if
|
|
// the client is missing (only happens in unit tests that
|
|
// construct a hook without a client).
|
|
const historyCallIdsWithResponse: Set<string> = llmClient
|
|
? llmClient.getHistoryFunctionResponseIds()
|
|
: new Set<string>();
|
|
const dedupedTools = completedAndReadyToSubmitTools.filter((tc) =>
|
|
historyCallIdsWithResponse.has(tc.request.callId),
|
|
);
|
|
const dedupedCallIds = dedupedTools.map((tc) => tc.request.callId);
|
|
if (dedupedCallIds.length > 0) {
|
|
// Optional-call with an empty fallback: some unit-test mocks
|
|
// predate this accessor, and a missing accessor must degrade to
|
|
// the old drop behavior rather than crash the batch.
|
|
const synthesizedRepairCallIds: Set<string> =
|
|
llmClient?.getSynthesizedRepairCallIds?.() ?? new Set<string>();
|
|
const replaceableTools = dedupedTools.filter((tc) =>
|
|
synthesizedRepairCallIds.has(tc.request.callId),
|
|
);
|
|
const replacedCallIds: string[] = [];
|
|
let abandonedSessionId: string | null = null;
|
|
if (replaceableTools.length > 0 && llmClient) {
|
|
// The shared batch's results are already finalized.
|
|
const unfinalizedTools = replaceableTools.filter(
|
|
({ request }) => !sharedResultsByCallId.has(request.callId),
|
|
);
|
|
const finalizedUnshared =
|
|
unfinalizedTools.length === 0
|
|
? []
|
|
: await finalizeToolResponses(
|
|
config,
|
|
unfinalizedTools.map(({ request, response }) => ({
|
|
callId: request.callId,
|
|
toolName: request.name,
|
|
responseParts: response.responseParts,
|
|
persistedOutputFiles: response.persistedOutputFiles,
|
|
artifacts: response.artifacts,
|
|
})),
|
|
new Map(
|
|
unfinalizedTools.flatMap(({ request }) =>
|
|
request.prompt_id
|
|
? [[request.callId, request.prompt_id] as const]
|
|
: [],
|
|
),
|
|
),
|
|
);
|
|
const finalizedReplaceable = replaceableTools.map(
|
|
(
|
|
toolCall,
|
|
): Awaited<ReturnType<typeof finalizeToolResponses>>[number] => {
|
|
const sharedResult = sharedResultsByCallId.get(
|
|
toolCall.request.callId,
|
|
);
|
|
return sharedResult
|
|
? {
|
|
callId: sharedResult.callId,
|
|
toolName: toolCall.request.name,
|
|
responseParts: sharedResult.parts,
|
|
}
|
|
: finalizedUnshared[unfinalizedTools.indexOf(toolCall)]!;
|
|
},
|
|
);
|
|
// A session swap (/clear, /resume, daemon churn) during the
|
|
// finalize await leaves the captured chat abandoned: splicing
|
|
// it is invisible, but recordToolResult resolves its session
|
|
// at write time and would land the old session's result in the
|
|
// NEW session's transcript. Skip both; the ids fall through to
|
|
// the drop bookkeeping below.
|
|
if (isCurrentSession()) {
|
|
finalizedReplaceable.forEach((entry, index) => {
|
|
const { request, response, status } = replaceableTools[index]!;
|
|
if (
|
|
!llmClient.replaceSynthesizedToolResponse?.(
|
|
entry.callId,
|
|
entry.responseParts,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
replacedCallIds.push(entry.callId);
|
|
// Every other completion path emits to the sidecar; without
|
|
// this a stream-json consumer never sees the late real result.
|
|
dualOutput?.emitToolResult(request, response);
|
|
// The drop path never reaches `recordToolResult`, so without
|
|
// this the transcript keeps a `model[functionCall]` whose
|
|
// response exists nowhere on disk — the exact phantom shape
|
|
// reported in issue #15297. The placeholder was itself
|
|
// in-memory only, so the real result is the first durable
|
|
// answer for this callId. On `--resume` the repair pass hoists
|
|
// it back only while it still sits in the user run right after
|
|
// the call; once a model turn intervenes the scan stops there
|
|
// and synthesizes a fresh placeholder instead, leaving the
|
|
// recorded result durable but unreferenced.
|
|
const sharedResult = sharedResultsByCallId.get(entry.callId);
|
|
if (sharedResult) {
|
|
sharedResult.record?.();
|
|
return;
|
|
}
|
|
config.getChatRecordingService?.()?.recordToolResult?.(
|
|
entry.responseParts,
|
|
{
|
|
callId: entry.callId,
|
|
status,
|
|
resultDisplay: response.resultDisplay,
|
|
persistedOutputFiles: entry.persistedOutputFiles,
|
|
artifacts: entry.artifacts,
|
|
error: response.error,
|
|
errorType: response.errorType,
|
|
executionStatus: response.executionStatus,
|
|
},
|
|
goalToolResultProvenance(request, entry.responseParts),
|
|
);
|
|
});
|
|
if (replacedCallIds.length > 0) {
|
|
debugLogger.warn(
|
|
`[REPAIR] Replaced synthesized placeholder(s) with late ` +
|
|
`real tool result(s) for callId(s): ` +
|
|
`${replacedCallIds.join(', ')}`,
|
|
);
|
|
}
|
|
} else {
|
|
abandonedSessionId = sessionId;
|
|
}
|
|
}
|
|
const replacedSet = new Set(replacedCallIds);
|
|
const droppedCallIds = dedupedCallIds.filter(
|
|
(id) => !replacedSet.has(id),
|
|
);
|
|
if (droppedCallIds.length > 0) {
|
|
debugLogger.warn(
|
|
`[REPAIR] Dropping ${droppedCallIds.length} late tool result(s) ` +
|
|
`whose callId already has a functionResponse in history: ` +
|
|
`${droppedCallIds.join(', ')}` +
|
|
// Without this the warning reads as "genuine duplicate" for
|
|
// ids that were only skipped because the session moved on.
|
|
(abandonedSessionId
|
|
? ` (session ${abandonedSessionId} was abandoned during ` +
|
|
`finalize, so its placeholder(s) were not replaced)`
|
|
: ``),
|
|
);
|
|
}
|
|
// Even though the wire-side submission is dropped, the tool DID
|
|
// run locally — `toolCallCount` and `skillsModifiedInSession`
|
|
// must reflect that. Without this, deduped skill-write tools
|
|
// (e.g. write_file under a project SKILLS path) would silently
|
|
// skip the `skillsModifiedInSession` flip that gates the
|
|
// skills-reload prompt at end-of-turn. Mirrors the
|
|
// `recordCompletedToolCall` loop below over `llmTools` —
|
|
// filter to the same shape (non-client-initiated) so client
|
|
// tools (which the original loop also skipped) stay skipped.
|
|
//
|
|
// Cancelled tools are also skipped: `dedupedTools` includes
|
|
// anything in a terminal state (success | error | cancelled),
|
|
// but cancelled means the tool never actually ran end-to-end —
|
|
// the `allToolsCancelled` branch below would have surfaced
|
|
// them via `addHistory + reportCancelled` rather than the
|
|
// completed-call metric, and the metric should match. Without
|
|
// this filter, a deduped + cancelled tool would inflate
|
|
// `toolCallCount` for a call that never produced a result
|
|
// (and could also flip `skillsModifiedInSession` for a
|
|
// never-executed skill-write).
|
|
for (const tc of dedupedTools) {
|
|
if (tc.request.isClientInitiated) continue;
|
|
if (tc.status === 'cancelled' || tc.nativeAttemptsObserved) continue;
|
|
if (countedByBatch(tc)) continue;
|
|
llmClient?.recordCompletedToolCall(
|
|
tc.request.name,
|
|
tc.request.args as Record<string, unknown>,
|
|
);
|
|
}
|
|
markToolsAsSubmitted(dedupedCallIds);
|
|
const detachedAbortControllers = new Set<AbortController>();
|
|
const foregroundAbortControllers = new Set<AbortController>();
|
|
for (const callId of dedupedCallIds) {
|
|
const continuationOwner =
|
|
continuationOwnersByToolCallIdRef.current.get(callId);
|
|
if (continuationOwner?.detachedAbortController) {
|
|
detachedAbortControllers.add(
|
|
continuationOwner.detachedAbortController,
|
|
);
|
|
}
|
|
if (continuationOwner?.foregroundAbortController) {
|
|
foregroundAbortControllers.add(
|
|
continuationOwner.foregroundAbortController,
|
|
);
|
|
}
|
|
interactionOwnersByToolCallIdRef.current.delete(callId);
|
|
continuationOwnersByToolCallIdRef.current.delete(callId);
|
|
}
|
|
for (const controller of detachedAbortControllers) {
|
|
const isStillOwned = [
|
|
...continuationOwnersByToolCallIdRef.current.values(),
|
|
].some((owner) => owner.detachedAbortController === controller);
|
|
if (!isStillOwned) {
|
|
detachedToolContinuationAbortControllersRef.current.delete(
|
|
controller,
|
|
);
|
|
}
|
|
}
|
|
for (const controller of foregroundAbortControllers) {
|
|
const isStillOwned = [
|
|
...continuationOwnersByToolCallIdRef.current.values(),
|
|
].some((owner) => owner.foregroundAbortController === controller);
|
|
if (!isStillOwned && abortControllerRef.current === controller) {
|
|
abortControllerRef.current = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
activeModelStreamsRef.current > 0 ||
|
|
completedToolBatchDrainHoldsRef.current > 0
|
|
) {
|
|
const deferredTools = completedAndReadyToSubmitTools.filter(
|
|
(toolCall) =>
|
|
!historyCallIdsWithResponse.has(toolCall.request.callId),
|
|
);
|
|
if (deferredTools.length > 0) {
|
|
// The drain runs this step again, against history as it is then.
|
|
const handledCallIds = new Set(dedupedCallIds);
|
|
pendingCompletedToolBatchesRef.current.push({
|
|
tools: deferredTools,
|
|
...(shared
|
|
? {
|
|
shared: {
|
|
...shared,
|
|
responses: shared.responses.filter(
|
|
({ callId }) => !handledCallIds.has(callId),
|
|
),
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const continuationOwner = completedAndReadyToSubmitTools
|
|
.filter(
|
|
(toolCall) =>
|
|
!historyCallIdsWithResponse.has(toolCall.request.callId),
|
|
)
|
|
.map((toolCall) =>
|
|
continuationOwnersByToolCallIdRef.current.get(
|
|
toolCall.request.callId,
|
|
),
|
|
)
|
|
.find((owner) => owner !== undefined);
|
|
const continuationWasCancelled = () =>
|
|
continuationOwner
|
|
? continuationOwner.signal.aborted
|
|
: turnCancelledRef.current ||
|
|
abortControllerRef.current?.signal.aborted === true;
|
|
|
|
// Finalize any client-initiated tools as soon as they are done.
|
|
// Skip ones whose callId already lives in chat history with a
|
|
// matching `functionResponse` — the dedup block above already
|
|
// called `markToolsAsSubmitted` for those, and re-dispatching
|
|
// the same callIds here would queue an extra React render.
|
|
const clientTools = completedAndReadyToSubmitTools.filter(
|
|
(t) =>
|
|
t.request.isClientInitiated &&
|
|
!historyCallIdsWithResponse.has(t.request.callId),
|
|
);
|
|
for (const toolCall of clientTools) {
|
|
const owner = continuationOwnersByToolCallIdRef.current.get(
|
|
toolCall.request.callId,
|
|
);
|
|
if (owner?.restoreResponseBinding) {
|
|
const [final] = await finalizeToolResponses(
|
|
config,
|
|
[
|
|
{
|
|
callId: toolCall.request.callId,
|
|
toolName: toolCall.request.name,
|
|
responseParts: toolCall.response.responseParts,
|
|
persistedOutputFiles: toolCall.response.persistedOutputFiles,
|
|
artifacts: toolCall.response.artifacts,
|
|
},
|
|
],
|
|
new Map(
|
|
toolCall.request.prompt_id
|
|
? [[toolCall.request.callId, toolCall.request.prompt_id]]
|
|
: [],
|
|
),
|
|
);
|
|
await completeRestoreToolResponse(
|
|
owner.restoreResponseBinding,
|
|
toolCall.request,
|
|
final.responseParts,
|
|
() =>
|
|
workflowConfigRef.current === config &&
|
|
!owner.signal.aborted &&
|
|
isCurrentSession() &&
|
|
owner.foregroundAbortController !== undefined &&
|
|
abortControllerRef.current === owner.foregroundAbortController,
|
|
{
|
|
toolCallResult: {
|
|
callId: toolCall.request.callId,
|
|
status: toolCall.status,
|
|
resultDisplay: toolCall.response.resultDisplay,
|
|
error: toolCall.response.error,
|
|
errorType: toolCall.response.errorType,
|
|
executionStatus: toolCall.response.executionStatus,
|
|
persistedOutputFiles: final.persistedOutputFiles,
|
|
artifacts: final.artifacts,
|
|
},
|
|
options: goalToolResultProvenance(
|
|
toolCall.request,
|
|
final.responseParts,
|
|
),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
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.nativeAttemptsObserved &&
|
|
t.request.name === 'save_memory' &&
|
|
t.status === 'success' &&
|
|
!processedMemoryToolsRef.current.has(t.request.callId),
|
|
);
|
|
|
|
let llmTools = completedAndReadyToSubmitTools.filter(
|
|
(t) =>
|
|
!t.request.isClientInitiated &&
|
|
!historyCallIdsWithResponse.has(t.request.callId),
|
|
);
|
|
const terminalPromptId = completedAndReadyToSubmitTools.find(
|
|
(toolCall) => !toolCall.request.isClientInitiated,
|
|
)?.request.prompt_id;
|
|
const ownerForToolCall = (toolCall: TrackedToolCall) =>
|
|
interactionOwnersByToolCallIdRef.current.get(toolCall.request.callId) ??
|
|
(activeInteractionPromptIdRef.current === toolCall.request.prompt_id
|
|
? activeInteractionOwnerRef.current
|
|
: undefined);
|
|
const liveOwnerForToolCall = (toolCall: TrackedToolCall) => {
|
|
const owner = ownerForToolCall(toolCall);
|
|
const ownerPromptId = toolCall.request.prompt_id;
|
|
return owner &&
|
|
ownerPromptId &&
|
|
getActiveInteractionSpan(ownerPromptId) === owner
|
|
? owner
|
|
: undefined;
|
|
};
|
|
const liveActiveInteractionOwner =
|
|
activeInteractionPromptIdRef.current &&
|
|
activeInteractionOwnerRef.current &&
|
|
getActiveInteractionSpan(activeInteractionPromptIdRef.current) ===
|
|
activeInteractionOwnerRef.current
|
|
? activeInteractionOwnerRef.current
|
|
: undefined;
|
|
const ownerToolCall =
|
|
(liveActiveInteractionOwner
|
|
? llmTools.find(
|
|
(toolCall) =>
|
|
liveOwnerForToolCall(toolCall) === liveActiveInteractionOwner,
|
|
)
|
|
: undefined) ??
|
|
llmTools.find((toolCall) => liveOwnerForToolCall(toolCall)) ??
|
|
llmTools[0] ??
|
|
completedAndReadyToSubmitTools.find(
|
|
(toolCall) => !toolCall.request.isClientInitiated,
|
|
);
|
|
const interactionOwner = ownerToolCall
|
|
? liveOwnerForToolCall(ownerToolCall)
|
|
: undefined;
|
|
const secondaryInteractionOwners = new Map<
|
|
NonNullable<ReturnType<typeof getActiveInteractionSpan>>,
|
|
string
|
|
>();
|
|
const secondaryTools = interactionOwner
|
|
? llmTools.filter(
|
|
(toolCall) => ownerForToolCall(toolCall) !== interactionOwner,
|
|
)
|
|
: [];
|
|
for (const toolCall of secondaryTools) {
|
|
const secondaryOwner = ownerForToolCall(toolCall);
|
|
if (secondaryOwner && toolCall.request.prompt_id) {
|
|
secondaryInteractionOwners.set(
|
|
secondaryOwner,
|
|
toolCall.request.prompt_id,
|
|
);
|
|
}
|
|
if (
|
|
toolCall.status !== 'cancelled' &&
|
|
!toolCall.nativeAttemptsObserved &&
|
|
!countedByBatch(toolCall)
|
|
) {
|
|
llmClient?.recordCompletedToolCall(
|
|
toolCall.request.name,
|
|
toolCall.request.args as Record<string, unknown>,
|
|
);
|
|
}
|
|
dualOutput?.emitToolResult(toolCall.request, toolCall.response);
|
|
}
|
|
if (secondaryTools.length > 0) {
|
|
const secondaryCallIds = new Set(
|
|
secondaryTools.map((toolCall) => toolCall.request.callId),
|
|
);
|
|
markToolsAsSubmitted([...secondaryCallIds]);
|
|
llmTools = llmTools.filter(
|
|
(toolCall) => !secondaryCallIds.has(toolCall.request.callId),
|
|
);
|
|
}
|
|
for (const toolCall of completedAndReadyToSubmitTools) {
|
|
interactionOwnersByToolCallIdRef.current.delete(
|
|
toolCall.request.callId,
|
|
);
|
|
continuationOwnersByToolCallIdRef.current.delete(
|
|
toolCall.request.callId,
|
|
);
|
|
}
|
|
for (const [owner, ownerPromptId] of secondaryInteractionOwners) {
|
|
if (getActiveInteractionSpan(ownerPromptId) === owner) {
|
|
endInteractionSpan('cancelled', { promptId: ownerPromptId });
|
|
}
|
|
}
|
|
let promptId =
|
|
ownerToolCall?.request.prompt_id ?? continuationOwner?.promptId;
|
|
const pairGoalToolResponsesIntoHistory = async () => {
|
|
if (!llmClient || llmTools.length === 0) return;
|
|
const responses = await finalizeToolResponses(
|
|
config,
|
|
llmTools.map(({ request, response }) => ({
|
|
callId: request.callId,
|
|
toolName: request.name,
|
|
responseParts: response.responseParts,
|
|
persistedOutputFiles: response.persistedOutputFiles,
|
|
artifacts: response.artifacts,
|
|
})),
|
|
new Map(
|
|
llmTools.flatMap(({ request }) =>
|
|
request.prompt_id
|
|
? [[request.callId, request.prompt_id] as const]
|
|
: [],
|
|
),
|
|
),
|
|
);
|
|
llmClient.addHistory({
|
|
role: 'user',
|
|
parts: responses.flatMap((entry) => entry.responseParts),
|
|
});
|
|
};
|
|
const endToolInteraction = (
|
|
status: 'ok' | 'error' | 'cancelled',
|
|
errorMessage?: string,
|
|
errorType?: string,
|
|
endsModTurn = true,
|
|
) => {
|
|
if (endsModTurn && status !== 'ok')
|
|
recordModTurnEnding(
|
|
modTurnRef.current,
|
|
promptId,
|
|
status === 'cancelled' ? 'aborted' : 'error',
|
|
);
|
|
if (continuationOwner?.detachedAbortController) {
|
|
detachedToolContinuationAbortControllersRef.current.delete(
|
|
continuationOwner.detachedAbortController,
|
|
);
|
|
}
|
|
if (
|
|
continuationOwner?.foregroundAbortController &&
|
|
abortControllerRef.current ===
|
|
continuationOwner.foregroundAbortController
|
|
) {
|
|
abortControllerRef.current = null;
|
|
}
|
|
if (
|
|
!promptId ||
|
|
!interactionOwner ||
|
|
getActiveInteractionSpan(promptId) !== interactionOwner
|
|
) {
|
|
return;
|
|
}
|
|
endInteractionSpan(status, {
|
|
promptId,
|
|
...(errorMessage ? { errorMessage } : {}),
|
|
...(errorType ? { errorType } : {}),
|
|
});
|
|
if (activeInteractionOwnerRef.current === interactionOwner) {
|
|
activeInteractionOwnerRef.current = undefined;
|
|
}
|
|
};
|
|
let toolGoalPermit: GoalTurnPermit | undefined;
|
|
const toolGoalContexts = llmTools.map(
|
|
(toolCall) => toolCall.request.goalContext,
|
|
);
|
|
try {
|
|
toolGoalPermit = sharedGoalPermit(toolGoalContexts);
|
|
} catch (error) {
|
|
const callIds = llmTools.map((toolCall) => toolCall.request.callId);
|
|
await pairGoalToolResponsesIntoHistory();
|
|
markToolsAsSubmitted(callIds);
|
|
const reason = getErrorMessage(error);
|
|
const bindings = new Map<string, GoalTurnBinding>();
|
|
const active = activeGoalTurnRef.current;
|
|
if (active) {
|
|
bindings.set(active.turnKey, active);
|
|
}
|
|
for (const permit of toolGoalContexts) {
|
|
if (!permit) continue;
|
|
const existing = goalTurnBindingsRef.current.get(permit.turnId);
|
|
const binding =
|
|
existing ??
|
|
({
|
|
permit: { ...permit },
|
|
turnKey: `goal-runtime:${permit.turnId}`,
|
|
controller: new AbortController(),
|
|
origin: 'runtime',
|
|
} satisfies GoalTurnBinding);
|
|
bindings.set(binding.turnKey, binding);
|
|
}
|
|
for (const binding of bindings.values()) {
|
|
// `reason` here is a scheduler diagnostic, not something a user
|
|
// reads. It stays the abort cause and the error item; the durable
|
|
// `lastReason` gets the builder's detail-free sentence.
|
|
await failClosedGoalTurn(binding, reason, {
|
|
pauseReason: goalPauseReasonForFailure(''),
|
|
});
|
|
}
|
|
addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: reason,
|
|
},
|
|
Date.now(),
|
|
);
|
|
endToolInteraction(
|
|
'error',
|
|
'invalid Goal tool context',
|
|
'continuation_goal_context_invalid',
|
|
);
|
|
return;
|
|
}
|
|
if (!toolGoalPermit && toolGoalContexts.length > 0) {
|
|
const active = activeGoalTurnRef.current;
|
|
let activeGoalPermitValid = false;
|
|
if (active) {
|
|
try {
|
|
const runtime = config.getGoalRuntime();
|
|
const currentPermit = runtime.permitForTurn(active.turnKey);
|
|
activeGoalPermitValid =
|
|
currentPermit !== undefined &&
|
|
sameGoalPermit(currentPermit, active.permit);
|
|
} catch {
|
|
// A missing runtime means this is an ordinary non-Goal batch.
|
|
}
|
|
}
|
|
if (active && activeGoalPermitValid) {
|
|
await pairGoalToolResponsesIntoHistory();
|
|
markToolsAsSubmitted(
|
|
llmTools.map((toolCall) => toolCall.request.callId),
|
|
);
|
|
const reason = 'ToolResult batch is missing the active Goal context';
|
|
await failClosedGoalTurn(active, reason, {
|
|
pauseReason: goalPauseReasonForFailure(''),
|
|
});
|
|
addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: reason,
|
|
},
|
|
Date.now(),
|
|
);
|
|
endToolInteraction(
|
|
'error',
|
|
'missing Goal tool context',
|
|
'continuation_goal_context_missing',
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
let toolGoalBinding: GoalTurnBinding | undefined;
|
|
if (toolGoalPermit) {
|
|
const existing = goalTurnBindingsRef.current.get(toolGoalPermit.turnId);
|
|
if (existing && !sameGoalPermit(existing.permit, toolGoalPermit)) {
|
|
await pairGoalToolResponsesIntoHistory();
|
|
markToolsAsSubmitted(
|
|
llmTools.map((toolCall) => toolCall.request.callId),
|
|
);
|
|
const reason = 'ToolResult batch has a stale Goal context';
|
|
await failClosedGoalTurn(existing, reason, {
|
|
pauseReason: goalPauseReasonForFailure(''),
|
|
});
|
|
addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: reason,
|
|
},
|
|
Date.now(),
|
|
);
|
|
endToolInteraction(
|
|
'error',
|
|
'stale Goal tool context',
|
|
'continuation_goal_context_stale',
|
|
);
|
|
return;
|
|
}
|
|
toolGoalBinding =
|
|
existing ??
|
|
bindGoalTurn(
|
|
toolGoalPermit,
|
|
`goal-runtime:${toolGoalPermit.turnId}`,
|
|
'runtime',
|
|
);
|
|
}
|
|
const memoryWriteCandidates = completedAndReadyToSubmitTools
|
|
.filter((call) => !call.nativeAttemptsObserved)
|
|
.map((toolCall) => ({
|
|
toolName: toolCall.request.name,
|
|
args: toolCall.request.args as Record<string, unknown>,
|
|
status: toolCall.status,
|
|
}));
|
|
const didRefreshManagedMemory = await refreshMemoryAfterManagedWrite(
|
|
config,
|
|
memoryWriteCandidates,
|
|
{ logContext: 'interactive memory tool batch' },
|
|
);
|
|
if (refreshContextFilesOnWriteRef.current) {
|
|
const matchedContextFileWrite = didWriteProjectContextFile(
|
|
memoryWriteCandidates,
|
|
config.getProjectRoot(),
|
|
);
|
|
debugLogger.debug(
|
|
`Checked marked context-file memory tool batch; matched=${matchedContextFileWrite}`,
|
|
);
|
|
if (matchedContextFileWrite) {
|
|
debugLogger.debug(
|
|
'Refreshing memory after context-file memory write',
|
|
);
|
|
await refreshMemoryInstruction(config, {
|
|
logContext: 'interactive context-file memory tool batch',
|
|
});
|
|
}
|
|
}
|
|
if (!isCurrentSession()) {
|
|
if (toolGoalBinding) releaseGoalTurn(toolGoalBinding);
|
|
return;
|
|
}
|
|
if (newSuccessfulMemorySaves.length > 0) {
|
|
if (!didRefreshManagedMemory) {
|
|
// Perform the legacy save_memory refresh only when the managed-memory
|
|
// write refresh did not already rebuild and publish a fresher state.
|
|
void performMemoryRefresh().catch((err) => {
|
|
debugLogger.warn(`save_memory refresh failed: ${err}`);
|
|
});
|
|
}
|
|
// 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 completedCallIds = new Set(
|
|
llmTools.map((toolCall) => toolCall.request.callId),
|
|
);
|
|
const secondaryCallIds = new Set(
|
|
secondaryTools.map((toolCall) => toolCall.request.callId),
|
|
);
|
|
const readyDuplicateBatches: PendingDuplicateToolResponses[] = [];
|
|
pendingDuplicateToolResponsesRef.current =
|
|
pendingDuplicateToolResponsesRef.current.filter((batch) => {
|
|
const isReady = [...batch.executableCallIds].some((callId) =>
|
|
completedCallIds.has(callId),
|
|
);
|
|
if (isReady) {
|
|
readyDuplicateBatches.push(batch);
|
|
}
|
|
const belongsToSecondaryOwner = [...batch.executableCallIds].some(
|
|
(callId) => secondaryCallIds.has(callId),
|
|
);
|
|
return !isReady && !belongsToSecondaryOwner;
|
|
});
|
|
const pendingDuplicateResponses = readyDuplicateBatches
|
|
.flatMap((batch) => batch.duplicateResponses)
|
|
.filter(
|
|
({ request }) =>
|
|
!interactionOwner ||
|
|
!request.prompt_id ||
|
|
request.prompt_id === promptId,
|
|
);
|
|
const pendingDuplicatePromptId = readyDuplicateBatches[0]?.promptId;
|
|
if (!promptId && pendingDuplicatePromptId) {
|
|
promptId = pendingDuplicatePromptId;
|
|
}
|
|
|
|
// The shared batch's results this continuation sends: its executed
|
|
// calls still in llmTools, and its replay answers. As on the current
|
|
// path, the replay answers go out only with an executed call: when
|
|
// history took them all, nothing is sent.
|
|
const unsentCallIds = new Set(
|
|
completedAndReadyToSubmitTools
|
|
.filter((toolCall) => !llmTools.includes(toolCall))
|
|
.map((toolCall) => toolCall.request.callId),
|
|
);
|
|
const sendsSharedCall = llmTools.some((toolCall) =>
|
|
sharedResultsByCallId.has(toolCall.request.callId),
|
|
);
|
|
const sharedResponses = sendsSharedCall
|
|
? (shared?.responses.filter(
|
|
({ callId }) => !unsentCallIds.has(callId),
|
|
) ?? [])
|
|
: [];
|
|
// The other calls' results are finalized and recorded here.
|
|
const unsharedTools = llmTools.filter(
|
|
(toolCall) => !sharedResultsByCallId.has(toolCall.request.callId),
|
|
);
|
|
if (shared && !promptId && shared.promptId) promptId = shared.promptId;
|
|
for (const toolCall of llmTools) {
|
|
if (toolCall.nativeAttemptsObserved || countedByBatch(toolCall)) {
|
|
continue;
|
|
}
|
|
llmClient?.recordCompletedToolCall(
|
|
toolCall.request.name,
|
|
toolCall.request.args as Record<string, unknown>,
|
|
);
|
|
}
|
|
|
|
if (
|
|
sharedResponses.length === 0 &&
|
|
unsharedTools.length === 0 &&
|
|
pendingDuplicateResponses.length === 0
|
|
) {
|
|
if (!promptId && terminalPromptId) {
|
|
promptId = terminalPromptId;
|
|
}
|
|
if (toolGoalBinding) {
|
|
await failClosedGoalTurn(
|
|
toolGoalBinding,
|
|
'Goal tool continuation ended without a result',
|
|
{ pauseReason: goalPauseReasonForFailure('') },
|
|
);
|
|
}
|
|
if (
|
|
completedAndReadyToSubmitTools.length > 0 &&
|
|
completedAndReadyToSubmitTools.every(
|
|
(toolCall) => toolCall.status === 'cancelled',
|
|
)
|
|
) {
|
|
endToolInteraction('cancelled');
|
|
} else if (
|
|
completedAndReadyToSubmitTools.some(
|
|
(toolCall) => toolCall.status === 'error',
|
|
)
|
|
) {
|
|
endToolInteraction(
|
|
'error',
|
|
'tool continuation ended with an error',
|
|
'continuation_tool_error',
|
|
);
|
|
} else {
|
|
endToolInteraction('ok');
|
|
}
|
|
return;
|
|
}
|
|
|
|
type ReadyToolResponse = {
|
|
request: ToolCallRequestInfo;
|
|
response: ToolCallResponseInfo;
|
|
status: 'success' | 'error' | 'cancelled';
|
|
};
|
|
const executableQueues = new Map<string, ReadyToolResponse[]>();
|
|
for (const toolCall of unsharedTools) {
|
|
const queue = executableQueues.get(toolCall.request.callId) ?? [];
|
|
queue.push({
|
|
request: toolCall.request,
|
|
response: toolCall.response,
|
|
status: toolCall.status,
|
|
});
|
|
executableQueues.set(toolCall.request.callId, queue);
|
|
}
|
|
const duplicateQueues = new Map<string, ReadyToolResponse[]>();
|
|
for (const duplicate of pendingDuplicateResponses) {
|
|
const queue = duplicateQueues.get(duplicate.request.callId) ?? [];
|
|
queue.push({
|
|
...duplicate,
|
|
status: duplicate.response.error ? 'error' : 'success',
|
|
});
|
|
duplicateQueues.set(duplicate.request.callId, queue);
|
|
}
|
|
const orderedResponses: ReadyToolResponse[] = [];
|
|
for (const batch of readyDuplicateBatches) {
|
|
for (const callId of batch.callOrder) {
|
|
const executable = executableQueues.get(callId)?.shift();
|
|
if (executable) {
|
|
orderedResponses.push(executable);
|
|
continue;
|
|
}
|
|
const duplicate = duplicateQueues.get(callId)?.shift();
|
|
if (duplicate) orderedResponses.push(duplicate);
|
|
}
|
|
}
|
|
for (const queue of executableQueues.values()) {
|
|
orderedResponses.push(...queue);
|
|
}
|
|
for (const queue of duplicateQueues.values()) {
|
|
orderedResponses.push(...queue);
|
|
}
|
|
|
|
const finalizedResponses =
|
|
orderedResponses.length === 0
|
|
? []
|
|
: await finalizeToolResponses(
|
|
config,
|
|
orderedResponses.map(({ request, response }) => ({
|
|
callId: request.callId,
|
|
toolName: request.name,
|
|
responseParts: response.responseParts,
|
|
persistedOutputFiles: response.persistedOutputFiles,
|
|
artifacts: response.artifacts,
|
|
})),
|
|
new Map(
|
|
orderedResponses.flatMap(({ request }) =>
|
|
request.prompt_id
|
|
? [[request.callId, request.prompt_id] as const]
|
|
: [],
|
|
),
|
|
),
|
|
);
|
|
// Each call's finalized parts, for the pairing a cancel owes history:
|
|
// the shared batch's first, in its request order.
|
|
const sentResponses = [
|
|
...sharedResponses.map(({ callId, parts }) => ({
|
|
callId,
|
|
responseParts: parts,
|
|
})),
|
|
...finalizedResponses,
|
|
];
|
|
const responsesToSend = sentResponses.flatMap(
|
|
(entry) => entry.responseParts,
|
|
);
|
|
if (!isCurrentSession()) {
|
|
if (toolGoalBinding) releaseGoalTurn(toolGoalBinding);
|
|
return;
|
|
}
|
|
for (const { record } of sharedResponses) record?.();
|
|
orderedResponses.forEach(({ request, response, status }, index) => {
|
|
config.getChatRecordingService?.()?.recordToolResult?.(
|
|
finalizedResponses[index].responseParts,
|
|
{
|
|
callId: request.callId,
|
|
status,
|
|
resultDisplay: response.resultDisplay,
|
|
persistedOutputFiles:
|
|
finalizedResponses[index].persistedOutputFiles,
|
|
artifacts: finalizedResponses[index].artifacts,
|
|
error: response.error,
|
|
errorType: response.errorType,
|
|
executionStatus: response.executionStatus,
|
|
},
|
|
goalToolResultProvenance(
|
|
request,
|
|
finalizedResponses[index].responseParts,
|
|
),
|
|
);
|
|
});
|
|
|
|
if (continuationWasCancelled()) {
|
|
// This is the branch a cancelled tool batch actually takes: the
|
|
// controller retained across tool execution feeds the continuation
|
|
// owner's signal, so pressing Esc while tools run aborts it here
|
|
// rather than at either of the branches below. `markToolsAsSubmitted`
|
|
// stops these callIds ever being submitted, so unless the responses
|
|
// are written now the model's function calls stay unanswered and the
|
|
// next turn sends a history with an unpaired call. The
|
|
// all-cancelled branch below writes them for the batch it handles;
|
|
// this branch owes its own batch the same pairing, whether or not
|
|
// every tool in it was cancelled.
|
|
if (llmClient) {
|
|
const pairedCallIds = llmClient.getHistoryFunctionResponseIds();
|
|
const unpairedParts = sentResponses
|
|
.filter((entry) => !pairedCallIds.has(entry.callId))
|
|
.flatMap((entry) => entry.responseParts);
|
|
if (unpairedParts.length > 0)
|
|
llmClient.addHistory({ role: 'user', parts: unpairedParts });
|
|
}
|
|
markToolsAsSubmitted(
|
|
llmTools.map((toolCall) => toolCall.request.callId),
|
|
);
|
|
if (toolGoalBinding) {
|
|
await failClosedGoalTurn(
|
|
toolGoalBinding,
|
|
'Goal tool continuation was cancelled',
|
|
{ userCancelled: true },
|
|
);
|
|
}
|
|
endToolInteraction('cancelled');
|
|
return;
|
|
}
|
|
|
|
// If all the tools were cancelled, don't submit a response to the model.
|
|
const allToolsCancelled = llmTools.every(
|
|
(tc) => tc.status === 'cancelled',
|
|
);
|
|
|
|
// The shared batch's replay answers, like the duplicate responses,
|
|
// still go out.
|
|
const llmToolCallIds = new Set(
|
|
llmTools.map((toolCall) => toolCall.request.callId),
|
|
);
|
|
const sendsSharedReplayAnswers = sharedResponses.some(
|
|
({ callId }) => !llmToolCallIds.has(callId),
|
|
);
|
|
if (
|
|
allToolsCancelled &&
|
|
pendingDuplicateResponses.length === 0 &&
|
|
!sendsSharedReplayAnswers
|
|
) {
|
|
if (llmClient) {
|
|
// We need to manually add the function responses to the history
|
|
// so the model knows the tools were cancelled.
|
|
llmClient.addHistory({
|
|
role: 'user',
|
|
parts: responsesToSend,
|
|
});
|
|
|
|
// Report cancellation to arena (safety net — cancelOngoingRequest
|
|
config.getArenaAgentClient()?.reportCancelled();
|
|
}
|
|
|
|
const callIdsToMarkAsSubmitted = llmTools.map(
|
|
(toolCall) => toolCall.request.callId,
|
|
);
|
|
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
|
// The person declined every tool the turn asked for, which ends it as
|
|
// Esc does; Esc and a Mod's `$.turn.abort` already set the cancel.
|
|
if (!turnCancelledRef.current)
|
|
getInteractiveModSession(config)?.stopSubmittedPrompts();
|
|
if (toolGoalBinding) {
|
|
// Every cancellation that reaches here originates in a user action:
|
|
// either Esc through `cancelOngoingRequest`, or a declined tool
|
|
// confirmation, which the dialog consumes so `turnCancelledRef`
|
|
// stays false. Selecting the failure arm on that ref would tell a
|
|
// user who declined one command that their Goal stopped because a
|
|
// turn failed.
|
|
await failClosedGoalTurn(
|
|
toolGoalBinding,
|
|
'Goal tool continuation was cancelled',
|
|
{ userCancelled: true },
|
|
);
|
|
}
|
|
endToolInteraction('cancelled');
|
|
return;
|
|
}
|
|
|
|
const callIdsToMarkAsSubmitted = llmTools.map(
|
|
(toolCall) => toolCall.request.callId,
|
|
);
|
|
|
|
// Persist model override from skill tool results (last one wins).
|
|
// Uses `in` so that undefined (from inherit/no-model skills) clears a
|
|
// prior override, while non-skill tools (field absent) leave it intact.
|
|
// An explicit inline `/model <id> <prompt>` override wins for the whole
|
|
// turn, so skip skill-tool writes (including the undefined-clears case)
|
|
// while it is active.
|
|
for (const toolCall of llmTools) {
|
|
if ('modelOverride' in toolCall.response) {
|
|
if (
|
|
inlineModelOverrideActiveRef.current ||
|
|
modelOverrideRef.current?.endsWith('\0')
|
|
) {
|
|
debugLogger.debug(
|
|
`skill-tool model override (${String(
|
|
toolCall.response.modelOverride,
|
|
)}) blocked: ${
|
|
inlineModelOverrideActiveRef.current
|
|
? 'inline override active'
|
|
: 'full-turn override active'
|
|
}`,
|
|
);
|
|
} else {
|
|
applyModelOverride(
|
|
modelOverrideRef,
|
|
inlineModelOverrideActiveRef,
|
|
toolCall.response.modelOverride,
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit tool results to dual output sidecar (if enabled)
|
|
if (dualOutput) {
|
|
for (const toolCall of llmTools) {
|
|
dualOutput.emitToolResult(toolCall.request, toolCall.response);
|
|
}
|
|
}
|
|
|
|
markToolsAsSubmitted(callIdsToMarkAsSubmitted);
|
|
|
|
const terminatesTurn = llmTools.some(
|
|
(toolCall) => toolCall.response.terminateTurn === true,
|
|
);
|
|
if (terminatesTurn && toolGoalBinding) {
|
|
llmClient.addHistory({ role: 'user', parts: responsesToSend });
|
|
let goalFinishFailed = false;
|
|
try {
|
|
await config.getChatRecordingService()?.flush();
|
|
const runtime = await config.getGoalRuntimeReady();
|
|
const currentPermit = runtime.permitForTurn(toolGoalBinding.turnKey);
|
|
if (
|
|
currentPermit &&
|
|
sameGoalPermit(currentPermit, toolGoalBinding.permit)
|
|
) {
|
|
await runtime.finishTurn(toolGoalBinding.permit);
|
|
const snapshot = runtime.getSnapshot();
|
|
const status = snapshot.goal?.status;
|
|
if (
|
|
status === 'complete' ||
|
|
status === 'blocked' ||
|
|
status === 'paused' ||
|
|
status === 'usage_limited'
|
|
) {
|
|
addItem(
|
|
{
|
|
type: 'goal_state',
|
|
snapshot,
|
|
cause: status === 'paused' ? 'pause' : status,
|
|
},
|
|
Date.now(),
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = getErrorMessage(error);
|
|
goalFinishFailed = true;
|
|
await failClosedGoalTurn(
|
|
toolGoalBinding,
|
|
`Goal turn could not finish: ${errorMessage}`,
|
|
{ pauseReason: goalPauseReasonForFailure(errorMessage) },
|
|
);
|
|
} finally {
|
|
// Idempotent with the release inside failClosedGoalTurn; also covers the success path.
|
|
releaseGoalTurn(toolGoalBinding);
|
|
}
|
|
if (goalFinishFailed) {
|
|
endToolInteraction(
|
|
'error',
|
|
'Goal tool continuation could not finish',
|
|
'continuation_goal_finish_failed',
|
|
);
|
|
} else {
|
|
endToolInteraction('ok');
|
|
}
|
|
return;
|
|
}
|
|
if (terminatesTurn && shared) {
|
|
// On the shared loop a tool that ends the turn ends it outside a
|
|
// Goal turn too, as on the other surfaces. Its results answer the
|
|
// calls in history, as a cancelled batch's do.
|
|
llmClient?.addHistory({ role: 'user', parts: responsesToSend });
|
|
endToolInteraction('ok');
|
|
return;
|
|
}
|
|
|
|
// Fire tool-use summary generation in parallel with the next API call.
|
|
// The fast-model latency is hidden behind the main-model streaming.
|
|
// Fire-and-forget: failures are silent and never block the turn.
|
|
// Subagent exclusion is implicit — useLlmStream only drives the
|
|
// main session; subagents run through agents/runtime/ with their own loop.
|
|
if (config.getEmitToolUseSummaries()) {
|
|
// Only summarize successful tools. Error/cancelled entries push
|
|
// "Cancelled by user" / retry-loop warnings into the summarizer
|
|
// prompt and produce plausibly-worded but misleading labels (the
|
|
// fast model happily synthesizes "Attempted to read files" from a
|
|
// batch that was mostly failures). cleanSummary can reject output
|
|
// prefixes but not prevent this kind of polluted-input hallucination.
|
|
// Goal tools already render authoritative lifecycle copy, which a
|
|
// generated summary can contradict while verification is pending.
|
|
const successfulTools = llmTools.filter(
|
|
(tc) =>
|
|
tc.status === 'success' &&
|
|
tc.request.name !== ToolNames.GET_GOAL &&
|
|
tc.request.name !== ToolNames.UPDATE_GOAL,
|
|
);
|
|
if (successfulTools.length > 0) {
|
|
const toolInfoForSummary = successfulTools.map((tc) => ({
|
|
name: tc.request.name,
|
|
input: tc.request.args,
|
|
output: extractToolResultText(tc.response.responseParts),
|
|
}));
|
|
const toolUseIds = successfulTools.map((tc) => tc.request.callId);
|
|
const lastAssistantText = extractLastAssistantText(
|
|
historyRef.current,
|
|
);
|
|
// Dedicated AbortController for this batch. Scoping it to the
|
|
// current turn via abortControllerRef.current would be wrong —
|
|
// submitQuery() below allocates a new controller for the next
|
|
// turn, so the captured signal becomes stale the moment the
|
|
// next turn starts. Instead, check the live abort state at
|
|
// resolve time (which covers both Ctrl+C on the next turn and
|
|
// mid-flight cancellation of this batch via turnCancelledRef).
|
|
const summaryAbort = new AbortController();
|
|
auxiliaryAbortRefsRef.current.add(summaryAbort);
|
|
|
|
// Capture the first callId so we can locate "our" tool_group at
|
|
// resolve time. If a newer tool_group has been added since we
|
|
// fired (i.e., the conversation moved on), we drop the summary
|
|
// rather than wedging the `● <label>` line between later items.
|
|
const anchorCallId = toolUseIds[0];
|
|
|
|
void generateToolUseSummary({
|
|
config,
|
|
tools: toolInfoForSummary,
|
|
signal: summaryAbort.signal,
|
|
lastAssistantText,
|
|
})
|
|
.then((summary) => {
|
|
auxiliaryAbortRefsRef.current.delete(summaryAbort);
|
|
const cancelled =
|
|
turnCancelledRef.current ||
|
|
abortControllerRef.current?.signal.aborted ||
|
|
summaryAbort.signal.aborted;
|
|
if (!summary || cancelled) return;
|
|
|
|
// Stale-summary check: only append if our tool_group is still
|
|
// the latest one in history. If a newer batch landed while
|
|
// the fast-model call was in flight, the conversation has
|
|
// moved past this batch and dropping in a `● <label>` line
|
|
// now would land it after later content (full mode) or
|
|
// attribute it to the wrong group (compact mode).
|
|
const currentHistory = historyRef.current;
|
|
const ourIdx = currentHistory.findIndex(
|
|
(h) =>
|
|
h.type === 'tool_group' &&
|
|
h.tools.some((t) => t.callId === anchorCallId),
|
|
);
|
|
if (ourIdx < 0) return;
|
|
const laterToolGroupExists = currentHistory
|
|
.slice(ourIdx + 1)
|
|
.some((h) => h.type === 'tool_group');
|
|
if (laterToolGroupExists) return;
|
|
|
|
if (summary && !cancelled) {
|
|
addItem(
|
|
{
|
|
type: 'tool_use_summary',
|
|
summary,
|
|
precedingToolUseIds: toolUseIds,
|
|
} as HistoryItemWithoutId,
|
|
Date.now(),
|
|
);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
auxiliaryAbortRefsRef.current.delete(summaryAbort);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Don't continue if model was switched due to quota error. On the
|
|
// shared loop the results answer the calls in history, as in a Goal
|
|
// turn.
|
|
if (modelSwitchedFromQuotaErrorRef.current) {
|
|
if (shared || toolGoalBinding) {
|
|
llmClient?.addHistory({ role: 'user', parts: responsesToSend });
|
|
}
|
|
if (toolGoalBinding) {
|
|
await failClosedGoalTurn(
|
|
toolGoalBinding,
|
|
'Goal tool continuation stopped after a model switch',
|
|
{ pauseReason: goalPauseReasonForFailure('') },
|
|
);
|
|
}
|
|
endToolInteraction('cancelled');
|
|
return;
|
|
}
|
|
|
|
// The shared batch noted the launches of the calls it executed; calls
|
|
// the scheduler ran alone, drained with it, are read from their
|
|
// results. The slots are checked now, as the continuation would wait.
|
|
const backgroundLaunchExhaustedCapacity =
|
|
llmTools.some((toolCall) =>
|
|
sharedResultsByCallId.has(toolCall.request.callId)
|
|
? shared?.backgroundLaunchCallIds.has(toolCall.request.callId) ===
|
|
true
|
|
: launchedBackgroundAgent(toolCall.request, toolCall.response),
|
|
) && backgroundSlotsSpent(config);
|
|
if (backgroundLaunchExhaustedCapacity) {
|
|
llmClient?.addHistory({ role: 'user', parts: responsesToSend });
|
|
if (toolGoalBinding) {
|
|
await failClosedGoalTurn(
|
|
toolGoalBinding,
|
|
'Goal tool continuation stopped: background capacity exhausted',
|
|
{ pauseReason: goalPauseReasonForFailure('') },
|
|
);
|
|
}
|
|
endToolInteraction(
|
|
'error',
|
|
'tool continuation capacity exhausted',
|
|
'continuation_capacity_exhausted',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const round: ToolResultRoundState = {
|
|
responsesToSend,
|
|
promptId,
|
|
continuationOwner,
|
|
continuationWasCancelled,
|
|
toolGoalBinding,
|
|
endToolInteraction,
|
|
releaseScheduler: shared?.releaseScheduler,
|
|
};
|
|
// On the shared loop the turn that awaits the batch sends the next
|
|
// round.
|
|
if (!shared) {
|
|
await sendToolResultRoundRef.current(round);
|
|
return undefined;
|
|
}
|
|
return () => sendToolResultRoundRef.current(round);
|
|
},
|
|
[
|
|
markToolsAsSubmitted,
|
|
llmClient,
|
|
performMemoryRefresh,
|
|
config,
|
|
addItem,
|
|
dualOutput,
|
|
bindGoalTurn,
|
|
failClosedGoalTurn,
|
|
releaseGoalTurn,
|
|
],
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
handleCompletedToolsRef.current = handleCompletedTools;
|
|
}, [handleCompletedTools]);
|
|
|
|
/**
|
|
* A turn's next round after its tool batch, as the shared loop's
|
|
* `nextRound` builds one: the input queued for the tool boundary (mid-turn
|
|
* messages, then teammate messages) follows the batch's results, and the
|
|
* round goes out as `ToolResult`. A turn cancelled meanwhile stops instead,
|
|
* with the results in history.
|
|
*/
|
|
const sendToolResultRound = useCallback(
|
|
async ({
|
|
responsesToSend,
|
|
promptId,
|
|
continuationOwner,
|
|
continuationWasCancelled,
|
|
toolGoalBinding,
|
|
endToolInteraction,
|
|
releaseScheduler,
|
|
}: ToolResultRoundState) => {
|
|
const toolResultPartsForPause = responsesToSend.slice();
|
|
|
|
// Drain steerable user messages at this sampling boundary and append
|
|
// them after the tool responses as genuine user content.
|
|
// Skip if the turn was cancelled — messages stay in queue for next turn.
|
|
const drained =
|
|
continuationOwner?.survivesGenerationChange ||
|
|
continuationWasCancelled()
|
|
? []
|
|
: (midTurnDrainRef?.current?.(
|
|
false,
|
|
Boolean(activeGoalAdmissionRef.current),
|
|
) ?? []);
|
|
let drainedSteer: SteerInput | undefined;
|
|
if (drained.length > 0) {
|
|
const midTurnAbort =
|
|
abortControllerRef.current ?? new AbortController();
|
|
const shouldTrackMidTurnAbort = !abortControllerRef.current;
|
|
if (shouldTrackMidTurnAbort) {
|
|
auxiliaryAbortRefsRef.current.add(midTurnAbort);
|
|
}
|
|
try {
|
|
drainedSteer = await resolveDrainedSteerMessages(
|
|
drained,
|
|
midTurnAbort.signal,
|
|
);
|
|
if (drainedSteer) {
|
|
responsesToSend.push(...drainedSteer.parts);
|
|
}
|
|
} finally {
|
|
if (shouldTrackMidTurnAbort) {
|
|
auxiliaryAbortRefsRef.current.delete(midTurnAbort);
|
|
midTurnAbort.abort();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Teammate messages get the same round-boundary delivery (#8172):
|
|
// waiting for `streamingState === Idle` holds them for the entire
|
|
// multi-round task because back-to-back tool rounds never reach
|
|
// Idle. Append after the tool-response parts (same ordering as
|
|
// steer above) so `tool_result` blocks lead the user message. The
|
|
// Idle drain stays as the fallback for turns that end without
|
|
// another tool round.
|
|
let drainedTeammates: ReturnType<typeof drainTeammateQueue> | undefined;
|
|
if (
|
|
!continuationOwner?.survivesGenerationChange &&
|
|
!continuationWasCancelled() &&
|
|
teammateQueueRef.current.length > 0
|
|
) {
|
|
drainedTeammates = drainTeammateQueue();
|
|
debugLogger.debug(
|
|
`draining ${drainedTeammates.entries.length} teammate message(s) into tool-round submission`,
|
|
);
|
|
responsesToSend.push(
|
|
...drainedTeammates.entries.map((entry) => ({
|
|
text: entry.modelText,
|
|
})),
|
|
);
|
|
}
|
|
// Settle the drained batch exactly once. The settlement carrier below
|
|
// is passed through the existing `steerInput` option so GeminiClient
|
|
// settles it next to the actual history push: acceptance compares the
|
|
// user-content push counter against the snapshot GeminiChat publishes
|
|
// on the request immediately before that push (no await between the
|
|
// snapshot and the push), and any exit that provably never pushed
|
|
// (hook block, cancel or failure before the push) restores the
|
|
// carrier unconditionally instead of consulting the global counter —
|
|
// a concurrent /btw push can therefore not supply the observed push.
|
|
const settleDrainedTeammates = (accepted: boolean) => {
|
|
if (!drainedTeammates || drainedTeammates.entries.length === 0) {
|
|
return;
|
|
}
|
|
const { entries, restore, generation } = drainedTeammates;
|
|
drainedTeammates = undefined;
|
|
const envelopeTexts = entries.map((entry) => entry.modelText);
|
|
// A TeamManager swap moved the generation while this batch was in
|
|
// flight: it belongs to the outgoing team no matter how it now
|
|
// settles, and must not be journaled into, or recorded as retry
|
|
// debt against, the NEW team's session. (The restore side of this
|
|
// guard lives in `drainTeammateQueue`'s restore itself.)
|
|
const swapped = teammateQueueGenerationRef.current !== generation;
|
|
// The envelopes are baked into the Ctrl+Y retry payload either way:
|
|
// `submitQuery` stored `finalQueryToSend` (envelope parts included)
|
|
// in `lastPromptRef` before the client call settled. Strip them on
|
|
// BOTH outcomes — a restored batch is redelivered by the Idle
|
|
// fallback, and an accepted batch is already in the session
|
|
// history, so a retry that re-sends them would hand the leader the
|
|
// identical report twice (accepted-then-failed-mid-stream retry,
|
|
// or retry + Idle drain after a restore). The trailing-match guard
|
|
// inside the helper keeps this a no-op when settlement fires before
|
|
// `submitQuery` stored the payload (cancel and preempt paths below)
|
|
// or after a later submission overwrote it. One exception to
|
|
// "already in the session history": an accepted round can still
|
|
// fail terminally BEFORE any content, leaving the pushed entry as
|
|
// a trailing orphan that the Retry path pops before re-pushing the
|
|
// payload. The accept branch records retry debt
|
|
// (`boundaryEnvelopeRetryDebtRef`) so `retryLastPrompt` re-attaches
|
|
// the envelopes exactly when that orphan pop would drop them.
|
|
if (accepted) {
|
|
stripTrailingTextsFromLastPrompt(envelopeTexts);
|
|
if (swapped) {
|
|
debugLogger.debug(
|
|
`dropping ${entries.length} accepted teammate message(s): team changed while in flight`,
|
|
);
|
|
return;
|
|
}
|
|
// The envelopes are in the session history; requeueing them
|
|
// would deliver them twice. Record the delivery instead, the
|
|
// same `recordNotification` journaling the hook-exempt
|
|
// SendMessageType.Teammate path gives Idle deliveries, so a
|
|
// resumed session restores the `● …` item and the envelopes
|
|
// stay in the reconstructed model context.
|
|
debugLogger.debug(
|
|
`recording ${entries.length} boundary-delivered teammate message(s)`,
|
|
);
|
|
config.getChatRecordingService?.()?.recordNotification?.(
|
|
entries.map((entry) => ({ text: entry.modelText })),
|
|
entries.map((entry) => entry.display).join('; '),
|
|
undefined,
|
|
toolGoalBinding?.permit,
|
|
);
|
|
// See `boundaryEnvelopeRetryDebtRef`: if this accepted round
|
|
// still fails terminally before any content, the pushed entry
|
|
// becomes the trailing orphan the Retry path pops, and a payload
|
|
// without these envelopes would lose them. Record the debt
|
|
// UNCONDITIONALLY, not only when the strip above matched: a
|
|
// concurrent submission admitted during the time-to-first-token
|
|
// window can overwrite `lastPromptRef` before this settlement
|
|
// fires, and the orphan pop drops the pushed entry regardless
|
|
// of what `lastPromptRef` holds at retry time — gating the debt
|
|
// on the strip match would silently drop the envelopes in that
|
|
// case while the journal still claims delivered. The retry-time
|
|
// orphan check keeps double delivery impossible: envelopes are
|
|
// only re-attached when the pushed entry really is the trailing
|
|
// orphan the pop is about to drop.
|
|
const envelopeParts = entries.map((entry) => ({
|
|
text: entry.modelText,
|
|
}));
|
|
boundaryEnvelopeRetryDebtRef.current.push({
|
|
envelopeParts,
|
|
pushedEntryParts: capturePushedTeammateEntry(envelopeParts),
|
|
});
|
|
return;
|
|
}
|
|
// The submission never reached the model (cancelled/preempted
|
|
// before send, admission failure, hook block): hand the batch
|
|
// back to the queue for the Idle fallback — unless a swap
|
|
// invalidated it, see the restore-side guard.
|
|
debugLogger.debug(
|
|
`restoring ${entries.length} teammate message(s) after failed/cancelled submission`,
|
|
);
|
|
restore();
|
|
stripTrailingTextsFromLastPrompt(envelopeTexts);
|
|
};
|
|
const submissionSettlement: SteerInput | undefined =
|
|
drainedSteer || drainedTeammates
|
|
? {
|
|
parts: drainedSteer?.parts ?? [],
|
|
accept: () => {
|
|
drainedSteer?.accept();
|
|
settleDrainedTeammates(true);
|
|
},
|
|
restore: () => {
|
|
drainedSteer?.restore();
|
|
settleDrainedTeammates(false);
|
|
},
|
|
}
|
|
: undefined;
|
|
|
|
// Both exits below leave a batch whose callIds are already marked
|
|
// submitted, so the responses have to reach history here or the
|
|
// model's function calls stay unanswered and the next `/goal resume`
|
|
// sends an unpaired call -- the same pairing the cancellation check
|
|
// above owes its own batch.
|
|
if (continuationWasCancelled()) {
|
|
drainedSteer?.restore();
|
|
settleDrainedTeammates(false);
|
|
if (toolGoalBinding) {
|
|
if (llmClient) {
|
|
llmClient.addHistory({
|
|
role: 'user',
|
|
parts: toolResultPartsForPause,
|
|
});
|
|
}
|
|
await failClosedGoalTurn(
|
|
toolGoalBinding,
|
|
'Goal tool continuation was cancelled',
|
|
{ userCancelled: true },
|
|
);
|
|
}
|
|
endToolInteraction('cancelled');
|
|
return;
|
|
}
|
|
if (toolGoalBinding?.controller.signal.aborted) {
|
|
drainedSteer?.restore();
|
|
settleDrainedTeammates(false);
|
|
if (llmClient) {
|
|
llmClient.addHistory({
|
|
role: 'user',
|
|
parts: toolResultPartsForPause,
|
|
});
|
|
}
|
|
await failClosedGoalTurn(
|
|
toolGoalBinding,
|
|
'Goal tool continuation was preempted',
|
|
{ pauseReason: GOAL_PAUSE_REASON_USER_INTERRUPT },
|
|
);
|
|
endToolInteraction('cancelled');
|
|
return;
|
|
}
|
|
|
|
releaseScheduler?.();
|
|
await submitQuery(responsesToSend, SendMessageType.ToolResult, promptId, {
|
|
steerInput: submissionSettlement,
|
|
onDelivered: () => submissionSettlement?.accept(),
|
|
onAdmissionFailed: () => {
|
|
submissionSettlement?.restore();
|
|
endToolInteraction(
|
|
'error',
|
|
'tool continuation admission failed',
|
|
'continuation_admission_failed',
|
|
);
|
|
},
|
|
onDeliveryFailed: () => {
|
|
submissionSettlement?.restore();
|
|
// The continuation's own failure already marks the Mod turn; a
|
|
// stale flag from a concurrent /btw must not.
|
|
endToolInteraction(
|
|
'error',
|
|
'tool continuation delivery failed',
|
|
'continuation_delivery_failed',
|
|
false,
|
|
);
|
|
},
|
|
goalBinding: toolGoalBinding,
|
|
toolContinuationOwner: continuationOwner,
|
|
});
|
|
},
|
|
[
|
|
submitQuery,
|
|
llmClient,
|
|
config,
|
|
midTurnDrainRef,
|
|
resolveDrainedSteerMessages,
|
|
drainTeammateQueue,
|
|
failClosedGoalTurn,
|
|
stripTrailingTextsFromLastPrompt,
|
|
capturePushedTeammateEntry,
|
|
],
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
sendToolResultRoundRef.current = sendToolResultRound;
|
|
}, [sendToolResultRound]);
|
|
|
|
// Adds a completed batch's final tool states to the history for display.
|
|
const addCompletedToolGroup = useCallback(
|
|
(calls: InteractiveToolCall[], batchId: string | undefined) => {
|
|
const toolGroupDisplay = mapTrackedToolCallsToDisplay(
|
|
calls as TrackedToolCall[],
|
|
config.getProjectRoot(),
|
|
);
|
|
toolGroupDisplay.batchId = batchId;
|
|
addItem(toolGroupDisplay, Date.now());
|
|
},
|
|
[config, addItem],
|
|
);
|
|
|
|
// Commits a completed batch's tool group, then hands it to
|
|
// handleCompletedTools: the scheduler's batches, and the shared loop's with
|
|
// their finalized results. A shared batch commits its tool group when the
|
|
// scheduler completes it and passes that group's batch id.
|
|
const commitCompletedToolBatch = useCallback(
|
|
async (
|
|
completedToolCallsFromScheduler: InteractiveToolCall[],
|
|
shared?: SharedToolBatchResults,
|
|
committedToolGroup?: { batchId: string | undefined },
|
|
): Promise<ToolResultRound | undefined> => {
|
|
if (completedToolCallsFromScheduler.length > 0) {
|
|
// The scheduler empties the display list before this callback
|
|
// (#9420), so once the issuing stream has settled nothing else
|
|
// keeps streamingState off Idle while the callback runs. A
|
|
// phantom Idle commit mid-turn would drain queued turns
|
|
// concurrently with the pending ToolResult continuation, fire
|
|
// YOLO turn-finished telemetry at the batch boundary, and no-op
|
|
// Esc cancellation. Re-assert the in-flight flags for the whole
|
|
// window; the release in finally settles them once the
|
|
// continuation is done.
|
|
setSubmissionInFlight(true);
|
|
setIsResponding(true);
|
|
const releaseToolCompletionActivity = retainSubmissionActivity(
|
|
submissionLeaseGenerationRef.current,
|
|
);
|
|
// Captured before the await: the continuation scheduled inside
|
|
// handleCompletedTools may re-register a reused callId for the
|
|
// NEXT batch, and the cleanup below must not delete that entry.
|
|
const batchId = committedToolGroup
|
|
? committedToolGroup.batchId
|
|
: getToolBatchId(completedToolCallsFromScheduler[0].request.callId);
|
|
let nextRound: ToolResultRound | undefined;
|
|
try {
|
|
if (!committedToolGroup) {
|
|
addCompletedToolGroup(completedToolCallsFromScheduler, batchId);
|
|
}
|
|
|
|
// Handle tool response submission immediately when tools complete
|
|
nextRound = await handleCompletedTools(
|
|
completedToolCallsFromScheduler as TrackedToolCall[],
|
|
shared,
|
|
);
|
|
} finally {
|
|
// On the shared loop the batch keeps the TUI off Idle until the
|
|
// turn sent the next round.
|
|
releaseToolCompletionActivity();
|
|
// Entries are only needed until the batch commits; the scheduler
|
|
// clears its display copy right after this callback returns.
|
|
// Delete only entries still pointing at this batch's id: a
|
|
// provider reusing a wire callId may have already registered
|
|
// the next batch under the same key during the await above.
|
|
for (const tc of completedToolCallsFromScheduler) {
|
|
if (
|
|
toolBatchIdByCallIdRef.current.get(tc.request.callId) === batchId
|
|
) {
|
|
toolBatchIdByCallIdRef.current.delete(tc.request.callId);
|
|
}
|
|
}
|
|
}
|
|
return nextRound;
|
|
}
|
|
return undefined;
|
|
},
|
|
[
|
|
setSubmissionInFlight,
|
|
retainSubmissionActivity,
|
|
getToolBatchId,
|
|
addCompletedToolGroup,
|
|
handleCompletedTools,
|
|
],
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
commitCompletedToolBatchRef.current = commitCompletedToolBatch;
|
|
}, [commitCompletedToolBatch]);
|
|
|
|
const runSharedToolBatch = useCallback(
|
|
async (
|
|
batch: SharedToolBatch,
|
|
turn: SharedToolBatchTurn,
|
|
): Promise<ToolResultRound | undefined> => {
|
|
debugLogger.debug(
|
|
`[runSharedToolBatch] tool batch on the shared agent loop (prompt ${batch.promptId}, ${batch.calls.length} call(s))`,
|
|
);
|
|
let settled: InteractiveToolCall[] = [];
|
|
let releaseBatchActivity: (() => void) | undefined;
|
|
let owner: SharedToolBatchResults['owner'] | undefined;
|
|
let committedToolGroup: { batchId: string | undefined } | undefined;
|
|
let nextRound: ToolResultRound | undefined;
|
|
let finishBatch!: () => void;
|
|
const batchFinished = new Promise<void>((resolve) => {
|
|
finishBatch = resolve;
|
|
});
|
|
// The current path holds the scheduler through the next round's whole
|
|
// stream; this hold ends as the next round is about to be sent, since
|
|
// its calls need the scheduler, or as the completion step stops.
|
|
let releaseScheduler!: () => void;
|
|
const schedulerHold = new Promise<void>((resolve) => {
|
|
releaseScheduler = resolve;
|
|
});
|
|
const executor = createInkToolExecutor(
|
|
scheduleToolCalls,
|
|
batch.modelOverride,
|
|
(calls) => {
|
|
settled = calls;
|
|
// As the scheduler's completion does: keep the turn off Idle
|
|
// while the batch is finalized.
|
|
setSubmissionInFlight(true);
|
|
setIsResponding(true);
|
|
releaseBatchActivity = retainSubmissionActivity(
|
|
submissionLeaseGenerationRef.current,
|
|
);
|
|
if (calls.length === 0) return;
|
|
// In the render that clears the scheduler's display list (#9420).
|
|
const batchId = getToolBatchId(calls[0].request.callId);
|
|
try {
|
|
addCompletedToolGroup(calls, batchId);
|
|
committedToolGroup = { batchId };
|
|
} catch (error) {
|
|
debugLogger.error('Committing a shared tool group failed:', error);
|
|
}
|
|
// Taken here, before the batch finalizes, as the scheduler's
|
|
// completion takes them, from the client the TUI holds now. With
|
|
// no chat to continue (a reset under way), the batch ends without
|
|
// a continuation.
|
|
try {
|
|
owner = {
|
|
sessionId: config.getSessionId(),
|
|
chat: llmClientRef.current?.getChat(),
|
|
};
|
|
} catch (error) {
|
|
debugLogger.error(
|
|
'Capturing a shared tool batch session failed:',
|
|
error,
|
|
);
|
|
}
|
|
// The batch ends when no model stream is active, as the current
|
|
// path holds a batch that completes during one, so its stops and
|
|
// results see the session as the continuation will.
|
|
return waitForModelStreamsIdle(batchFinished);
|
|
},
|
|
schedulerHold,
|
|
);
|
|
const backgroundLaunchCallIds = new Set<string>();
|
|
// Replay answers the batch marks as sent but never sends, when it
|
|
// throws, must not count as answered.
|
|
const providerKeys = batch.calls.flatMap(({ providerCallId, callId }) =>
|
|
(providerCallId ?? callId) ? [providerCallId ?? callId] : [],
|
|
);
|
|
const respondedBefore = new Set(
|
|
providerKeys.filter((key) =>
|
|
duplicateProviderToolCallResponseIdsRef.current.has(key),
|
|
),
|
|
);
|
|
let committed = false;
|
|
try {
|
|
const result = await runToolBatch(batch.calls, {
|
|
config,
|
|
client: llmClient,
|
|
signal: batch.signal,
|
|
duplicates: {
|
|
handled: batch.handled,
|
|
responded: duplicateProviderToolCallResponseIdsRef.current,
|
|
},
|
|
executor,
|
|
// Esc keeps the results, which the TUI pairs into history.
|
|
keepCancelledResults: true,
|
|
// The TUI records only the results it sends, in its own session.
|
|
deferRecording: true,
|
|
host: {
|
|
onToolResult: ({ request, response, origin }) => {
|
|
if (origin !== 'executed') {
|
|
dualOutput?.emitToolResult(request, response);
|
|
}
|
|
if (
|
|
origin === 'executed' &&
|
|
launchedBackgroundAgent(request, response)
|
|
) {
|
|
backgroundLaunchCallIds.add(request.callId);
|
|
}
|
|
// The TUI's stops end the batch: a quota model switch, or a
|
|
// background agent launch that spent the last background
|
|
// slot. The completion step checks both again when it sends.
|
|
return (
|
|
modelSwitchedFromQuotaErrorRef.current ||
|
|
(backgroundLaunchCallIds.size > 0 &&
|
|
backgroundSlotsSpent(config))
|
|
);
|
|
},
|
|
},
|
|
});
|
|
if (settled.length === 0 || !owner) return undefined;
|
|
committed = true;
|
|
// Through the ref: the batch may have waited on approvals or long
|
|
// tools, and the continuation must use the TUI's state as it is now
|
|
// (a quota fallback, a new submitQuery), as the scheduler's
|
|
// completion does through its onComplete ref.
|
|
nextRound = await commitCompletedToolBatchRef.current(
|
|
settled,
|
|
{
|
|
responses: result.responses ?? [],
|
|
ranCallIds: countedCallIds(settled),
|
|
promptId: batch.promptId,
|
|
owner,
|
|
backgroundLaunchCallIds,
|
|
releaseScheduler,
|
|
},
|
|
committedToolGroup,
|
|
);
|
|
} catch (error) {
|
|
debugLogger.error('Shared tool batch failed:', error);
|
|
// The scheduler already settled the calls: complete them without
|
|
// the batch's results, as the current path would, so the tool group
|
|
// is kept and the turn goes on. The batch counted them already.
|
|
if (!committed) {
|
|
for (const key of providerKeys) {
|
|
if (!respondedBefore.has(key)) {
|
|
duplicateProviderToolCallResponseIdsRef.current.delete(key);
|
|
}
|
|
}
|
|
}
|
|
if (!committed && settled.length > 0 && owner) {
|
|
try {
|
|
nextRound = await commitCompletedToolBatchRef.current(
|
|
settled,
|
|
{
|
|
responses: [],
|
|
ranCallIds: countedCallIds(settled),
|
|
promptId: batch.promptId,
|
|
owner,
|
|
backgroundLaunchCallIds: new Set(),
|
|
releaseScheduler,
|
|
},
|
|
committedToolGroup,
|
|
);
|
|
} catch (commitError) {
|
|
debugLogger.error(
|
|
'Completing a failed shared tool batch failed:',
|
|
commitError,
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
// A next round keeps the scheduler and the TUI's activity until it
|
|
// is sent.
|
|
if (!nextRound) {
|
|
releaseScheduler();
|
|
releaseBatchActivity?.();
|
|
}
|
|
finishBatch();
|
|
}
|
|
const round = nextRound;
|
|
if (!round) return undefined;
|
|
const send: ToolResultRound = async () => {
|
|
try {
|
|
await round();
|
|
} catch (error) {
|
|
debugLogger.error(
|
|
'Sending the next round of a shared tool batch failed:',
|
|
error,
|
|
);
|
|
} finally {
|
|
releaseScheduler();
|
|
releaseBatchActivity?.();
|
|
}
|
|
};
|
|
// The turn sends the next round, unless it did not wait for the batch.
|
|
if (!turn.left) {
|
|
turn.round = send;
|
|
return send;
|
|
}
|
|
await send();
|
|
return undefined;
|
|
},
|
|
[
|
|
scheduleToolCalls,
|
|
setSubmissionInFlight,
|
|
retainSubmissionActivity,
|
|
config,
|
|
llmClient,
|
|
dualOutput,
|
|
getToolBatchId,
|
|
addCompletedToolGroup,
|
|
waitForModelStreamsIdle,
|
|
],
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
runSharedToolBatchRef.current = runSharedToolBatch;
|
|
}, [runSharedToolBatch]);
|
|
|
|
const pendingHistoryItems = useMemo(() => {
|
|
// A completed batch precedes the response that consumes its results.
|
|
const toolGroupFinished = pendingToolCallGroupDisplay?.tools.every(
|
|
(tool) =>
|
|
tool.status === ToolCallStatus.Success ||
|
|
tool.status === ToolCallStatus.Error ||
|
|
tool.status === ToolCallStatus.Canceled,
|
|
);
|
|
return [
|
|
toolGroupFinished ? pendingToolCallGroupDisplay : undefined,
|
|
...pendingAssistantItems,
|
|
pendingThoughtItem,
|
|
toolGroupFinished ? undefined : pendingToolCallGroupDisplay,
|
|
pendingHistoryItem,
|
|
pendingRetryErrorItem,
|
|
pendingRetryCountdownItem,
|
|
].filter((i) => i !== undefined && i !== null);
|
|
}, [
|
|
pendingThoughtItem,
|
|
pendingAssistantItems,
|
|
pendingHistoryItem,
|
|
pendingRetryErrorItem,
|
|
pendingRetryCountdownItem,
|
|
pendingToolCallGroupDisplay,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const saveRestorableToolCalls = async () => {
|
|
if (!config.getFileCheckpointingEnabled()) {
|
|
return;
|
|
}
|
|
const restorableToolCalls = toolCalls.filter(
|
|
(toolCall) =>
|
|
EDIT_TOOL_NAMES.has(toolCall.request.name) &&
|
|
toolCall.status === 'awaiting_approval' &&
|
|
!toolCall.request.isClientInitiated,
|
|
);
|
|
|
|
if (restorableToolCalls.length > 0) {
|
|
const checkpointDir = storage.getProjectTempCheckpointsDir();
|
|
|
|
if (!checkpointDir) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await fs.mkdir(checkpointDir, { recursive: true });
|
|
} catch (error) {
|
|
if (!isNodeError(error) || error.code !== 'EEXIST') {
|
|
onDebugMessage(
|
|
`Failed to create checkpoint directory: ${getErrorMessage(error)}`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
for (const toolCall of restorableToolCalls) {
|
|
const filePath = (toolCall.request.args['file_path'] ??
|
|
toolCall.request.args['notebook_path']) as string;
|
|
if (!filePath) {
|
|
onDebugMessage(
|
|
`Skipping restorable tool call due to missing file_path: ${toolCall.request.name}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const promptId = toolCall.request.prompt_id;
|
|
const timestamp = new Date()
|
|
.toISOString()
|
|
.replace(/:/g, '-')
|
|
.replace(/\./g, '_');
|
|
const toolName = toolCall.request.name;
|
|
const fileName = path.basename(filePath);
|
|
const toolCallWithSnapshotFileName = `${timestamp}-${fileName}-${toolName}.json`;
|
|
const clientHistory = llmClient?.getHistoryShallow();
|
|
// JSON.stringify drops the Symbol-keyed prompt identity, so
|
|
// persist it as a parallel array for /restore to re-mark against.
|
|
const promptIds = clientHistory?.map(
|
|
(content) => getApiHistoryPromptId(content) ?? null,
|
|
);
|
|
const toolCallWithSnapshotFilePath = path.join(
|
|
checkpointDir,
|
|
toolCallWithSnapshotFileName,
|
|
);
|
|
|
|
await fs.writeFile(
|
|
toolCallWithSnapshotFilePath,
|
|
JSON.stringify(
|
|
{
|
|
history,
|
|
clientHistory,
|
|
...(promptIds?.some(Boolean) ? { promptIds } : {}),
|
|
toolCall: {
|
|
callId: toolCall.request.callId,
|
|
name: toolCall.request.name,
|
|
args: toolCall.request.args,
|
|
},
|
|
promptId,
|
|
filePath,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
onDebugMessage(
|
|
`Failed to create checkpoint for ${filePath}: ${getErrorMessage(
|
|
error,
|
|
)}. This may indicate a problem with file system permissions.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
saveRestorableToolCalls();
|
|
}, [toolCalls, config, onDebugMessage, history, llmClient, storage]);
|
|
|
|
// ─── Unified notification queue (cron + background agents) ──────
|
|
const notificationQueueRef = useRef<QueuedNotification[]>([]);
|
|
const [notificationTrigger, setNotificationTrigger] = useState(0);
|
|
/**
|
|
* Notifications lost to queue overflow since the last drain, reported as one
|
|
* summary on the next drained turn. Per-loss lines would reproduce the very
|
|
* flooding the cap exists to stop.
|
|
*/
|
|
const droppedNotificationsRef = useRef(new DroppedNotificationTally());
|
|
/**
|
|
* A summary already taken from the tally but not yet accepted by a turn.
|
|
* `take()` resets the tally, so a rejected admission would otherwise lose
|
|
* the only record of what overflow discarded; the drain parks it here and
|
|
* the next drain reuses it, exactly as it re-queues the rejected batch.
|
|
*/
|
|
const pendingDroppedSummaryRef = useRef<PendingDroppedSummary | undefined>(
|
|
undefined,
|
|
);
|
|
/**
|
|
* Admit one notification into the shared queue, evicting or dropping when it
|
|
* is full. Agent and workflow results and cron prompts are protected: an
|
|
* agent result is the only copy of what a background agent produced, and a
|
|
* cron prompt is work the user scheduled. Shell results and monitor pulses
|
|
* absorb the overflow, pulses first — the next poll supersedes them anyway.
|
|
*/
|
|
const admitNotification = useCallback(
|
|
(item: QueuedNotification): void => {
|
|
const queue = notificationQueueRef.current;
|
|
const admission = decideNotificationAdmission(queue, item, {
|
|
max: MAX_BACKGROUND_NOTIFICATION_QUEUE,
|
|
isProtected: isProtectedNotification,
|
|
});
|
|
if (admission.action === 'drop') {
|
|
debugLogger.warn(
|
|
`Notification queue overflow: dropping task=${item.taskId ?? 'unknown'} kind=${item.kind} because ${admission.reason === 'all-protected' ? 'every queued notification is protected' : 'the next monitor pulse will supersede it'}`,
|
|
);
|
|
droppedNotificationsRef.current.record(item);
|
|
return;
|
|
}
|
|
if (admission.action === 'evict') {
|
|
const [evicted] = queue.splice(admission.index, 1);
|
|
debugLogger.warn(
|
|
`Notification queue overflow: evicting task=${evicted?.taskId ?? 'unknown'} kind=${evicted?.kind ?? 'unknown'}`,
|
|
);
|
|
if (evicted) {
|
|
const cancelledPulse =
|
|
evicted.interim &&
|
|
evicted.taskId !== undefined &&
|
|
config.getMonitorRegistry().get(evicted.taskId)?.status ===
|
|
'cancelled';
|
|
if (!cancelledPulse) droppedNotificationsRef.current.record(evicted);
|
|
}
|
|
}
|
|
queue.push(item);
|
|
setNotificationTrigger((n) => n + 1);
|
|
},
|
|
[config],
|
|
);
|
|
// Last time an interim-monitor-led notification batch started a model turn
|
|
// (#10818 cooldown).
|
|
const lastInterimMonitorTurnAtRef = useRef(0);
|
|
const goalQueuePendingCount =
|
|
goalQueueRef?.current?.getPendingSubmissionCount?.() ?? 0;
|
|
const claimSystemGoalTurn = useCallback((): {
|
|
ready: boolean;
|
|
claimGoalTurn?: () => QueuedGoalTurn | undefined;
|
|
} => {
|
|
if (goalQueueRef?.current?.hasQueuedUserMessages?.()) {
|
|
return { ready: false };
|
|
}
|
|
let goalOwnsTurn = false;
|
|
try {
|
|
goalOwnsTurn = goalRequiresExactPermit(
|
|
config.getGoalRuntime().getSnapshot(),
|
|
);
|
|
} catch {
|
|
goalOwnsTurn = false;
|
|
}
|
|
if (!goalOwnsTurn) return { ready: true };
|
|
if ((goalQueueRef?.current?.getPendingSubmissionCount?.() ?? 0) === 0) {
|
|
return { ready: false };
|
|
}
|
|
return {
|
|
ready: true,
|
|
claimGoalTurn: () => {
|
|
if (goalQueueRef?.current?.hasQueuedUserMessages?.()) return undefined;
|
|
return goalQueueRef?.current?.claimGoalTurn?.();
|
|
},
|
|
};
|
|
}, [config, goalQueueRef]);
|
|
|
|
const getAutonomousLoopTickResolver = useCallback(() => {
|
|
autonomousLoopTickResolverRef.current ??= new AutonomousLoopTickResolver();
|
|
return autonomousLoopTickResolverRef.current;
|
|
}, []);
|
|
const notificationQueueSessionIdRef = useRef(sessionStates.sessionId);
|
|
|
|
useEffect(() => {
|
|
if (notificationQueueSessionIdRef.current === sessionStates.sessionId) {
|
|
return;
|
|
}
|
|
notificationQueueSessionIdRef.current = sessionStates.sessionId;
|
|
notificationQueueRef.current = [];
|
|
droppedNotificationsRef.current.clear();
|
|
pendingDroppedSummaryRef.current = undefined;
|
|
autonomousLoopTickResolverRef.current?.resetCache();
|
|
}, [sessionStates.sessionId]);
|
|
|
|
// Current sessionId for the cron effect, read through a ref so the
|
|
// effect doesn't list sessionId as a dep. Keeping it out of the deps is
|
|
// deliberate: /clear swaps the sessionId mid-session, and a re-run would
|
|
// fire the cleanup below — printing a false "loops cancelled" notice and
|
|
// tearing down a scheduler that immediately restarts. The effect should
|
|
// run once on mount and clean up only on real unmount.
|
|
const cronSessionIdRef = useRef(sessionStates.sessionId);
|
|
cronSessionIdRef.current = sessionStates.sessionId;
|
|
|
|
// Start the cron scheduler once config is initialized, stop on unmount.
|
|
// Cron fires enqueue onto the shared notification queue.
|
|
// Gated on isConfigInitialized: without this gate, enableDurable() runs
|
|
// before config.initialize() completes, and overdue-task fires delivered
|
|
// through the notification drain reach a chat client whose startChat() has
|
|
// not yet run — producing "Chat not initialized" on every fresh launch
|
|
// that has pending durable work (#5022). This matches the ordering the
|
|
// ACP (Session.ts) and headless (nonInteractiveCli.ts) paths already use.
|
|
useEffect(() => {
|
|
if (!isConfigInitialized) return;
|
|
if (!config.isCronEnabled()) return;
|
|
const scheduler = config.getCronScheduler();
|
|
|
|
let stopped = false;
|
|
// Await enableDurable before start so overdue fires buffer into
|
|
// pendingFires (onFire is still null) and flush through start()'s
|
|
// buffer-drain — matching the ACP and headless startup order.
|
|
void (async () => {
|
|
try {
|
|
// Enable durable (file-backed) cron support (loads tasks from the
|
|
// user's per-project runtime dir, acquires the lock). The tasks file
|
|
// lives under ~/.qwen, not the working tree, so it's user-owned
|
|
// rather than project-controlled — no folder-trust gate needed; the
|
|
// user's own loops run regardless of how the folder is trusted.
|
|
// Missed one-shots arrive as late fires through the start() callback.
|
|
await scheduler.enableDurable(cronSessionIdRef.current);
|
|
} catch (err) {
|
|
// Fall through (no `return`): a failed enableDurable must NOT skip
|
|
// start(), or session-only cron tasks (created via cron_create during
|
|
// this session) would silently never fire. Only durable/persistent
|
|
// tasks are lost when enableDurable fails. Pre-#5022 the unconditional
|
|
// start() preserved this; keep that behavior.
|
|
debugLogger.warn(
|
|
`Durable cron init failed — persistent tasks will not fire in this session: ${err}`,
|
|
);
|
|
}
|
|
// Unmount may have happened during the await above; the cleanup below
|
|
// already ran scheduler.stop(), so do not (re)install onFire.
|
|
if (stopped) return;
|
|
scheduler.start(
|
|
(job: {
|
|
id?: string;
|
|
prompt: string;
|
|
cronExpr?: string;
|
|
missed?: boolean;
|
|
todoWorkChainId?: string;
|
|
}) => {
|
|
const source = job.cronExpr === '@wakeup' ? 'Loop' : 'Cron';
|
|
const autonomousMode = detectAutonomousSentinel(job.prompt);
|
|
let label = job.prompt.slice(0, 40);
|
|
let modelText = job.prompt;
|
|
if (autonomousMode) {
|
|
if (job.missed) return;
|
|
const resolver = getAutonomousLoopTickResolver();
|
|
const tick = resolver.resolveAutonomous(autonomousMode);
|
|
label = 'Autonomous loop tick';
|
|
modelText = tick.modelText;
|
|
admitNotification({
|
|
displayText: `${job.missed ? 'Missed' : source}: ${label}`,
|
|
modelText,
|
|
sendMessageType: SendMessageType.Cron,
|
|
kind: 'cron',
|
|
taskId: job.id,
|
|
todoWorkChainId: job.todoWorkChainId,
|
|
onDelivered: () => resolver.markDelivered(),
|
|
});
|
|
return;
|
|
}
|
|
admitNotification({
|
|
displayText: `${job.missed ? 'Missed' : source}: ${label}`,
|
|
modelText,
|
|
sendMessageType: SendMessageType.Cron,
|
|
kind: 'cron',
|
|
taskId: job.id,
|
|
todoWorkChainId: job.todoWorkChainId,
|
|
});
|
|
},
|
|
);
|
|
})();
|
|
|
|
return () => {
|
|
stopped = true;
|
|
const summary = scheduler.getExitSummary();
|
|
scheduler.stop();
|
|
if (summary) {
|
|
process.stderr.write(summary + '\n');
|
|
}
|
|
};
|
|
}, [
|
|
admitNotification,
|
|
config,
|
|
getAutonomousLoopTickResolver,
|
|
isConfigInitialized,
|
|
]);
|
|
|
|
// Register background agent notification callback onto the shared queue.
|
|
useEffect(() => {
|
|
const registry = config.getBackgroundTaskRegistry();
|
|
registry.setNotificationCallback((displayText, modelText, meta) => {
|
|
admitNotification({
|
|
displayText,
|
|
modelText,
|
|
sendMessageType: SendMessageType.Notification,
|
|
kind: 'agent',
|
|
taskId: meta?.agentId,
|
|
todoWorkChainId: meta?.todoWorkChainId,
|
|
});
|
|
});
|
|
return () => {
|
|
registry.setNotificationCallback(undefined);
|
|
};
|
|
}, [admitNotification, config]);
|
|
|
|
// Register background shell terminal notification callback onto the shared queue.
|
|
useEffect(() => {
|
|
const registry = config.getBackgroundShellRegistry();
|
|
registry.setNotificationCallback((displayText, modelText, meta) => {
|
|
admitNotification({
|
|
displayText,
|
|
modelText,
|
|
sendMessageType: SendMessageType.Notification,
|
|
kind: 'shell',
|
|
taskId: meta?.shellId,
|
|
todoWorkChainId: meta?.todoWorkChainId,
|
|
});
|
|
});
|
|
return () => {
|
|
registry.setNotificationCallback(undefined);
|
|
};
|
|
}, [admitNotification, config]);
|
|
|
|
// Register background and client-started foreground workflow completions.
|
|
// The registry keeps this separate from its terminal-bell subscriber.
|
|
useEffect(() => {
|
|
const registry = config.getWorkflowRunRegistry();
|
|
registry.setCompletionCallback((displayText, modelText, meta) => {
|
|
// The result must remain visible even if the model request is delayed
|
|
// or fails. Background notifications retain their existing drain timing.
|
|
const displayed = meta.isBackgrounded === false;
|
|
if (displayed) {
|
|
addItem({ type: 'notification', text: displayText }, Date.now());
|
|
}
|
|
admitNotification({
|
|
displayText,
|
|
modelText,
|
|
sendMessageType: SendMessageType.Notification,
|
|
kind: 'workflow',
|
|
taskId: meta.runId,
|
|
todoWorkChainId: meta.todoWorkChainId,
|
|
displayed,
|
|
});
|
|
});
|
|
return () => {
|
|
registry.setCompletionCallback(undefined);
|
|
};
|
|
}, [addItem, admitNotification, config]);
|
|
|
|
// Register monitor notification callback onto the shared queue.
|
|
useEffect(() => {
|
|
const registry = config.getMonitorRegistry();
|
|
registry.setNotificationCallback((displayText, modelText, meta) => {
|
|
if (meta.status === 'running' && typeof registry.get === 'function') {
|
|
const entry = registry.get(meta.monitorId);
|
|
if (!entry || entry.status !== 'running') return;
|
|
}
|
|
admitNotification({
|
|
displayText,
|
|
modelText,
|
|
sendMessageType: SendMessageType.Notification,
|
|
kind: 'monitor',
|
|
taskId: meta.monitorId,
|
|
interim: meta.status === 'running',
|
|
monitor: { id: meta.monitorId, status: meta.status },
|
|
todoWorkChainId: meta.todoWorkChainId,
|
|
});
|
|
});
|
|
return () => {
|
|
registry.setNotificationCallback(undefined);
|
|
};
|
|
}, [admitNotification, config]);
|
|
|
|
// When idle, batch-drain all contiguous same-type notifications from the
|
|
// front of the queue into a single API call. This reduces token waste: N
|
|
// notifications that accumulate while the model is busy become 1 roundtrip
|
|
// instead of N sequential ones. Skip when another submission is in flight
|
|
// (e.g. the teammate drain effect won this render) — the queue stays
|
|
// intact and the effect will re-fire when streamingState returns to Idle.
|
|
useEffect(() => {
|
|
if (
|
|
streamingState !== StreamingState.Idle ||
|
|
isSubmittingQueryRef.current ||
|
|
notificationQueueRef.current.length === 0
|
|
) {
|
|
return undefined;
|
|
}
|
|
{
|
|
// #10818: interim monitor pulses arrive at whatever rate the monitored
|
|
// command prints; without a session-level minimum interval each pulse
|
|
// starts its own model turn and the session never returns to idle (Esc
|
|
// cancels the in-flight turn, the next pulse starts another). Gate only
|
|
// interim (status 'running') monitor-led batches; terminal notifications
|
|
// and cron fires stay prompt. Checking queue[0] before the cancelled-
|
|
// monitor prune inside is conservative in the right direction.
|
|
const waitMs = interimMonitorWaitMs(
|
|
notificationQueueRef.current,
|
|
lastInterimMonitorTurnAtRef.current,
|
|
);
|
|
if (waitMs > 0) {
|
|
// Re-fire this effect when the window elapses so queued pulses
|
|
// still batch into a single catch-up turn even if the monitor
|
|
// goes quiet in the meantime.
|
|
const timer = setTimeout(
|
|
() => setNotificationTrigger((n) => n + 1),
|
|
waitMs,
|
|
);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
|
|
// Consumer-side guard for #7156: this effect can run on a render pass
|
|
// that React batched together with progress setState calls issued from
|
|
// INSIDE a subagent's AsyncLocalStorage frame, in which case the whole
|
|
// synchronous effect stack — and every async continuation submitQuery
|
|
// starts — inherits the subagent's runtime view, and the notification
|
|
// turn resolves Config.getModel() to the SUBAGENT's model. Exiting the
|
|
// frame here guarantees the drained turn always runs on the main
|
|
// session's configuration, regardless of which producer's setState
|
|
// triggered the commit.
|
|
runOutsideAgentContext(() => {
|
|
const admission = claimSystemGoalTurn();
|
|
if (!admission.ready) return;
|
|
const queue = notificationQueueRef.current;
|
|
const monitorRegistry = config.getMonitorRegistry();
|
|
for (let i = queue.length - 1; i >= 0; i--) {
|
|
const monitor = queue[i]!.monitor;
|
|
if (
|
|
monitor?.status === 'running' &&
|
|
monitorRegistry.get(monitor.id)?.status === 'cancelled'
|
|
) {
|
|
queue.splice(i, 1);
|
|
}
|
|
}
|
|
if (queue.length === 0) {
|
|
return;
|
|
}
|
|
const targetType = queue[0]!.sendMessageType;
|
|
|
|
// Report what overflow discarded on the first turn that follows it, so
|
|
// the model learns what it will never be told about before it acts on
|
|
// the notifications that survived. Parked until a turn accepts it.
|
|
const droppedSummary: PendingDroppedSummary | undefined =
|
|
pendingDroppedSummaryRef.current ??
|
|
droppedNotificationsRef.current.take();
|
|
pendingDroppedSummaryRef.current = droppedSummary;
|
|
const displayDroppedSummary = (at: number) => {
|
|
if (!droppedSummary || droppedSummary.displayed) return;
|
|
addItem(
|
|
{ type: 'notification' as const, text: droppedSummary.displayText },
|
|
at,
|
|
);
|
|
droppedSummary.displayed = true;
|
|
};
|
|
const withDroppedSummary = (text: string) =>
|
|
droppedSummary ? `${droppedSummary.modelText}\n\n${text}` : text;
|
|
const releaseDroppedSummary = () => {
|
|
pendingDroppedSummaryRef.current = undefined;
|
|
};
|
|
const restoreDroppedSummary = () => {
|
|
pendingDroppedSummaryRef.current = droppedSummary;
|
|
};
|
|
const restoreBatch = (batch: QueuedNotification[]) => {
|
|
queue.unshift(...batch);
|
|
while (queue.length > MAX_BACKGROUND_NOTIFICATION_QUEUE) {
|
|
const admission = decideNotificationAdmission(
|
|
queue,
|
|
{ ...queue[0]!, interim: false },
|
|
{
|
|
max: MAX_BACKGROUND_NOTIFICATION_QUEUE,
|
|
isProtected: isProtectedNotification,
|
|
},
|
|
);
|
|
const victimIndex =
|
|
admission.action === 'evict' ? admission.index : queue.length - 1;
|
|
const [victim] = queue.splice(victimIndex, 1);
|
|
if (victim) droppedNotificationsRef.current.record(victim);
|
|
}
|
|
};
|
|
|
|
// Cron prompts must run as individual turns — each needs its own
|
|
// slash/shell/@ preprocessing and approval cycle. Only batch
|
|
// Notification items (which pass through without preprocessing).
|
|
if (targetType === SendMessageType.Cron) {
|
|
const item = queue.shift()!;
|
|
const cronAt = Date.now();
|
|
if (
|
|
queue.some(
|
|
(queued) =>
|
|
queued.sendMessageType === SendMessageType.Notification,
|
|
)
|
|
) {
|
|
displayDroppedSummary(cronAt);
|
|
}
|
|
if (!item.displayed) {
|
|
addItem(
|
|
{ type: 'notification' as const, text: item.displayText },
|
|
cronAt,
|
|
);
|
|
item.displayed = true;
|
|
}
|
|
void submitQuery(item.modelText, item.sendMessageType, undefined, {
|
|
notificationDisplayText: item.displayText,
|
|
todoWorkChainId: item.todoWorkChainId,
|
|
// A cron prompt is never a shell command, whatever mode the
|
|
// composer happens to be in when the queue drains — with a gate
|
|
// the text also carries a command's untrusted output.
|
|
shellMode: false,
|
|
onDelivered: item.onDelivered,
|
|
onDeliveryFailed: () => {
|
|
restoreDroppedSummary();
|
|
item.onDeliveryFailed?.();
|
|
},
|
|
onAdmissionFailed: () => {
|
|
queue.unshift(item);
|
|
restoreDroppedSummary();
|
|
},
|
|
claimGoalTurn: admission.claimGoalTurn,
|
|
onGoalClaimDeferred: () => {
|
|
queue.unshift(item);
|
|
restoreDroppedSummary();
|
|
setNotificationTrigger((n) => n + 1);
|
|
},
|
|
}).catch((error) => {
|
|
debugLogger.warn('Failed to admit cron notification', error);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Drain contiguous leading Notification items into one batch.
|
|
let splitIdx = 0;
|
|
while (
|
|
splitIdx < queue.length &&
|
|
queue[splitIdx]!.sendMessageType === targetType &&
|
|
queue[splitIdx]!.todoWorkChainId === queue[0]!.todoWorkChainId
|
|
) {
|
|
splitIdx++;
|
|
}
|
|
const batch = queue.splice(0, splitIdx);
|
|
if (batch[0]?.monitor?.status === 'running') {
|
|
lastInterimMonitorTurnAtRef.current = Date.now();
|
|
}
|
|
|
|
const combinedModelText = batch.map((e) => e.modelText).join('\n\n');
|
|
const combinedDisplayText = batch.map((e) => e.displayText).join('; ');
|
|
releaseDroppedSummary();
|
|
void submitQuery(
|
|
withDroppedSummary(combinedModelText),
|
|
targetType,
|
|
undefined,
|
|
{
|
|
notificationDisplayText: combinedDisplayText,
|
|
todoWorkChainId: batch[0]?.todoWorkChainId,
|
|
onAdmissionFailed: () => {
|
|
restoreBatch(batch);
|
|
restoreDroppedSummary();
|
|
},
|
|
claimGoalTurn: admission.claimGoalTurn,
|
|
onGoalClaimDeferred: () => {
|
|
restoreBatch(batch);
|
|
restoreDroppedSummary();
|
|
setNotificationTrigger((n) => n + 1);
|
|
},
|
|
onRequestStarted: () => {
|
|
const now = Date.now();
|
|
displayDroppedSummary(now);
|
|
for (const item of batch) {
|
|
if (!item.displayed) {
|
|
addItem(
|
|
{ type: 'notification' as const, text: item.displayText },
|
|
now,
|
|
);
|
|
item.displayed = true;
|
|
}
|
|
}
|
|
},
|
|
},
|
|
).catch((error) => {
|
|
debugLogger.warn('Failed to admit background notification', error);
|
|
});
|
|
});
|
|
return undefined;
|
|
}
|
|
}, [
|
|
streamingState,
|
|
submitQuery,
|
|
notificationTrigger,
|
|
addItem,
|
|
config,
|
|
claimSystemGoalTurn,
|
|
goalQueuePendingCount,
|
|
]);
|
|
|
|
// ─── A Mod's own prompts (`$.prompt.submit`) ───────────────
|
|
// Each runs as a turn of its own once the session is idle, after the
|
|
// person's queued prompts and the notifications that can run now, and not
|
|
// while a Goal owns the session's turns: a Mod's prompt is neither the
|
|
// person's words nor a turn the Goal asked for. A monitor update waiting
|
|
// out its interval does not hold it, or a busy monitor would hold it for
|
|
// as long as it runs.
|
|
useEffect(
|
|
() =>
|
|
getInteractiveModSession(config)?.subscribePrompts(() =>
|
|
setModPromptTrigger((n) => n + 1),
|
|
),
|
|
[config],
|
|
);
|
|
// A Goal lets go of the session's turns on its own schedule, after the
|
|
// session is already idle (its verifier accepts, its last turn is
|
|
// released), so the drain hears the Goal too.
|
|
useEffect(() => {
|
|
try {
|
|
return config.getGoalRuntime().subscribe(() => {
|
|
if (getInteractiveModSession(config)?.hasSubmittedPrompts)
|
|
setModPromptTrigger((n) => n + 1);
|
|
});
|
|
} catch {
|
|
// Goal persistence can be disabled for this session.
|
|
return undefined;
|
|
}
|
|
}, [config, sessionStates.sessionId]);
|
|
useEffect(() => {
|
|
const mods = getInteractiveModSession(config);
|
|
if (
|
|
streamingState !== StreamingState.Idle ||
|
|
isSubmittingQueryRef.current ||
|
|
(notificationQueueRef.current.length > 0 &&
|
|
interimMonitorWaitMs(
|
|
notificationQueueRef.current,
|
|
lastInterimMonitorTurnAtRef.current,
|
|
) === 0) ||
|
|
!mods?.hasSubmittedPrompts ||
|
|
goalQueueRef?.current?.hasQueuedUserMessages?.() ||
|
|
goalQueuePendingCount > 0
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
if (goalRequiresExactPermit(config.getGoalRuntime().getSnapshot()))
|
|
return;
|
|
} catch {
|
|
// Goal runtime is optional during early initialization.
|
|
}
|
|
runOutsideAgentContext(() => {
|
|
const prompt = mods.takeSubmittedPrompt();
|
|
if (!prompt) return;
|
|
void submitQuery(
|
|
frameModPrompt(prompt),
|
|
SendMessageType.UserQuery,
|
|
undefined,
|
|
{
|
|
modPrompt: prompt,
|
|
submittedPrompt: prompt.text,
|
|
shellMode: false,
|
|
// Another submission won this idle moment; the prompt waits for
|
|
// it to settle. When the turn could not start for a failure of
|
|
// its own (the Mods failed to start, the prompt failed to
|
|
// prepare) the prompt also waits for the next settle rather than
|
|
// retrying at once, which would fail the same way in a loop.
|
|
onAdmissionFailed: () => mods.returnSubmittedPrompt(prompt),
|
|
},
|
|
).catch((error) => {
|
|
debugLogger.warn("Failed to start a Mod's prompt", error);
|
|
});
|
|
});
|
|
}, [
|
|
streamingState,
|
|
submitQuery,
|
|
modPromptTrigger,
|
|
notificationTrigger,
|
|
config,
|
|
goalQueueRef,
|
|
goalQueuePendingCount,
|
|
]);
|
|
|
|
// ─── Teammate message integration ─────────────────────────
|
|
// The queue state (`teammateQueueRef` / `teammateTrigger`) is declared
|
|
// near the top of the hook so `handleCompletedTools` can drain it at
|
|
// tool-round boundaries (#8172).
|
|
|
|
// Subscribe to TeamManager's leader message callback.
|
|
// Track the bound manager so we can detach the callback
|
|
// before a new manager replaces it (and on unmount) —
|
|
// otherwise a stale TeamManager could keep pushing into
|
|
// the active queue ref after team recreation/remount.
|
|
useEffect(() => {
|
|
let boundManager: import('@qwen-code/qwen-code-core').TeamManager | null =
|
|
null;
|
|
const handleManagerChange = (
|
|
manager: import('@qwen-code/qwen-code-core').TeamManager | null,
|
|
) => {
|
|
if (boundManager && boundManager !== manager) {
|
|
boundManager.setLeaderMessageCallback(null);
|
|
// Drop any messages the old team's teammates queued but that
|
|
// weren't drained before the swap — they belong to a team that
|
|
// no longer exists and must not be submitted into the new
|
|
// team's session. Only fires on a genuine manager swap; a React
|
|
// remount re-binds the same manager (boundManager is null here)
|
|
// and preserves the queue.
|
|
teammateQueueRef.current.length = 0;
|
|
// The queue clear only covers entries still queued. A batch
|
|
// already drained into an in-flight tool-round submission lives
|
|
// in that submission's settlement closure; moving the generation
|
|
// makes its restore drop the batch and its settlement skip the
|
|
// journal/debt, so it cannot resurface in the new team's session
|
|
// either.
|
|
teammateQueueGenerationRef.current += 1;
|
|
}
|
|
boundManager = manager;
|
|
if (manager) {
|
|
manager.setLeaderMessageCallback(
|
|
(modelText: string, display: string) => {
|
|
teammateQueueRef.current.push({ modelText, display });
|
|
setTeammateTrigger((n) => n + 1);
|
|
},
|
|
);
|
|
}
|
|
};
|
|
|
|
config.onTeamManagerChange(handleManagerChange);
|
|
|
|
// Catch manager that was set before this effect ran
|
|
const current = config.getTeamManager();
|
|
if (current) {
|
|
handleManagerChange(current);
|
|
}
|
|
|
|
return () => {
|
|
config.onTeamManagerChange(null, handleManagerChange);
|
|
if (boundManager) {
|
|
boundManager.setLeaderMessageCallback(null);
|
|
boundManager = null;
|
|
}
|
|
};
|
|
}, [config]);
|
|
|
|
// When idle, drain teammate messages one batch at a time.
|
|
// Skip when another submission is in flight (e.g. the
|
|
// notification effect won this render and called submitQuery
|
|
// synchronously, flipping isSubmittingQueryRef). Without this
|
|
// guard the splice would drain the queue and submitQuery
|
|
// would early-return, permanently losing those messages.
|
|
useEffect(() => {
|
|
if (
|
|
streamingState === StreamingState.Idle &&
|
|
!isSubmittingQueryRef.current &&
|
|
teammateQueueRef.current.length > 0
|
|
) {
|
|
// React can flush this effect after restoring the teammate frame.
|
|
runOutsideAgentContext(() => {
|
|
const admission = claimSystemGoalTurn();
|
|
if (!admission.ready) return;
|
|
// Shared drain protocol with the tool-round boundary: splice +
|
|
// one compact `● …` line per report (the full envelope goes only
|
|
// to the model; the USER bubble is suppressed for
|
|
// SendMessageType.Teammate in prepareQueryForLlm) +
|
|
// idempotent requeue/restore.
|
|
const { entries: batch, restore } = drainTeammateQueue();
|
|
const modelText = batch.map((e) => e.modelText).join('\n\n');
|
|
const display = batch.map((e) => e.display).join('; ');
|
|
void submitQuery(modelText, SendMessageType.Teammate, undefined, {
|
|
notificationDisplayText: display,
|
|
onAdmissionFailed: restore,
|
|
claimGoalTurn: admission.claimGoalTurn,
|
|
onGoalClaimDeferred: restore,
|
|
}).catch((error) => {
|
|
debugLogger.warn('Failed to admit teammate notification', error);
|
|
});
|
|
});
|
|
}
|
|
}, [
|
|
streamingState,
|
|
submitQuery,
|
|
teammateTrigger,
|
|
drainTeammateQueue,
|
|
claimSystemGoalTurn,
|
|
goalQueuePendingCount,
|
|
]);
|
|
|
|
return {
|
|
streamingState,
|
|
workflowKeywordActive,
|
|
submitQuery,
|
|
initError,
|
|
pendingHistoryItems,
|
|
clearPendingState,
|
|
thought,
|
|
cancelOngoingRequest,
|
|
preemptGoalTurn,
|
|
retryLastPrompt,
|
|
pendingToolCalls: toolCalls,
|
|
handleApprovalModeChange,
|
|
activePtyId,
|
|
loopDetectionConfirmationRequest,
|
|
streamingResponseLengthRef,
|
|
isReceivingContent,
|
|
/** True once the user cancels the current turn; reset when one starts. */
|
|
turnCancelledRef: turnCancelledRef as RefObject<boolean>,
|
|
};
|
|
};
|