/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import { useCallback, useMemo, useRef } from 'react'; import { type EditorType, type LiveOutputUpdate, type MessageBus, type RecordingIntegration, type ToolCall, type AgentRequestInput, } from '@vybestack/llxprt-code-core'; import type { Agent } from '@vybestack/llxprt-code-agents'; import { type LoadedSettings } from '../../../config/settings.js'; import { type HistoryItemWithoutId, type SlashCommandProcessorResult, } from '../../types.js'; import { type RemoveHistoryItems, type UseHistoryManagerReturn, } from '../useHistoryManager.js'; import { type TrackedToolCall } from '../useReactToolScheduler.js'; import { mapToDisplay as mapTrackedToolCallsToDisplay } from '../toolMapping.js'; import { useStreamState } from './useStreamState.js'; import { useSubmitQuery, type UseSubmitQueryDeps } from './useSubmitQuery.js'; import { useAgentEventStream, type AgentEventRouter, } from './useAgentEventStream.js'; import { useCancellation, useShellCommandSetup, useStreamingState, useToolSchedulerSetup, } from './useAgentStreamLifecycle.js'; import type { QueuedSubmission } from './types.js'; import type { StreamRuntime, UiSubagentManager } from '../../cliUiRuntime.js'; import type { OperationLifecycleRegistry } from './operationLifecycle.js'; export interface AgentStreamOrchestrationDeps { agent: Agent; addItem: UseHistoryManagerReturn['addItem']; removeItems?: RemoveHistoryItems; runtime: StreamRuntime; settings: LoadedSettings; onDebugMessage: (message: string) => void; handleSlashCommand: ( cmd: AgentRequestInput, ) => Promise; shellModeActive: boolean; getPreferredEditor: () => EditorType | undefined; onAuthError: () => void; performMemoryRefresh: () => Promise; onEditorClose: () => void; onCancelSubmit: (shouldRestorePrompt?: boolean) => void; setShellInputFocused: (value: boolean) => void; terminalWidth?: number; terminalHeight?: number; onEditorOpen: () => void; recordingIntegration?: RecordingIntegration; runtimeMessageBus?: MessageBus; subagentManager?: UiSubagentManager; /** * Optional perf operation lifecycle registry (P06/P07). When undefined * (perf disabled), no lifecycle instrumentation occurs. Supplied by P12 * integration wiring. */ operationLifecycle?: OperationLifecycleRegistry; } export interface AgentStreamOrchestrationResult { st: ReturnType; streamingState: ReturnType; submitQuery: ReturnType['submitQuery']; pendingToolCallGroupDisplay: HistoryItemWithoutId | undefined; toolCalls: TrackedToolCall[]; lastToolOutputTime: number; lastShellOutputTime: number; interactiveRuntimeReady: boolean; cancelOngoingRequest: () => void; sendAllQueuedSubmissions: () => void; steerAllQueuedSubmissions: () => void; activeShellPtyId: number | null; queuedSubmissions: readonly QueuedSubmission[]; } interface ToolSchedulerState { scheduler: ReturnType; toolCalls: TrackedToolCall[]; scheduleToolCalls: UseSubmitQueryDeps['scheduleToolCalls']; markToolsAsDisplayCleared: (callIds: string[]) => void; cancelAllToolCalls: () => void; lastToolOutputTime: number; interactiveRuntimeReady: boolean; /** Bound display-state updaters for the AgenticLoop's displayCallbacks. */ replaceToolCalls: (calls: ToolCall[]) => void; updateToolOutput: (callId: string, update: LiveOutputUpdate) => void; } export function useAgentStreamOrchestration( args: AgentStreamOrchestrationDeps, ): AgentStreamOrchestrationResult { const st = useStreamState(args.addItem, args.runtime); const loopDetectedRef = useRef(false); const scheduler = useToolSchedulerState(args, st); const pendingToolCallGroupDisplay = usePendingToolGroupDisplay( scheduler.toolCalls, ); const shell = useShell(args, st); const streamingState = useStreamingState( st.isResponding, scheduler.toolCalls, st.turnCancelled, ); // Cancels EVERY running async subagent on ESC, not only those launched by the // current foreground turn. This is intentional (issue #2074): an async task // launched in a prior turn has its foreground-signal relay bound to that // earlier turn's signal, which already settled and can never abort. Relaying // alone would therefore leave such cross-turn tasks running, which is the // exact bug #2074 reports. The AsyncTaskManager is the single session-wide // owner of running tasks, so cancelling all of them is the only mechanism // that reliably stops detached subagents regardless of launch turn. const cancelRunningAsyncTasks = useCallback(() => { const mgr = args.runtime.asyncTasks.getAsyncTaskManager(); mgr?.getRunningTasks().forEach((t) => mgr.cancelTask(t.id)); }, [args.runtime]); const { cancelOngoingRequest } = useCancellation( streamingState, st.turnCancelledRef, st.setTurnCancelled, st.abortControllerRef, scheduler.cancelAllToolCalls, st.pendingHistoryItemRef, st.flushPendingHistoryItem, args.addItem, st.setPendingHistoryItem, args.onCancelSubmit, st.setIsResponding, args.setShellInputFocused, st.drainSuppressedRef, cancelRunningAsyncTasks, ); // Refs to break the circular dependency between useSubmitQuery (which // creates useStreamEventHandlers → processAgentEvent) and useAgentEventStream // (which provides runStream). Each is populated synchronously during render. const processAgentEventRef = useRef(null); const runStreamRef = useRef< | (( message: AgentRequestInput, signal: AbortSignal, promptId: string, ) => Promise) | null >(null); const submitQueryResult = useSubmitForStream( args, st, scheduler.scheduleToolCalls, shell.handleShellCommand, loopDetectedRef, streamingState, runStreamRef, ); const submitQuery = submitQueryResult.submitQuery; const scheduleNextQueuedSubmission = submitQueryResult.scheduleNextQueuedSubmission; const { sendAllQueuedSubmissions, steerAllQueuedSubmissions } = useQueuedActions(st, args.agent, scheduleNextQueuedSubmission); // Populate the ref synchronously so the first render already has the real // function available for any synchronous consumer. processAgentEventRef.current = submitQueryResult.processAgentEvent; const agentEventStream = useEventStreamForAgent( args, st, scheduler, processAgentEventRef, ); // Populate runStreamRef synchronously. runStreamRef.current = agentEventStream.runStream; return buildResult( st, streamingState, submitQuery, scheduler, shell, pendingToolCallGroupDisplay, cancelOngoingRequest, sendAllQueuedSubmissions, steerAllQueuedSubmissions, ); } function useToolSchedulerState( args: AgentStreamOrchestrationDeps, st: ReturnType, ): ToolSchedulerState { const scheduler = useToolSchedulerSetup( args.runtime, st.setPendingHistoryItem, args.getPreferredEditor, args.onEditorClose, args.onEditorOpen, args.runtimeMessageBus, args.addItem, args.agent, ); const [ toolCalls, scheduleToolCalls, markToolsAsDisplayCleared, cancelAllToolCalls, lastToolOutputTime, interactiveRuntimeReady, replaceToolCalls, updateToolOutput, ] = scheduler.toolSchedulerResult; return { scheduler, toolCalls, scheduleToolCalls, markToolsAsDisplayCleared, cancelAllToolCalls, lastToolOutputTime, interactiveRuntimeReady, replaceToolCalls, updateToolOutput, }; } function useShell( args: AgentStreamOrchestrationDeps, st: ReturnType, ) { return useShellCommandSetup({ addItem: args.addItem, setPendingHistoryItem: st.setPendingHistoryItem, setIsResponding: st.setIsResponding, onDebugMessage: args.onDebugMessage, runtime: args.runtime, agent: args.agent, setShellInputFocused: args.setShellInputFocused, terminalWidth: args.terminalWidth, terminalHeight: args.terminalHeight, pendingHistoryItemRef: st.pendingHistoryItemRef, }); } function useSubmitForStream( args: AgentStreamOrchestrationDeps, st: ReturnType, scheduleToolCalls: UseSubmitQueryDeps['scheduleToolCalls'], handleShellCommand: (query: string, signal: AbortSignal) => boolean, loopDetectedRef: React.MutableRefObject, streamingState: ReturnType, runStreamRef: UseSubmitQueryDeps['runStreamRef'], ) { const result = useSubmitQuery( buildSubmitQueryDeps({ args, st, scheduleToolCalls, handleShellCommand, loopDetectedRef, streamingState, runStreamRef, }), ); return result; } function useEventStreamForAgent( args: AgentStreamOrchestrationDeps, st: ReturnType, scheduler: ToolSchedulerState, processAgentEventRef: React.MutableRefObject, ) { const lifecycle = args.operationLifecycle; return useAgentEventStream({ agent: args.agent, addItem: args.addItem, processAgentEventRef, flushPendingHistoryItem: st.flushPendingHistoryItem, clearPendingHistoryItem: () => { st.pendingResponse.endCommittedSegments(); st.setPendingHistoryItem(null); st.pendingResponse.reset(); }, performMemoryRefresh: args.performMemoryRefresh, markToolsAsDisplayCleared: scheduler.markToolsAsDisplayCleared, onToolCallsUpdate: scheduler.replaceToolCalls, outputUpdateHandler: scheduler.updateToolOutput, getPreferredEditor: args.getPreferredEditor, onEditorOpen: args.onEditorOpen, onEditorClose: args.onEditorClose, // P07: perf event observation routed OUTSIDE the generic event-handler // catch (D8: a perf callback throw rejects the stream). The turn's // AbortSignal is passed through so measurements route to the correct op, // never to "the current active op" by position. Wired only when a registry // exists (perf enabled). onAgentEventObserved: lifecycle ? (event, signal, handlerMs) => lifecycle.observeAgentEvent(event, signal, handlerMs) : undefined, }); } function usePendingToolGroupDisplay(toolCalls: TrackedToolCall[]) { return useMemo( () => toolCalls.length > 0 ? mapTrackedToolCallsToDisplay(toolCalls) : undefined, [toolCalls], ); } function useQueuedActions( st: ReturnType, agent: Agent, scheduleNextQueuedSubmission: () => void, ) { const { drainSuppressedRef, queuedSubmissionsRef, clearSubmissions } = st; const sendAllQueuedSubmissions = useCallback(() => { drainSuppressedRef.current = false; scheduleNextQueuedSubmission(); }, [drainSuppressedRef, scheduleNextQueuedSubmission]); const steerAllQueuedSubmissions = useCallback(() => { const items = queuedSubmissionsRef.current; if (items.length === 0) return; const newline = String.fromCharCode(10); const text = items.map((s) => s.query).join(newline); agent.injectSteer(text); clearSubmissions(); }, [agent, queuedSubmissionsRef, clearSubmissions]); return { sendAllQueuedSubmissions, steerAllQueuedSubmissions }; } function buildResult( st: ReturnType, streamingState: ReturnType, submitQuery: ReturnType['submitQuery'], scheduler: ToolSchedulerState, shell: ReturnType, pendingToolCallGroupDisplay: HistoryItemWithoutId | undefined, cancelOngoingRequest: () => void, sendAllQueuedSubmissions: () => void, steerAllQueuedSubmissions: () => void, ): AgentStreamOrchestrationResult { return { st, streamingState, submitQuery, pendingToolCallGroupDisplay, toolCalls: scheduler.toolCalls, lastToolOutputTime: scheduler.lastToolOutputTime, lastShellOutputTime: shell.lastShellOutputTime, interactiveRuntimeReady: scheduler.interactiveRuntimeReady, cancelOngoingRequest, sendAllQueuedSubmissions, steerAllQueuedSubmissions, activeShellPtyId: shell.activeShellPtyId, queuedSubmissions: st.queuedSubmissions, }; } interface BuildSubmitQueryDepsArgs { args: AgentStreamOrchestrationDeps; st: ReturnType; scheduleToolCalls: UseSubmitQueryDeps['scheduleToolCalls']; handleShellCommand: (query: string, signal: AbortSignal) => boolean; loopDetectedRef: React.MutableRefObject; streamingState: ReturnType; runStreamRef: UseSubmitQueryDeps['runStreamRef']; } function buildSubmitQueryDeps({ args, st, scheduleToolCalls, handleShellCommand, loopDetectedRef, streamingState, runStreamRef, }: BuildSubmitQueryDepsArgs): UseSubmitQueryDeps { return { runtime: args.runtime, agent: args.agent, addItem: args.addItem, removeItems: args.removeItems, settings: args.settings, onDebugMessage: args.onDebugMessage, onCancelSubmit: args.onCancelSubmit, onAuthError: args.onAuthError, recordingIntegration: args.recordingIntegration, sanitizeContent: st.sanitizeContent, flushPendingHistoryItem: st.flushPendingHistoryItem, pendingResponse: st.pendingResponse, pendingHistoryItemRef: st.pendingHistoryItemRef, thinkingBlocksRef: st.thinkingBlocksRef, turnCancelledRef: st.turnCancelledRef, setTurnCancelled: st.setTurnCancelled, drainSuppressedRef: st.drainSuppressedRef, queuedSubmissionsRef: st.queuedSubmissionsRef, enqueueSubmission: st.enqueueSubmission, enqueueSubmissionFirst: st.enqueueSubmissionFirst, requeueSubmission: st.requeueSubmission, dequeueSubmission: st.dequeueSubmission, clearSubmissions: st.clearSubmissions, tryReserveDrain: st.tryReserveDrain, releaseDrain: st.releaseDrain, setPendingHistoryItem: st.setPendingHistoryItem, setIsResponding: st.setIsResponding, setInitError: st.setInitError, setThought: st.setThought, setLastAgentActivityTime: st.setLastAgentActivityTime, scheduleToolCalls, abortActiveStream: st.abortActiveStream, handleShellCommand, handleSlashCommand: args.handleSlashCommand, logger: st.logger, shellModeActive: args.shellModeActive, loopDetectedRef, lastProfileNameRef: st.lastProfileNameRef, lastModelInfoRef: st.lastModelInfoRef, lastModelIdentityRef: st.lastModelIdentityRef, abortControllerRef: st.abortControllerRef, submitQueryRef: st.submitQueryRef, isResponding: st.isResponding, streamingState, runStreamRef, subagentManager: args.subagentManager, operationLifecycle: args.operationLifecycle, }; }