/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ /** * useSubmitQuery — extracted submit query orchestration from useAgentStream. * Contains the submitQuery callback, queued-submission scheduling, * submitQueryRef update effect, idle-queue-drain effect, and * async-task-auto-trigger effect. */ import { useCallback, useEffect, useRef } from 'react'; import type { Agent } from '@vybestack/llxprt-code-agents'; import { debugLogger } from '@vybestack/llxprt-code-telemetry'; import { type MessageSenderType, type RecordingIntegration, type ThinkingBlock, type ThoughtSummary, type ToolCallRequestInfo, type AgentRequestInput, } from '@vybestack/llxprt-code-core'; import { StreamingState, type HistoryItem, type HistoryItemWithoutId, type SlashCommandProcessorResult, } from '../../types.js'; import { isSlashCommand } from '../../utils/commandUtils.js'; import { useSessionStats } from '../../contexts/SessionContext.js'; import { useStreamEventHandlers } from './useStreamEventHandlers.js'; import { dispatchAgentEvent } from './agentEventDispatcher.js'; import type { AgentEventRouter } from './useAgentEventStream.js'; import { resolveContentPrefixIdentity, createCliModelIdentityRuntime, } from '../../utils/modelIdentity.js'; import type { QueuedSubmission } from './types.js'; import type { StreamRuntime, UiSubagentManager } from '../../cliUiRuntime.js'; import { observeAgentEvent } from '../../../observation/jspWiring.js'; import type { PendingResponseBuffer } from './pendingResponseBuffer.js'; import type { OperationLifecycleRegistry } from './operationLifecycle.js'; import { runSubmitQueryCore, finaliseOnceAfterBegin, isCurrentTurn, type TurnInit, type SubmitQueryCallbackDeps, } from './submitQueryTurnLifecycle.js'; export type SubmissionDisposition = | 'consumed' | 'requeue' | 'requeue-await-event'; const MAX_QUEUED_SUBMISSION_RETRIES = 3; // A one-second pause avoids a tight failure loop while keeping transient // submitter-initialization races responsive. const QUEUED_SUBMISSION_RETRY_DELAY_MS = 1000; export type SubmissionExecutor = ( query: AgentRequestInput, options?: { isContinuation: boolean }, prompt_id?: string, fromQueue?: boolean, ) => Promise; /** * Shared content-prefix identity resolver for the AgentEvent dispatcher. Reads * fresh runtime state at call time so a single stable reference can be reused. */ function defaultGetContentPrefixIdentity(): string | null { try { return resolveContentPrefixIdentity(createCliModelIdentityRuntime()); } catch { return null; } } export interface UseSubmitQueryDeps { runtime: StreamRuntime; agent: Agent; addItem: ( item: Omit, timestamp?: number, isResuming?: boolean, ) => number; removeItems?: (ids: readonly number[]) => void; settings: Parameters[0]['settings']; onDebugMessage: (message: string) => void; onCancelSubmit: (shouldRestorePrompt?: boolean) => void; onAuthError: () => void; recordingIntegration?: RecordingIntegration; sanitizeContent: (text: string) => { text: string; blocked: boolean; feedback?: string; }; flushPendingHistoryItem: (timestamp: number) => void; pendingResponse: PendingResponseBuffer; pendingHistoryItemRef: React.MutableRefObject; thinkingBlocksRef: React.MutableRefObject; turnCancelledRef: React.MutableRefObject; drainSuppressedRef: React.MutableRefObject; setTurnCancelled: (value: boolean) => void; queuedSubmissionsRef: React.MutableRefObject; enqueueSubmission: (submission: QueuedSubmission) => void; enqueueSubmissionFirst: (submission: QueuedSubmission) => void; requeueSubmission: (submission: QueuedSubmission) => void; dequeueSubmission: () => QueuedSubmission | undefined; clearSubmissions: () => void; tryReserveDrain: () => boolean; releaseDrain: () => void; setPendingHistoryItem: React.Dispatch< React.SetStateAction >; setIsResponding: React.Dispatch>; setInitError: React.Dispatch>; setThought: React.Dispatch>; setLastAgentActivityTime: React.Dispatch>; scheduleToolCalls: ( requests: ToolCallRequestInfo[], signal: AbortSignal, ) => Promise; abortActiveStream: (reason?: unknown) => void; handleShellCommand: (query: string, signal: AbortSignal) => boolean; handleSlashCommand: ( cmd: AgentRequestInput, ) => Promise; logger: | { logMessage: (sender: MessageSenderType, text: string) => Promise } | null | undefined; shellModeActive: boolean; loopDetectedRef: React.MutableRefObject; lastProfileNameRef: React.MutableRefObject; lastModelInfoRef: React.MutableRefObject; lastModelIdentityRef: React.MutableRefObject; abortControllerRef: React.MutableRefObject; subagentManager?: UiSubagentManager; /** * Ref to the Agent event-stream runner. Held as a ref to break the circular * dependency: runStream comes from useAgentEventStream which needs * processAgentEvent from useStreamEventHandlers (created inside this hook). * The ref is populated synchronously during render and read at call time. */ runStreamRef: React.MutableRefObject< | (( message: AgentRequestInput, signal: AbortSignal, promptId: string, ) => Promise) | null >; submitQueryRef: React.MutableRefObject; isResponding: boolean; streamingState: StreamingState; /** * Optional operation lifecycle registry for perf telemetry (P06). When * undefined (perf disabled), no lifecycle instrumentation occurs. Supplied * later by P12 integration wiring. */ operationLifecycle?: OperationLifecycleRegistry; } export interface UseSubmitQueryReturn { submitQuery: ( query: AgentRequestInput, options?: { isContinuation: boolean }, prompt_id?: string, ) => Promise; scheduleNextQueuedSubmission: () => void; /** Processes a single AgentEvent into React state (for the event-stream router). */ processAgentEvent: AgentEventRouter; displayUserMessage: ( trimmedQuery: string, userMessageTimestamp: number, ) => void; prepareQueryForAgent: ( query: AgentRequestInput, userMessageTimestamp: number, abortSignal: AbortSignal, promptId: string, ) => Promise<{ queryToSend: AgentRequestInput | null; shouldProceed: boolean; }>; handleLoopDetectedEvent: () => void; } export function useSubmitQuery(deps: UseSubmitQueryDeps): UseSubmitQueryReturn { const { startNewPrompt, getPromptCount } = useSessionStats(); const activeTurnRef = useRef(false); const handlers = useStreamEventHandlers({ runtime: deps.runtime, agent: deps.agent, settings: deps.settings, addItem: deps.addItem, removeItems: deps.removeItems, onDebugMessage: deps.onDebugMessage, onCancelSubmit: deps.onCancelSubmit, sanitizeContent: deps.sanitizeContent, flushPendingHistoryItem: deps.flushPendingHistoryItem, pendingResponse: deps.pendingResponse, pendingHistoryItemRef: deps.pendingHistoryItemRef, thinkingBlocksRef: deps.thinkingBlocksRef, turnCancelledRef: deps.turnCancelledRef, clearSubmissions: deps.clearSubmissions, setPendingHistoryItem: deps.setPendingHistoryItem, setIsResponding: deps.setIsResponding, setThought: deps.setThought, setLastAgentActivityTime: deps.setLastAgentActivityTime, scheduleToolCalls: deps.scheduleToolCalls, abortActiveStream: deps.abortActiveStream, handleShellCommand: deps.handleShellCommand, handleSlashCommand: deps.handleSlashCommand, logger: deps.logger, shellModeActive: deps.shellModeActive, loopDetectedRef: deps.loopDetectedRef, lastProfileNameRef: deps.lastProfileNameRef, lastModelInfoRef: deps.lastModelInfoRef, lastModelIdentityRef: deps.lastModelIdentityRef, subagentManager: deps.subagentManager, }); const processAgentEvent = useProcessAgentEvent(deps, handlers, activeTurnRef); const scheduleNextQueuedSubmission = useScheduleNext(deps, activeTurnRef); const executeSubmission = useSubmitQueryCallback({ ...deps, displayUserMessage: handlers.displayUserMessage, prepareQueryForAgent: handlers.prepareQueryForAgent, handleLoopDetectedEvent: handlers.handleLoopDetectedEvent, startNewPrompt, getPromptCount, activeTurnRef, scheduleNextQueuedSubmission, }); const submitQuery = useCallback( async (query, options, promptId) => { // Dispositions are an internal queue-drain protocol; the public API // intentionally remains Promise for existing callers. await executeSubmission(query, options, promptId); }, [executeSubmission], ); useSubmitQueryEffects(deps, executeSubmission, scheduleNextQueuedSubmission); return { submitQuery, scheduleNextQueuedSubmission, processAgentEvent, displayUserMessage: handlers.displayUserMessage, prepareQueryForAgent: handlers.prepareQueryForAgent, handleLoopDetectedEvent: handlers.handleLoopDetectedEvent, }; } function useProcessAgentEvent( deps: UseSubmitQueryDeps, handlers: Pick< ReturnType, | 'handleContentEvent' | 'handleStreamAttemptDiscarded' | 'handleUserCancelledEvent' | 'handleErrorEvent' | 'handleChatCompressionEvent' | 'handleFinishedNotice' | 'handleMaxSessionTurnsEvent' | 'handleContextWindowWillOverflowEvent' | 'handleCitationEvent' >, activeTurnRef: React.MutableRefObject, ) { const agentBufferRef = useRef(''); // Latest-ref pattern: store deps+handlers in a ref so the useCallback // never needs to change identity (avoids recreating every render). const latestDeps = useRef(deps); latestDeps.current = deps; const latestHandlers = useRef(handlers); latestHandlers.current = handlers; return useCallback( (event, userMessageTimestamp, signal) => { if (!isCurrentTurn(latestDeps.current, signal)) { return; } // P07: live phase tracking for granular cancellation classification is // routed OUTSIDE this handler's call site (via the // onAgentEventObserved callback in useAgentEventStream, invoked directly // outside the generic catch — D8). It must NOT live inside // processAgentEvent, where it could be swallowed by this handler's // callers. See useAgentStreamOrchestration.useEventStreamForAgent. // Release the interactive active-turn gate and responding state BEFORE // dispatching fallible event rendering for terminal public Agent // error/idle-timeout events. If rendering throws, the gate must already // be released so follow-up submissions are not queued while the // iterator/promise settles (issue #2954). The dispatcher preserves the // existing error-queue-clearing / idle-timeout-queue-preservation // semantics via handleErrorEvent's clearQueue option. const isTerminal = event.type === 'error' || event.type === 'idle-timeout'; if (isTerminal) { activeTurnRef.current = false; latestDeps.current.setIsResponding(false); agentBufferRef.current = ''; } observeAgentEvent(event); const result = dispatchAgentEvent( event, { addItem: latestDeps.current.addItem, sanitizeContent: latestDeps.current.sanitizeContent, flushPendingHistoryItem: latestDeps.current.flushPendingHistoryItem, pendingResponse: latestDeps.current.pendingResponse, pendingHistoryItemRef: latestDeps.current.pendingHistoryItemRef, thinkingBlocksRef: latestDeps.current.thinkingBlocksRef, turnCancelledRef: latestDeps.current.turnCancelledRef, loopDetectedRef: latestDeps.current.loopDetectedRef, lastModelInfoRef: latestDeps.current.lastModelInfoRef, lastModelIdentityRef: latestDeps.current.lastModelIdentityRef, setPendingHistoryItem: latestDeps.current.setPendingHistoryItem, setLastAgentActivityTime: latestDeps.current.setLastAgentActivityTime, setThought: latestDeps.current.setThought, getContentPrefixIdentity: defaultGetContentPrefixIdentity, ...latestHandlers.current, }, agentBufferRef.current, userMessageTimestamp, ); agentBufferRef.current = result.agentMessageBuffer; }, [activeTurnRef], ); } function useSubmitQueryEffects( deps: UseSubmitQueryDeps, submitQuery: ReturnType, scheduleNextQueuedSubmission: () => void, ) { const { submitQueryRef, streamingState, runtime, enqueueSubmission } = deps; useEffect(() => { submitQueryRef.current = submitQuery; }, [submitQuery, submitQueryRef]); useEffect(() => { if (streamingState === StreamingState.Idle) { scheduleNextQueuedSubmission(); } }, [streamingState, scheduleNextQueuedSubmission]); useEffect(() => { const unsubscribe = runtime.events.onMcpClientUpdate( scheduleNextQueuedSubmission, ); return () => { unsubscribe(); }; }, [runtime, scheduleNextQueuedSubmission]); useEffect(() => { const isAgentBusy = () => streamingState !== StreamingState.Idle; const triggerAgentTurn = async (message: string) => { enqueueSubmission({ query: [{ type: 'text', text: message }], }); scheduleNextQueuedSubmission(); }; const unsubscribe = runtime.asyncTasks.setupAsyncTaskAutoTrigger( isAgentBusy, triggerAgentTurn, ); return () => { unsubscribe(); }; }, [ runtime, streamingState, scheduleNextQueuedSubmission, enqueueSubmission, ]); } function useDrainCleanup( drainTimeoutRef: React.MutableRefObject | null>, retryTimeoutRef: React.MutableRefObject | null>, pendingSubmissionRef: React.MutableRefObject, mountedRef: React.MutableRefObject, requeueSubmission: (submission: QueuedSubmission) => void, releaseDrain: () => void, ) { useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; if (drainTimeoutRef.current !== null) { clearTimeout(drainTimeoutRef.current); drainTimeoutRef.current = null; } if (retryTimeoutRef.current !== null) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } if (pendingSubmissionRef.current !== null) { requeueSubmission(pendingSubmissionRef.current); pendingSubmissionRef.current = null; releaseDrain(); } }; }, [ drainTimeoutRef, retryTimeoutRef, mountedRef, pendingSubmissionRef, releaseDrain, requeueSubmission, ]); } function useDrainSubmission( drainTimeoutRef: React.MutableRefObject | null>, retryTimeoutRef: React.MutableRefObject | null>, pendingSubmissionRef: React.MutableRefObject, retryCountRef: React.MutableRefObject, scheduleRef: React.MutableRefObject<() => void>, submitQueryRef: UseSubmitQueryDeps['submitQueryRef'], requeueSubmission: UseSubmitQueryDeps['requeueSubmission'], releaseDrain: UseSubmitQueryDeps['releaseDrain'], ) { return useCallback( async (next: QueuedSubmission): Promise => { drainTimeoutRef.current = null; let disposition: SubmissionDisposition = 'requeue-await-event'; try { const submit = submitQueryRef.current; if (submit === null) { debugLogger.error( 'Cannot drain queued submission before the submitter is ready.', ); } else { disposition = await submit( next.query, next.options, next.promptId, true, ); } } catch (error: unknown) { debugLogger.error('Queued submission failed unexpectedly:', error); } finally { const ownsPendingSubmission = pendingSubmissionRef.current === next; const shouldRetry = ownsPendingSubmission && disposition === 'requeue-await-event' && retryCountRef.current < MAX_QUEUED_SUBMISSION_RETRIES; const retryLimitReached = ownsPendingSubmission && disposition === 'requeue-await-event' && !shouldRetry; if (ownsPendingSubmission) { pendingSubmissionRef.current = null; if (disposition !== 'consumed' && !retryLimitReached) { requeueSubmission(next); } releaseDrain(); } if (shouldRetry) { retryCountRef.current += 1; retryTimeoutRef.current = setTimeout(() => { retryTimeoutRef.current = null; scheduleRef.current(); }, QUEUED_SUBMISSION_RETRY_DELAY_MS); } if (ownsPendingSubmission && !shouldRetry) { retryCountRef.current = 0; if (retryLimitReached) { debugLogger.error( 'Dropping queued submission after repeated drain failures.', ); } scheduleRef.current(); } } }, [ drainTimeoutRef, retryTimeoutRef, pendingSubmissionRef, retryCountRef, scheduleRef, submitQueryRef, requeueSubmission, releaseDrain, ], ); } function useScheduleNext( deps: UseSubmitQueryDeps, activeTurnRef: React.MutableRefObject, ) { const { queuedSubmissionsRef, dequeueSubmission, requeueSubmission, submitQueryRef, tryReserveDrain, releaseDrain, drainSuppressedRef, } = deps; const drainTimeoutRef = useRef | null>(null); const retryTimeoutRef = useRef | null>(null); const pendingSubmissionRef = useRef(null); const retryCountRef = useRef(0); const mountedRef = useRef(true); // The mutable indirection breaks the schedule/drain callback cycle while // allowing delayed retries to invoke the latest stable scheduler. const scheduleRef = useRef<() => void>(() => undefined); const streamingStateRef = useRef(deps.streamingState); streamingStateRef.current = deps.streamingState; useDrainCleanup( drainTimeoutRef, retryTimeoutRef, pendingSubmissionRef, mountedRef, requeueSubmission, releaseDrain, ); const drainSubmission = useDrainSubmission( drainTimeoutRef, retryTimeoutRef, pendingSubmissionRef, retryCountRef, scheduleRef, submitQueryRef, requeueSubmission, releaseDrain, ); const schedule = useCallback(() => { const queueEmpty = queuedSubmissionsRef.current.length === 0; const busyOrNotIdle = activeTurnRef.current || streamingStateRef.current !== StreamingState.Idle; const cannotDrain = !mountedRef.current || busyOrNotIdle || queueEmpty || drainSuppressedRef.current; if (cannotDrain || !tryReserveDrain()) { return; } if (retryTimeoutRef.current !== null) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } const next = dequeueSubmission(); if (!next) { releaseDrain(); return; } pendingSubmissionRef.current = next; drainTimeoutRef.current = setTimeout(() => { void drainSubmission(next); }, 0); }, [ activeTurnRef, queuedSubmissionsRef, retryTimeoutRef, dequeueSubmission, drainSubmission, tryReserveDrain, releaseDrain, drainSuppressedRef, ]); scheduleRef.current = schedule; return schedule; } function useSubmitQueryCallback(cbd: SubmitQueryCallbackDeps) { // Keep the callback identity stable while every invocation reads current deps. const latestCbdRef = useRef(cbd); latestCbdRef.current = cbd; return useCallback( async (query, options, promptId, fromQueue = false) => { const current = latestCbdRef.current; // submitQuery handles NEW user prompts only; the Agent's event stream // drives multi-turn continuation internally. void options; if ( current.activeTurnRef.current || isQueueable(current.streamingState) ) { if (fromQueue) { return 'requeue'; } // Issue #3169: a fresh (non-queue-originated) submission arriving // while an acknowledged cancellation still suppresses draining is // explicit intent to resume. It must not inherit that suppression — // which would leave it stuck in the drawer until the user pressed // Enter again — and it must precede any preserved pre-cancel entries, // so it is front-inserted and suppression is released. It still waits // for the cancelled turn to release activeTurnRef, so serialization is // unaffected. if (isResumeAfterAcknowledgedCancellation(current)) { current.enqueueSubmissionFirst({ query, promptId }); current.drainSuppressedRef.current = false; return 'consumed'; } current.enqueueSubmission({ query, promptId, }); return 'consumed'; } current.activeTurnRef.current = true; // Starting a fresh user-initiated turn resumes normal drain behavior. // A cancel may have suppressed draining to keep queued messages in the // drawer; the user explicitly submitting a new message signals intent // to resume automatic processing. current.drainSuppressedRef.current = false; // Install the turn's AbortController BEFORE any potentially throwing // initialization so the outer finally can verify ownership even when // initTurn rejects. The signal is captured immediately so a throw // inside initTurn cannot leave the gate locked (issue #2954). const turnController = new AbortController(); current.abortControllerRef.current = turnController; const turnSignal = turnController.signal; try { const turn = initTurn( current, query, promptId, current.getPromptCount, turnSignal, ); current.operationLifecycle?.begin(turnSignal, turn.promptId); // Once `begin` has succeeded, any later setup failure must finalise the // operation exactly once as 'error'. This caller owns display setup; // runSubmitQueryCore owns its subsequent turn-start setup. Both preserve // the original failure and aggregate a finalisation failure if needed. if (shouldDisplayUserMessage(turn.trimmedStr)) { try { current.displayUserMessage( turn.trimmedStr, turn.userMessageTimestamp, ); } catch (displayError) { await finaliseOnceAfterBegin( current.operationLifecycle, turnSignal, displayError, 'Display user message failed', ); } } await runSubmitQueryCore(current, query, turn); return 'consumed'; } finally { // Guard against stale cleanup: a terminal error/idle-timeout event // may have already released interactive ownership, allowing a newer // turn to start and replace abortControllerRef.current. If this turn // no longer owns the current AbortController, clearing ownership or // scheduling a drain would clobber the newer turn (issue #2954). if (isCurrentTurn(current, turnSignal)) { current.activeTurnRef.current = false; current.scheduleNextQueuedSubmission(); } } }, [], ); } function isQueueable(streamingState: StreamingState): boolean { return ( streamingState === StreamingState.Responding || streamingState === StreamingState.WaitingForConfirmation ); } function shouldDisplayUserMessage(trimmedStr: string): boolean { return !!trimmedStr && !isSlashCommand(trimmedStr); } function initTurn( deps: UseSubmitQueryDeps, query: AgentRequestInput, promptId: string | undefined, getPromptCount: () => number, abortSignal: AbortSignal, ): TurnInit { const userMessageTimestamp = Date.now(); deps.setTurnCancelled(false); deps.loopDetectedRef.current = false; const resolvedPromptId = promptId ?? deps.runtime.session.getSessionId() + '########' + getPromptCount(); const trimmedStr = typeof query === 'string' ? query.trim() : ''; return { userMessageTimestamp, abortSignal, promptId: resolvedPromptId, trimmedStr, }; } /** * Issue #3169: detects a fresh submission that arrives while an acknowledged * cancellation still suppresses queue draining. * * `drainSuppressedRef` is authoritative here because cancellation is its only * writer of `true` (useCancellation), and the two paths that clear it — * claiming a fresh turn below, and the explicit empty-Enter release in * useAgentStreamOrchestration — both run before any turn can proceed. So * observing it set on a non-queue-originated submission means exactly one * thing: the user cancelled and is now resuming. * * Deliberately NOT gated on `streamingState`. That is a render value derived * from tool calls flattened across every scheduler, so an `awaiting_approval` * call owned by a non-main scheduler keeps it at WaitingForConfirmation even * after cancellation has been acknowledged — which would misclassify the * resume prompt as ordinary queued work and leave it stuck, the very bug this * fixes. */ function isResumeAfterAcknowledgedCancellation( deps: SubmitQueryCallbackDeps, ): boolean { return deps.drainSuppressedRef.current; }