Files
qwen-code/packages/cli/src/ui/hooks/use-llm-stream.ts
T
易良andQwen-Coder a32684604a fix(core): surface Anthropic refusal details (#16079)
* 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]>
2026-11-07 23:42:24 +08:00

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>,
};
};