@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
2,229 lines • 93.1 kB
JavaScript
require("../_virtual/_rolldown/runtime.cjs");
const require_constants = require("./constants.cjs");
const require_message_coercion = require("./message-coercion.cjs");
const require_tools = require("../client/stream/handles/tools.cjs");
const require_headless_tools = require("../headless-tools.cjs");
const require_store = require("./store.cjs");
const require_channel_registry = require("./channel-registry.cjs");
const require_hitl_interrupt_payload = require("../ui/hitl-interrupt-payload.cjs");
const require_interrupts = require("../ui/interrupts.cjs");
const require_namespace = require("./namespace.cjs");
const require_subagents = require("./discovery/subagents.cjs");
const require_subgraphs = require("./discovery/subgraphs.cjs");
require("./discovery/index.cjs");
const require_namespace_from_history = require("./discovery/namespace-from-history.cjs");
const require_message_metadata_tracker = require("./message-metadata-tracker.cjs");
const require_lifecycle_loading_tracker = require("./lifecycle-loading-tracker.cjs");
const require_root_message_projection = require("./root-message-projection.cjs");
const require_optimistic_input = require("./optimistic-input.cjs");
const require_queue_adapter = require("./queue-adapter.cjs");
const require_submit_coordinator = require("./submit-coordinator.cjs");
const require_tool_calls = require("./tool-calls.cjs");
let _langchain_core_utils_uuid = require("@langchain/core/utils/uuid");
require("@langchain/core/messages");
//#region src/stream/controller.ts
/**
* Framework-agnostic controller for the experimental v2 stream.
*
* Responsibilities:
* - Owns at most one {@link ThreadStream} at a time (swapped on
* `hydrate(newThreadId)` or `dispose`).
* - Exposes three always-on observable surfaces via {@link StreamStore}:
* - `rootStore` : root values/messages/toolCalls/interrupts/…
* - `subagentStore` : discovery map of subagents (no content)
* - `subgraphStore` : discovery map of subgraphs (no content)
* - Owns a {@link ChannelRegistry} that framework selector hooks
* (`useMessages`, `useToolCalls`, `useExtension`, `useChannel`)
* use to lazily open per-namespace subscriptions.
* - Imperative run surface: `submit`, `stop`, `respond`, `joinStream`.
*
* A single multi-channel subscription (`values`, `lifecycle`, `input`,
* `messages`, `tools`) powers every always-on projection and both
* discovery runners. Selector hooks add their own (deduped)
* subscriptions on top — so even a UI with many subagents only opens
* one extra subscription per `(channels, namespace)` actually
* rendered on screen.
*/
function isAbortLikeError(error) {
if (error == null || typeof error !== "object") return false;
const maybeError = error;
return maybeError.name === "AbortError" || typeof maybeError.message === "string" && maybeError.message.includes("aborted");
}
function lifecycleReason(event) {
if (event === "completed") return "success";
if (event === "failed") return "error";
if (event === "interrupted") return "interrupt";
return null;
}
/**
* Decide whether a hydrated thread is *active* (a run is executing or
* paused awaiting resume) from the `getState()` snapshot alone — no
* extra request.
*
* Why this gate exists: a finished thread does not need either of the
* always-on SSE pumps. Subagent/subgraph cards are already seeded from
* the `getState()` messages and a single bounded `getHistory()` page, so
* opening the depth-1 content pump + the wildcard lifecycle watcher only
* to replay a completed run and then idle forever is pure waste. We open
* the pumps eagerly only when the thread is active; otherwise they come
* up on the first local `submit()` (the existing deferred-pump path) or
* a thread swap that lands on an active thread.
*
* The gate is deliberately conservative: we only conclude *idle* when
* the state proves it. A thread is treated as active unless `next` is a
* present, empty array AND no task carries a pending interrupt:
* - `next` missing / not an array: unknown shape (a server or custom
* client may omit it). Treat as active so an already-running
* server-side run is still observed on reconnect — never silently
* disable streaming on an unfamiliar `getState` shape.
* - `next.length > 0`: the checkpoint still has nodes to execute, i.e.
* a run is mid-flight or paused at an interrupt.
* - `next` is `[]` but a `tasks[].interrupts` is non-empty: the thread
* is interrupted and a resume (which starts a run) must be observable.
* - `next` is `[]` and no pending interrupts: a completed run → idle.
*/
function isThreadStateActive(state) {
if (state == null) return true;
if (!Array.isArray(state.next)) return true;
if (state.next.length > 0) return true;
if (Array.isArray(state.tasks)) for (const task of state.tasks) {
const interrupts = task?.interrupts;
if (Array.isArray(interrupts) && interrupts.length > 0) return true;
}
return false;
}
const ROOT_NAMESPACE = [];
/**
* Channel set covered by the always-on root subscription. Exported so
* projections (and transports) can reason about what the root pump
* already delivers before opening additional server subscriptions.
*/
const ROOT_PUMP_CHANNELS = [
"values",
"checkpoints",
"lifecycle",
"input",
"messages",
"tools"
];
const PARKED_INTERRUPT_SETTLE_MS = 5e3;
const PARKED_INTERRUPT_SETTLE_POLL_MS = 500;
/**
* Coordinates one thread's protocol-v2 stream and exposes stable
* observable projections for framework bindings.
*
* The controller owns the root subscription, lazily binds scoped
* projections through {@link ChannelRegistry}, and normalizes protocol
* events into class-message, tool-call, discovery, interrupt, and queue
* stores.
*
* @typeParam StateType - Shape of the graph state exposed on `values`.
* @typeParam InterruptType - Shape of protocol interrupt payloads.
* @typeParam ConfigurableType - Shape of `config.configurable` accepted by submit.
*/
var StreamController = class {
rootStore;
subagentStore;
subgraphStore;
subgraphByNodeStore;
messageMetadataStore;
queueStore;
registry;
#options;
#messagesKey;
#subagents = new require_subagents.SubagentDiscovery();
#subgraphs = new require_subgraphs.SubgraphDiscovery();
#messageMetadata = new require_message_metadata_tracker.MessageMetadataTracker();
#thread;
#currentThreadId;
#rootSubscription;
#rootPump;
#rootPumpReady;
/**
* `true` while a self-created thread has its root pump deferred until
* the first `submitRun` / `respondInput` commits the thread row
* server-side. See `#ensureThread` and `#startDeferredRootPump`.
*/
#rootPumpDeferred = false;
#threadEventUnsubscribe;
#threadErrorUnsubscribe;
#disposed = false;
#pendingDisposeTimer = null;
/**
* Interrupt ids this controller has locally marked resolved via
* {@link respond} / {@link respondAll}. Used only to suppress
* *historical* SSE replay of those `input.requested` events (there
* is no `input.responded` protocol event). A live event after the
* resume barrier clears an id from this set so an interrupt raised
* again by a later run can reappear on `rootStore.interrupts`.
*/
#resolvedInterrupts = /* @__PURE__ */ new Set();
/**
* Set of interrupt IDs the server reports as currently *active* on
* the thread (from `state.tasks[].interrupts`). Populated during
* {@link hydrate} from `client.threads.getState()` and used as a
* strict allowlist when processing replayed `input.requested`
* events from the persistent SSE subscription. Without this guard,
* SSE replay re-adds historically-requested interrupts that have
* since been resolved (no `input.responded` event exists in the
* protocol, so the SDK has no other way to tell replay from live
* for an idle thread). After a command is accepted, events newer
* than {@link #interruptReplayThroughSeq} bypass the allowlist.
*/
#hydratedActiveInterruptIds = null;
/**
* Creation time of the checkpoint the latest {@link hydrate} seeded
* from. A new stream subscription replays the thread's retained
* history, and events produced before this moment are already part of
* the seed: applying them again rewinds `messages`, `values` and
* `toolCalls` to older turns and then rebuilds them.
*/
#hydratedCheckpointTime;
/**
* Highest event sequence already applied when the latest run command
* was accepted. Interrupts outside the hydrate-time allowlist remain
* filtered through this sequence, while newer events are live output
* from the newly-started run and must be accepted.
*/
#interruptReplayThroughSeq;
/** Unknown inputs waiting to be classified as live or replayed history. */
#pendingHydrationInterruptEvents = [];
#settleParkedInterruptsPromise;
/**
* Unknown interrupt events received between local command dispatch and its
* response. The response supplies the sequence barrier needed to classify
* each event as historical replay or live output from the new run.
*/
#pendingInterruptEvents = [];
#interruptCommandPending = false;
/**
* Monotonic counter bumped at the start of each `submit()` and used
* by {@link hydrate} to skip its post-fetch allowlist write when a
* submit started while the state fetch was in flight. Without this
* guard, a submit-then-hydrate race could re-install a stale
* allowlist that filters out genuinely-new live interrupts emitted
* by the just-started run.
*/
#submitGeneration = 0;
/**
* Monotonic counter bumped at the start of each `#teardownThread`.
* The root pump captures the generation when it starts and drops
* any event whose generation no longer matches — including queued
* events that `SubscriptionHandle.close()` still drains after the
* snapshot has already been cleared by `hydrate()`.
*/
#rootPumpGeneration = 0;
/**
* Thread ids this controller minted client-side on first `submit()`.
* `hydrate()` skips `threads.getState()` for these — we know there
* is nothing checkpointed yet. Cleared once dispatch commits the
* thread server-side.
*/
#selfCreatedThreadIds = /* @__PURE__ */ new Set();
/**
* Caller-supplied thread ids that 404'd on hydrate. Unlike
* {@link #selfCreatedThreadIds}, these are revalidated on every
* hydrate: another client may create the same id between visits.
* While marked missing, `submit()` still defers the root SSE pump
* until `run.start` / `commands` commits the row — joining
* `/stream/events` beforehand leaves a dead subscription on
* langgraph_api's in-mem runtime.
*/
#missingThreadIds = /* @__PURE__ */ new Set();
/**
* In-flight per-subagent namespace resolutions, keyed by tool-call
* id. De-dupes concurrent {@link resolveSubagentNamespace} calls so
* re-renders / multiple consumers of the same subagent don't issue
* parallel `getHistory` walks.
*/
#namespaceResolves = /* @__PURE__ */ new Map();
/**
* In-flight hydrate-time discovery seed ({@link #seedDiscoveryFromHistory}):
* a single bounded `getHistory` page that bulk-promotes every
* still-default subagent namespace and seeds subgraph hosts. Per-card
* {@link resolveSubagentNamespace} calls await this shared promise
* instead of each firing their own `getHistory` walk, so opening N
* cards right after reconnect costs one history read, not N. Re-armed
* per hydrate cycle and cleared once it settles.
*/
#discoverySeedPromise;
#scopedHistorySeeds = /* @__PURE__ */ new Map();
#rootEventListeners = /* @__PURE__ */ new Set();
#rootBus;
#activeRunId;
/**
* Run id `run.start` returned for the in-flight local `submit()`. Kept
* apart from {@link #activeRunId}, which a rolled-back submit's late
* response can still overwrite.
*/
#submittedRunId;
/** `true` from a local run's dispatch until its `run.start` answers. */
#submittedRunIdPending = false;
/** One per armed terminal wait: re-runs the lifecycle events it held until `run.start` answered. */
#heldLifecycleReleases = /* @__PURE__ */ new Set();
#localRunDepth = 0;
/**
* `true` once a root `values` event has been applied for the current
* optimistic run. Reset to `false` in {@link #beginOptimistic} and
* read in {@link #settleOptimistic}: when a run terminates without
* the server ever echoing a `values` snapshot, optimistically-merged
* non-message keys are rolled back to their pre-submit values.
*/
#sawValuesForRun = false;
/**
* Single-shot hydration promise. Exposed via `hydrationPromise`
* so Suspense wrappers can throw it until the first hydrate
* completes (resolve) or fails (reject). Reset whenever a new
* hydrate cycle begins so `<Suspense>` boundaries re-suspend on
* thread switch.
*/
#hydrationPromise;
#resolveHydration;
#rejectHydration;
/**
* Tool assembler lives for the lifetime of a thread; reset on
* rebind so a fresh thread starts with a clean slate.
*/
#rootToolAssembler = new require_tools.ToolCallAssembler();
#rootMessages;
#lifecycleLoading;
#submitter;
#threadListeners = /* @__PURE__ */ new Set();
/**
* Create a controller around a LangGraph client and optional initial thread.
*
* @param options - Runtime configuration, client, thread id, and initial state.
*/
constructor(options) {
this.#options = options;
this.#messagesKey = options.messagesKey ?? "messages";
this.#currentThreadId = options.threadId ?? null;
this.#rootBus = {
channels: ROOT_PUMP_CHANNELS,
subscribe: (listener) => {
this.#rootEventListeners.add(listener);
return () => {
this.#rootEventListeners.delete(listener);
};
},
trySeedFromHistory: (params) => this.#trySeedProjectionFromHistory(params)
};
this.registry = new require_channel_registry.ChannelRegistry(this.#rootBus);
this.subagentStore = this.#subagents.store;
this.subgraphStore = this.#subgraphs.store;
this.subgraphByNodeStore = this.#subgraphs.byNodeStore;
this.rootStore = new require_store.StreamStore(this.#createInitialSnapshot());
this.#rootMessages = new require_root_message_projection.RootMessageProjection({
messagesKey: this.#messagesKey,
store: this.rootStore
});
this.#lifecycleLoading = new require_lifecycle_loading_tracker.LifecycleLoadingTracker({
store: this.rootStore,
isDisposed: () => this.#disposed,
onSettled: () => this.#submitter.scheduleQueueDrainOnObservedIdle(),
ignore: (event) => this.#submittedRunMatch(event) != null
});
this.messageMetadataStore = this.#messageMetadata.store;
this.queueStore = new require_store.StreamStore(require_queue_adapter.EMPTY_QUEUE);
this.#submitter = new require_submit_coordinator.SubmitCoordinator({
options: this.#options,
rootStore: this.rootStore,
queueStore: this.queueStore,
getDisposed: () => this.#disposed,
getCurrentThreadId: () => this.#currentThreadId,
setCurrentThreadId: (threadId) => {
this.#currentThreadId = threadId;
},
rememberSelfCreatedThreadId: (threadId) => {
this.#selfCreatedThreadIds.add(threadId);
},
isSelfCreatedThreadId: (threadId) => this.#selfCreatedThreadIds.has(threadId) || this.#missingThreadIds.has(threadId),
hydrate: (threadId) => this.hydrate(threadId),
ensureThread: (threadId, deferRootPump) => this.#ensureThread(threadId, deferRootPump),
startDeferredRootPump: () => this.#startDeferredRootPump(),
abandonDeferredRootPump: () => this.#abandonDeferredRootPump(),
forgetSelfCreatedThreadId: (threadId) => {
this.#selfCreatedThreadIds.delete(threadId);
this.#missingThreadIds.delete(threadId);
},
waitForRootPumpReady: () => this.#rootPumpReady,
awaitNextTerminal: (signal) => this.#awaitNextTerminal(signal),
awaitResumedRunTerminal: (signal) => this.#awaitResumedRunTerminal(signal),
onSubmitStart: () => {
this.#submitGeneration += 1;
this.#interruptCommandPending = this.#hydratedActiveInterruptIds != null;
this.#pendingInterruptEvents = this.#pendingHydrationInterruptEvents;
this.#pendingHydrationInterruptEvents = [];
},
onRunStart: () => this.#markLocalRunStart(),
onRunStartReturned: (runId) => {
this.#submittedRunId = runId;
this.#submittedRunIdPending = false;
for (const release of [...this.#heldLifecycleReleases]) release();
},
onRunCreated: (runId) => {
this.#advanceInterruptReplayBarrier();
this.#notifyCreated(runId);
},
onRunCompleted: (reason, runId) => this.#notifyCompleted(reason, runId),
onRunEnd: () => {
this.#interruptCommandPending = false;
this.#markLocalRunEnd();
},
beginOptimistic: (input) => this.#beginOptimistic(input),
settleOptimistic: (handle, event) => this.#settleOptimistic(handle, event)
});
this.#hydrationPromise = this.#createHydrationPromise();
/**
* Attach a default no-op catch so orphaned hydrationPromise
* rejections (e.g. controllers spawned during Suspense retries
* whose promise never gets subscribed to because the suspense
* cache already holds an earlier one) don't surface as unhandled
* rejections. Callers that attach their own handlers via .then()
* still receive the rejection on their derived promise.
*/
this.#hydrationPromise.catch(() => void 0);
/**
* Kick off the initial hydrate eagerly when a caller-supplied
* thread id is present. Suspense consumers throw
* `hydrationPromise` on the very first render, which unmounts the
* subtree before any `useEffect` can run — so if we waited for an
* effect to drive the hydrate we'd deadlock. Firing here makes
* the promise settle independently of the component lifecycle.
*/
if (this.#currentThreadId != null) this.hydrate(this.#currentThreadId);
else this.#resolveHydration();
}
/**
* Promise that settles the first time {@link hydrate} finishes on
* the current thread. Resolves on a clean hydration, rejects when
* the thread-state fetch errors. A fresh promise is installed on
* every thread swap so `<Suspense>` wrappers re-suspend on
* `switchThread`.
*/
get hydrationPromise() {
return this.#hydrationPromise;
}
/**
* Create the deferred promise backing the current hydration cycle.
*/
#createHydrationPromise() {
return new Promise((resolve, reject) => {
this.#resolveHydration = resolve;
this.#rejectHydration = reject;
});
}
/**
* Replace the current hydration promise before a thread swap.
*/
#resetHydrationPromise() {
/**
* Swallow rejection on the orphaned promise so Node doesn't
* flag it as unhandled; Suspense callers that still hold a
* reference see the rejection they subscribed to.
*/
this.#hydrationPromise.catch(() => void 0);
this.#hydrationPromise = this.#createHydrationPromise();
}
/**
* Load thread state for hydration, preferring the active custom
* adapter's `getState()` when present.
*/
async #fetchHydrationState() {
const threadId = this.#currentThreadId;
if (threadId == null) return null;
const transport = this.#options.transport;
if (transport != null && typeof transport === "object" && typeof transport.getState === "function") return await transport.getState();
return this.#options.client.threads.getState(threadId);
}
/**
* Fetch the checkpointed thread state and seed the root snapshot.
* Re-calling with a different `threadId` swaps the underlying
* {@link ThreadStream}, rewires the registry to the new thread, and
* resets assemblers.
*
* @param threadId - Optional replacement thread id; `null` clears the active thread.
*/
async hydrate(threadId) {
if (this.#disposed) return;
const target = threadId === void 0 ? this.#currentThreadId : threadId;
const changed = target !== this.#currentThreadId;
this.#currentThreadId = target ?? null;
this.#discoverySeedPromise = void 0;
this.#scopedHistorySeeds.clear();
this.rootStore.setState((s) => ({
...s,
threadId: this.#currentThreadId
}));
if (changed) {
/**
* Swap to a new thread: re-arm the hydration promise so any
* Suspense boundary remounted against the new id suspends again.
*/
this.#resetHydrationPromise();
/**
* Kick teardown so the synchronous abort (unbind, drop onEvent,
* close the root subscription, reset assemblers) runs before we
* touch the snapshot. Then reset immediately so the UI does not
* wait on pump/close. An interrupted thread parks the root pump
* on `waitForResume()`; awaiting teardown *before* this reset
* left `threadId` null while `messages` still showed the previous
* conversation (`useStream` fire-and-forgets `hydrate()`).
*/
const teardown = this.#teardownThread();
this.rootStore.setState(() => ({
...this.#createInitialSnapshot(),
threadId: this.#currentThreadId
}));
/**
* Drop queued submissions — they were targeted at the previous
* thread so dispatching them against the new thread would be
* surprising. Mirrors the legacy `StreamOrchestrator` behaviour.
*/
this.queueStore.setState(() => require_queue_adapter.EMPTY_QUEUE);
await teardown;
}
if (this.#currentThreadId == null) {
this.rootStore.setState((s) => ({
...s,
isThreadLoading: false
}));
this.#resolveHydration();
return;
}
/**
* Self-generated thread ids have nothing to fetch server-side yet
* — the thread is created lazily by the first `run.start`. Calling
* `threads.getState()` here would return a 404 and surface a
* spurious error to the UI.
*/
if (this.#selfCreatedThreadIds.has(this.#currentThreadId)) {
this.rootStore.setState((s) => ({
...s,
isThreadLoading: false
}));
this.#resolveHydration();
return;
}
this.rootStore.setState((s) => ({
...s,
isThreadLoading: true
}));
const hydratedThreadId = this.#currentThreadId;
let hydrationError;
let threadExists = false;
let threadMissing = false;
let threadActive = true;
let hasActiveInterrupts = false;
try {
const state = await this.#fetchHydrationState();
if (this.#disposed || this.#currentThreadId !== hydratedThreadId) return;
threadExists = state != null;
threadActive = isThreadStateActive(state);
const checkpointTime = Date.parse(state?.created_at ?? "");
this.#hydratedCheckpointTime = Number.isNaN(checkpointTime) ? void 0 : checkpointTime;
this.#missingThreadIds.delete(hydratedThreadId);
if (state?.values != null) {
/**
* `threads.getState()` returns the legacy `ThreadState` shape
* where `parent_checkpoint` is an object (`{ thread_id,
* checkpoint_id, checkpoint_ns }`). Synthesize the v2
* `Checkpoint` envelope (matching the `checkpoints` channel
* payload) so hydrated messages also get their
* `parentCheckpointId` populated for fork / edit flows.
*/
const checkpointId = state.checkpoint?.checkpoint_id;
const parentCheckpointId = state.parent_checkpoint?.checkpoint_id ?? void 0;
/**
* Carry the checkpoint `step` from `getState()` metadata so the
* root message projection treats this seed as the authoritative
* latest superstep. The content pump's reconnect replay emits
* older checkpoints (lower step); marking the seed's step lets
* the projection reject those as stale instead of letting them
* remove the seeded message tail (the final assistant turn).
*/
const seedStep = state.metadata?.step;
const syntheticCheckpoint = typeof checkpointId === "string" ? {
id: checkpointId,
...parentCheckpointId != null ? { parent_id: parentCheckpointId } : {},
...typeof seedStep === "number" ? { step: seedStep } : {}
} : void 0;
this.#applyValues(state.values, syntheticCheckpoint);
/**
* Seed subagent discovery from checkpoint messages so deep-agent
* cards render on refresh without waiting for SSE replay. Zero
* extra HTTP (reuses the `getState` payload); mirrors the
* interrupt-seeding below. `#subagents` was cleared in
* `#teardownThread`, and `seedFromCheckpointMessages` is
* idempotent, so this is safe on re-hydrate.
*/
const seedMessages = state.values[this.#messagesKey];
if (Array.isArray(seedMessages)) {
this.#subagents.seedFromCheckpointMessages(seedMessages);
const finishedToolCalls = require_tool_calls.seedToolCallsFromMessages(ROOT_NAMESPACE, require_message_coercion.ensureMessageInstances(seedMessages)).filter((toolCall) => toolCall.status === "finished");
if (finishedToolCalls.length > 0) this.rootStore.setState((s) => ({
...s,
toolCalls: finishedToolCalls.reduce((toolCalls, toolCall) => require_tool_calls.upsertToolCall(toolCalls, toolCall), s.toolCalls)
}));
/**
* An idle (finished) thread defers its root SSE pump; the first
* `submit()` brings it up and the transport replays the finished
* run from `seq=0`. Seal the seeded message ids so that replay's
* `messages` channel deltas can't downgrade the already-complete
* tail to empty partials (a visible "messages replay"). Only safe
* for idle threads, where every seeded message is final — an
* active thread's tail may still be streaming and must keep
* receiving deltas. New ids from the next run are never sealed,
* and the seal lifts once a newer checkpoint advances the
* timeline.
*/
if (!threadActive) {
const sealedIds = seedMessages.map((message) => message?.id).filter((id) => typeof id === "string");
if (sealedIds.length > 0) this.#rootMessages.sealMessageIds(sealedIds);
}
}
}
/**
* Converge to server truth: drop any optimistic messages the
* server state does not contain (`pending` / `failed` that were
* never persisted — e.g. a failed run's user message). Echoed
* ids were flipped to `"sent"` by `#applyValues` above and so are
* excluded from `unpersistedOptimisticIds()`.
*/
const unpersisted = this.#messageMetadata.unpersistedOptimisticIds();
if (unpersisted.size > 0) {
this.#rootMessages.dropMessages(unpersisted);
this.#messageMetadata.forget(unpersisted);
}
/**
* Sync the visible interrupt list to the server's authoritative
* `state.tasks[].interrupts`. Without this, SSE replay of past
* `input.requested` events would re-add resolved interrupts to
* the UI on every page navigation back to the thread.
*
* Only runs when `state.tasks` is an array — runtimes that don't
* surface tasks in `threads.getState()` are left untouched (an
* unconditional overwrite would wipe any in-flight interrupt
* state the wildcard watcher may already have recorded).
*/
if (Array.isArray(state?.tasks)) {
const generationAtFetch = this.#submitGeneration;
const { activeInterrupts, activeIds } = collectActiveInterruptsFromTasks(state.tasks);
hasActiveInterrupts = activeInterrupts.length > 0;
for (const id of activeIds) this.#resolvedInterrupts.delete(id);
this.rootStore.setState((s) => ({
...s,
interrupts: activeInterrupts,
interrupt: activeInterrupts[0]
}));
if (this.#submitGeneration === generationAtFetch) {
this.#hydratedActiveInterruptIds = activeIds;
this.#interruptReplayThroughSeq = void 0;
}
}
} catch (error) {
if (error?.status !== 404) {
hydrationError = error;
this.rootStore.setState((s) => ({
...s,
error
}));
} else threadMissing = true;
} finally {
this.rootStore.setState((s) => ({
...s,
isThreadLoading: false
}));
if (hydrationError != null) this.#rejectHydration(hydrationError);
else this.#resolveHydration();
}
/**
* Missing threads must not open `/stream/events`. The in-mem
* runtime accepts the join against a not-yet-created id but the
* subscription never attaches to the live channel — the first run
* then delivers 0 bytes until idle reconnect. Park the pump until
* `submit()`'s `run.start` creates the row (see
* {@link #startDeferredRootPump}). Tracked in `#missingThreadIds`
* (not `#selfCreatedThreadIds`) so a later hydrate revalidates —
* another client may create the same id between visits.
*/
if (threadMissing) {
this.#missingThreadIds.add(hydratedThreadId);
return;
}
/**
* Open the shared subscription on mount so in-flight server-side
* runs are observed even when no local `submit()` is active — BUT
* only when the thread is actually active (see
* {@link isThreadStateActive}). A finished thread's cards are seeded
* from `getState()` + the bounded `getHistory()` below, so opening
* the depth-1 content pump just to replay a completed run and idle
* forever is pure waste. When idle we take the deferred path: the
* pump (and watcher) come up on the first local `submit()` via
* {@link #startDeferredRootPump}, exactly like a self-created thread.
* The transport replays from `seq=0` on the deferred subscribe, so
* nothing is missed.
*/
const thread = this.#ensureThread(hydratedThreadId, !threadActive);
/**
* Picks up runs pending from before this page load, or another tab.
* Fire-and-forget. Must run after `#ensureThread` above, not before:
* `AgentServerQueueAdapter`'s own `getThread` callback also calls
* `#ensureThread(threadId, true)`, which would otherwise create
* `#thread` with the pump deferred before the line above decides it
* should be eager.
*/
this.#submitter.hydrateQueue(hydratedThreadId);
/**
* Start the wildcard lifecycle watcher up-front for existing,
* active threads. The root content pump runs at `depth: 1`, which
* covers root-namespace and one-deep events but not arbitrarily-
* nested subagent / subgraph lifecycle — the dedicated watcher
* handles those.
*
* Skipped when:
* - the thread is idle/finished — there are no live events to
* watch; discovery is seeded from history below, and the watcher
* starts with the deferred pump on the first `submit()`.
* - the thread is self-created (new) — the watcher would 404
* against a not-yet-existent thread; `submitRun` / `respondInput`
* call `startLifecycleWatcher` on first submission instead.
*/
if (threadExists && threadActive) {
thread.startLifecycleWatcher();
/**
* Seed `isLoading` from the checkpoint itself, not just a live
* lifecycle event: a subscriber that joins after a foreign run's
* own `running` event already fired would otherwise never observe
* it. Excludes a thread merely parked at an interrupt — that's
* waiting on a human, not "loading", matching
* `LifecycleLoadingTracker`'s own `interrupted → isLoading = false`.
*/
if (threadActive && !hasActiveInterrupts) this.rootStore.setState((s) => s.isLoading ? s : {
...s,
isLoading: true
});
}
if (threadExists) {
/**
* Seed subgraph discovery and promote subagent execution
* namespaces from a single bounded `getHistory` page. Subgraph
* structure is not present in the root checkpoint messages
* (unlike subagents), so it can only be reconstructed from
* history. Fire-and-forget — not awaited into the hydration
* promise, so Suspense / first paint stay unblocked; cards fill
* in progressively when it resolves.
*
* Held in `#discoverySeedPromise` so lazy per-card
* {@link resolveSubagentNamespace} calls coalesce onto this single
* read instead of each firing their own `getHistory` walk.
*/
const seed = this.#seedDiscoveryFromHistory(this.#currentThreadId).finally(() => {
if (this.#discoverySeedPromise === seed) this.#discoverySeedPromise = void 0;
});
this.#discoverySeedPromise = seed;
}
}
/**
* One bounded, non-blocking `getHistory` read at hydrate that seeds
* subgraph hosts and bulk-promotes still-default subagent execution
* namespaces. O(1) in requests regardless of subagent/subgraph count.
*/
async #seedDiscoveryFromHistory(threadId) {
try {
const history = await require_namespace_from_history.getHistoryPage(this.#options.client, threadId, { limit: 20 });
if (this.#disposed || this.#currentThreadId !== threadId) return;
this.#primeScopedHistorySeedsFromHistory(threadId, history);
const hosts = require_namespace_from_history.collectSubgraphHostNamespaces(history);
this.#subgraphs.seedFromHistory(hosts);
const defaultOnlyIds = [...this.#subagents.snapshot.values()].filter(namespaceIsDefaultOnly).map((entry) => entry.id);
if (defaultOnlyIds.length > 0) {
const map = require_namespace_from_history.mapSubagentNamespaces(history, defaultOnlyIds, this.#messagesKey);
for (const [id, segment] of map) this.#subagents.applyExecutionNamespace(id, segment);
}
} catch {}
}
/**
* Lazily resolve a single subagent's execution namespace from
* checkpoint history. Intended call site: the first scoped
* `useMessages` / `useToolCalls` mount for a subagent whose namespace
* is still the default `tools:<toolCallId>`. A fallback for the
* hydrate-time bulk seed ({@link #seedDiscoveryFromHistory}) — most
* subagents are already promoted by the time a panel opens.
*
* Skips ids already promoted past default-only (SSE replay or a prior
* resolve). Concurrent calls for the same id share one `getHistory`
* walk via {@link #namespaceResolves}.
*
* @param toolCallId - Parent `task` tool-call id (the subagent's discovery key).
*/
async resolveSubagentNamespace(toolCallId) {
if (this.#disposed) return;
const threadId = this.#currentThreadId;
if (threadId == null) return;
if (!namespaceIsDefaultOnly(this.#subagents.snapshot.get(toolCallId))) return;
const inflight = this.#namespaceResolves.get(toolCallId);
if (inflight != null) return inflight;
const run = (async () => {
try {
/**
* Coalesce onto the hydrate-time discovery seed. That single
* bounded `getHistory` page bulk-promotes every default-only
* subagent, so when many cards mount at once (the common
* reconnect case) they all await this one read instead of each
* firing their own walk. Re-check after it settles: usually the
* bulk seed already promoted us and no further fetch is needed.
*/
const seed = this.#discoverySeedPromise;
if (seed != null) {
await seed;
if (this.#disposed || this.#currentThreadId !== threadId) return;
if (!namespaceIsDefaultOnly(this.#subagents.snapshot.get(toolCallId))) return;
}
const map = await require_namespace_from_history.resolveSubagentNamespaces(this.#options.client, threadId, [toolCallId], { messagesKey: this.#messagesKey });
if (this.#disposed || this.#currentThreadId !== threadId) return;
const segment = map.get(toolCallId);
if (segment != null) this.#subagents.applyExecutionNamespace(toolCallId, segment);
} catch {} finally {
this.#namespaceResolves.delete(toolCallId);
}
})();
this.#namespaceResolves.set(toolCallId, run);
return run;
}
/**
* Try to satisfy a scoped selector projection from checkpoint history
* instead of opening a scoped `/events` replay.
*
* This is only valid while the root pump is deferred, which means hydrate
* has classified the thread as idle/stale. Active and interrupted threads
* must keep using SSE so ongoing work and resumes are observed. For an idle
* thread, though, a late-mounted subagent card only needs the latest scoped
* checkpoint snapshot; opening `/events` just asks the server to replay work
* that already finished and can be slow for namespaces discovered from
* history.
*
* Returns `true` when the projection was handled without `/events`. That can
* mean either the store was seeded from namespace-specific history, or the
* projection targeted a default subagent namespace that should be skipped
* because hydrate promoted it to its execution namespace. Returns `false`
* when the caller should fall back to the normal subscription path.
*/
async #trySeedProjectionFromHistory(params) {
const threadId = this.#currentThreadId;
if (this.#disposed || threadId == null || params.namespace.length === 0 || !this.#rootPumpDeferred || this.#selfCreatedThreadIds.has(threadId) || this.#missingThreadIds.has(threadId)) return false;
if (await this.#skipDefaultSubagentProjection(params.namespace, threadId)) return true;
if (this.#disposed || this.#currentThreadId !== threadId || !this.#rootPumpDeferred) return false;
const seed = await this.#getScopedHistorySeed(threadId, params.namespace);
if (seed == null || this.#disposed || this.#currentThreadId !== threadId || !this.#rootPumpDeferred) return false;
if (await this.#skipDefaultSubagentProjection(params.namespace, threadId)) return true;
if (params.kind === "messages") {
params.store.setValue(seed.messages);
return true;
}
params.store.setValue(seed.toolCalls);
return true;
}
/**
* Suppress subscriptions for placeholder subagent namespaces once hydrate has
* resolved the real execution namespace.
*
* Deep-agent discovery first creates cards at `tools:<toolCallId>`. The
* actual worker history usually lives under a different checkpoint namespace
* such as `tools:<uuid>`, and hydrate resolves that mapping from the bounded
* root history seed. React/Vue/Svelte/Angular selector effects can mount
* while that seed is still in flight, so this helper waits for it and then
* returns `true` when the original placeholder namespace is stale. Returning
* `true` tells the projection runtime not to open an `/events` subscription
* for the wrong namespace; the framework will re-render with the promoted
* card namespace and acquire the real projection.
*/
async #skipDefaultSubagentProjection(namespace, threadId) {
const toolCallId = defaultSubagentToolCallId(namespace);
if (toolCallId == null) return false;
if (!namespaceIsDefaultOnly(this.#subagents.snapshot.get(toolCallId))) return false;
const seed = this.#discoverySeedPromise;
if (seed != null) await seed;
if (this.#disposed || this.#currentThreadId !== threadId) return true;
return !namespaceIsDefaultOnly(this.#subagents.snapshot.get(toolCallId));
}
/**
* Load and cache the latest checkpoint snapshot for one scoped namespace.
*
* `useMessages(stream, subagent)` and `useToolCalls(stream, subagent)` often
* mount together. Both need the same namespace-specific history page, so the
* controller keeps an in-flight promise per `threadId + checkpoint_ns`.
* The cache may already be primed by the hydrate-time discovery history page;
* otherwise this method performs a narrow `checkpoint_ns` read and derives
* both projection snapshots from that one response:
*
* - `messages` are coerced with the stream-local message coercion rules, so
* serialized `content_blocks` and tool-call metadata hydrate correctly.
* - `toolCalls` are reconstructed from AI tool calls plus matching
* ToolMessages, enough for finished/stale card panels without replaying
* the `tools` channel.
*
* Returns `null` when history does not contain usable values, or the request
* fails. Callers treat that as a signal to fall back to `/events` so custom
* servers or unusual state shapes still work.
*/
#getScopedHistorySeed(threadId, namespace) {
const checkpointNs = require_namespace.namespaceKey(namespace);
const key = `${threadId}|${checkpointNs}`;
const existing = this.#scopedHistorySeeds.get(key);
if (existing != null) return existing;
const seed = (async () => {
try {
const values = (await require_namespace_from_history.getHistoryPage(this.#options.client, threadId, {
limit: 1,
checkpoint: { checkpoint_ns: checkpointNs }
}))[0]?.values;
if (values == null || typeof values !== "object") return null;
const messages = extractAndCoerceMessagesWithFallback(values, this.#messagesKey);
if (messages == null) return null;
return {
messages,
toolCalls: require_tool_calls.seedToolCallsFromMessages(namespace, messages)
};
} catch {
return null;
}
})();
this.#scopedHistorySeeds.set(key, seed);
return seed;
}
/**
* Reuse the hydrate-time discovery history page as scoped projection data
* when it already contains checkpoint values for a namespace.
*
* The discovery read is required to resolve subagent execution namespaces and
* subgraph hosts. That same page often includes the latest values for those
* namespaces, so priming `#scopedHistorySeeds` here lets later
* `useMessages(stream, subagent)` / `useToolCalls(stream, subagent)` mounts
* hydrate from memory instead of issuing an immediate second `getHistory`
* request. If a namespace is not present in the bounded page,
* `#getScopedHistorySeed` still falls back to a targeted `checkpoint_ns`
* history read.
*/
#primeScopedHistorySeedsFromHistory(threadId, history) {
for (const state of history) {
const checkpointNs = state.checkpoint?.checkpoint_ns;
if (typeof checkpointNs !== "string" || checkpointNs.length === 0) continue;
const namespace = checkpointNs.split("\0").filter((segment) => segment.length > 0);
if (namespace.length === 0) continue;
const key = `${threadId}|${require_namespace.namespaceKey(namespace)}`;
if (this.#scopedHistorySeeds.has(key)) continue;
const values = state.values;
if (values == null || typeof values !== "object") continue;
const messages = extractAndCoerceMessagesWithFallback(values, this.#messagesKey);
if (messages == null) continue;
this.#scopedHistorySeeds.set(key, Promise.resolve({
messages,
toolCalls: require_tool_calls.seedToolCallsFromMessages(namespace, messages)
}));
}
}
/**
* Submit input to the active thread.
*
* To resume a pending interrupt, use {@link respond} instead.
*
* @param input - Input payload for a new run.
* @param options - Per-run config, metadata, multitask behavior, and callbacks.
*/
async submit(input, options) {
await this.#submitter.submit(input, options);
}
/**
* Disconnect the client from the active run and mark the controller
* idle. By default also cancels the run server-side; pass
* `{ cancel: false }` or call {@link disconnect} to keep the agent
* running (join/rejoin).
*/
async stop(options) {
if (options?.cancel ?? true) {
const threadId = this.#currentThreadId;
const runId = this.#activeRunId;
if (runId != null && this.rootStore.getSnapshot().isLoading) {
const dropped = this.#rootMessages.closeRun({
sync: true,
unfinishedOnly: true
});
if (dropped.size > 0) this.#messageMetadata.forget(dropped);
}
if (threadId != null && runId != null) try {
await this.#options.client.runs.cancel(threadId, runId);
} catch {}
}
await this.#submitter.stop();
}
/**
* Disconnect the client without cancelling the run server-side.
* Alias for `stop({ cancel: false })`.
*/
async disconnect() {
return this.stop({ cancel: false });
}
#markLocalRunStart() {
this.#localRunDepth += 1;
this.#submittedRunId = void 0;
this.#submittedRunIdPending = true;
}
#markLocalRunEnd() {
this.#localRunDepth = Math.max(0, this.#localRunDepth - 1);
if (this.#localRunDepth === 0) this.#submittedRunIdPending = false;
}
#notifyCreated(runId) {
this.#activeRunId = runId;
try {
this.#options.onCreated?.({ runId });
} catch {}
}
#notifyCompleted(reason, runId = this.#activeRunId) {
if (runId != null && runId === this.#activeRunId) this.#activeRunId = void 0;
setTimeout(() => {
if (this.#disposed) return;
try {
this.#options.onCompleted?.(runId == null ? { reason } : {
runId,
reason
});
} catch {}
}, 0);
}
#runLifecycleListener = (event) => {
if (this.#localRunDepth > 0) return;
if (event.method !== "lifecycle") return;
if (!require_namespace.isRootNamespace(event.params.namespace)) return;
if (!this.rootStore.getSnapshot().isLoading) return;
const lifecycle = event.params.data;
const reason = lifecycleReason(lifecycle?.event);
if (reason == null) return;
this.#notifyCompleted(reason);
};
/**
* Cancel a queued submission by id. Returns `true` when the entry
* was found and removed, `false` otherwise.
*
* Removes the client-side entry either way; also issues a server-side
* cancel when the configured queue adapter is server-backed (see
* `AgentServerAdapter.serverQueue`). A no-op for the default,
* client-only adapter.
*
* @param id - Client-side queue entry id to remove.
*/
async cancelQueued(id) {
return this.#submitter.cancelQueued(id);
}
/**
* Drop every queued submission. Cancels the underlying server-side
* runs too when the configured queue adapter is server-backed.
*/
async clearQueue() {
await this.#submitter.clearQueue();
}
/**
* Respond to a single pending protocol interrupt.
*
* When `options.interruptId` is omitted, resolution walks
* {@link ThreadStream.interrupts `thread.interrupts`} from newest to
* oldest and picks the first entry whose `interruptId` has not already
* been resolved by a prior `respond()` call. That entry may be at the
* root (`namespace: []`) or inside a subgraph (non-empty `namespace`).
* {@link RootSnapshot.interrupts `rootStore.interrupts`} /
* framework `stream.interrupts` mirrors the same pending interrupts
* (including nested namespaces) for UI rendering.
*
* Omitting `interruptId` is fine when exactly one interrupt is pending.
* When several can be active (parallel subagents, fan-out, nested
* graphs), pass an explicit `interruptId` so you resume the interrupt
* the user acted on. `namespace` is resolved automatically from
* `thread.interrupts` / `stream.interrupts` when omitted.
*
* To resume several interrupts pending at the same checkpoint in one
* command, use {@link respondAll} — sequential single `respond()` calls
* would not work, since the first resume starts a run, leaving the
* others with no interrupted run to respond to.
*
* The server validates `namespace` against the pending interrupt. Root
* interrupts use `namespace: []`. Subgraph interrupts carry the exact
* tuple on each {@link Interrupt} / {@link InterruptPayload} entry.
*
* @param response - Payload sent back to the interrupted namespace.
* @param options - Optional target (`interruptId` / `namespace`) and
* run-level `config` / `metadata` folded into the run that services
* the resume (model/user config, trigger source, test flags, …).
* Equivalent to the same fields on {@link StreamSubmitOptions}.
*
* @example Single pending interrupt (safe to omit a target)
* ```ts
* await controller.respond({ approved: true });
* ```
*
* @example Carry run config / metadata onto the resume
* ```ts
* await controller.respond(
* { approved: true },
* { config: { configurable: { model: "gpt-4o" } }, metadata: { source: "ui" } },
* );
* ```
*
* @example Multiple / nested interrupts — target by id
* ```tsx
* for (const intr of stream.interrupts) {
* await stream.respond(decide(intr.value), { interruptId: intr.id! });
* }
* ```
*
* Each {@link InterruptPayload} on `thread.interrupts` and each
* {@link Interrupt} on `stream.interrupts` mirrors an
* `input.requested` event with its protocol `namespace`.
*/
async respond(response, options) {
if (this.#disposed || this.#thread == null) throw new Error("No active thread to respond to.");
const resolved = options?.interruptId != null ? {
interruptId: options.interruptId,
namespace: this.#resolveNamespaceForInterruptId(options.interruptId, options.namespace)
} : this.#resolveInterruptForResume();
if (resolved == null) throw new Error("No pending interrupt to respond to.");
const thread = this.#thread;
const prepared = options?.update != null ? this.#beginOptimistic(options.update) : void 0;
const dispatchUpdate = this.#resolveDispatchUpdate(options?.update, prepared);
try {
await this.#submitter.dispatchResume(async () => {
await thread.respondInput({
namespace: resolved.namespace,
interrupt_id: resolved.interruptId,
response: require_hitl_interrupt_payload.normalizeHitlResponseForServer(response),
...dispatchUpdate != null ? { update: dispatchUpdate } : {},
...options?.goto != null ? { goto: options.goto } : {},
config: options?.config,
metadata: options?.metadata
});
this.#advanceInterruptReplayBarrier();
this.#markInterruptResolvedInRootStore(resolved.interruptId);
}, prepared?.handle);
} catch (error) {
this.#discardPendingInterruptEvents();
if (this.#disposed && isAbortLikeError(error)) return;
throw error;
}
}
/**
* Resume several pending interrupts at the same checkpoint in a single
* command.
*
* Required when a run pauses on multiple interrupts simultaneously
* (e.g. parallel tool-authorization prompts): a single
* `Command({ resume })` carrying every interrupt's payload resumes them
* together. Sequential {@link respond} calls would fail because the
* first resume starts a run, leaving the rest with no interrupted run to
* respond to.
*
* `responsesById` maps each pending `interruptId` to the payload sent
* back to it, so different interrupts can receive different responses
* (approve one, deny another). To send the *same* payload to several
* interrupts, build the map with that value for each id, e.g.
* `Object.fromEntries(ids.map((id) => [id, response]))`.
*
* The server resumes by `interruptId`, so namespaces are resolved
* internally from `getThread()?.interrupts` and need not be supplied.
*
* @param responsesById - Map of pending `interruptId` to its response
* payload. Must contain at least one entry.
* @param options - Optional run-level `config` / `metadata` folded into
* the single run that services the batched resume. Equivalent to the
* same fields on {@link StreamSubmitOptions}.
*
* @example Distinct payloads per interrupt
* ```tsx
* await stream.respondAll({
* [interruptA.id]: { approved: true },
* [interruptB.id]: { approved: false },
* });
* ```
*
* @example Same payload to every pending interrupt
* ```tsx
* await stream.respondAll(
* Object.fromEntries(stream.interrupts.map((i) => [i.id!, { approved: true }])),
* );
* ```
*/
async respondAll(responsesById, options) {
if (this.#disposed || this.#thread == null) throw new Error("No active thread to respond to.");
const entries = Object.entries(responsesById);
if (entries.length === 0) throw new Error("respondAll() requires at least one response.");
const thread = this.#thread;
const responses = entries.map(([interruptId, response]) => ({
interrupt_id: interruptId,
response: require_hitl_interrupt_payload.normalizeHitlResponseForServer(response),
namespace: this.#resolveNamespaceForInterruptId(interruptId)
}));
const prepared = options?.update != null ? this.#beginOptimistic(options.update) : void 0;
const dispatchUpdate = this.#resolveDispatchUpdate(options?.update, prepared);
try {
await this.#submitter.dispatchResume(async () => {
await thread.respondInput({
responses,
...dispatchUpdate != null ? { update: dispatchUpdate } : {},
...options?.goto != null ? { goto: options.goto } : {},
config: options?.config,
metadata: options?.metadata
});
this.#advanceInterruptReplayBarrier();
for (const { interrupt_id: interruptId } of responses) this.#markInterruptResolvedInRootStore(interruptId);
}, prepared?.handle);
} catch (error) {
this.#discardPendingInterruptEvents();
if (this.#disposed && isAbortLikeError(error)) return;
throw error;
}
}
/**
* Dispose the active thread, subscriptions, registry entries, and listeners.
*/
async dispose() {
if (this.#disposed) return;
this.#cancelPendingDispose();
this.#disposed = true;
this.#submitter.abortActiveRun();
await this.#teardownThread();
await this.registry.dispose();
this.#threadListeners.clear();
}
/**
* StrictMode-safe lifecycle hook for framework bindings.
*
* React 18+ `StrictMode` intentionally mounts → unmounts → remounts
* components in dev to surface effect-cleanup bugs. A naive
* `useEffect(() => () => controller.dispose())` would permanently
* tear the controller down on that first synthetic unmount, leaving
* every subsequent `submit()` a silent no-op.
*
* Call {@link activate} from the bind site's effect and return the
* result as the effect's cleanup. The controller uses deferred
* disposal: a `release()` only schedules a dispose on the next
* microtask, which is cancelled if another `activate()` arrives
* before it fires (the normal StrictMode remount path).
*/
activate() {
this.#cancelPendingDispose();
return () => {
if (this.#disposed) return;
this.#pendingDisposeTimer = setTimeout(() => {
this.#pendingDisposeTimer = null;
this.dispose().catch(() => void 0);
}, 0);
};
}
/**
* Cancel a deferred dispose scheduled by {@link activate}.
*/
#cancelPendingDispose() {
if (this.#pendingDisposeTimer != null) {
clearTimeout(this.#pendingDisposeTimer);
this.#pendingDisposeTimer = null;
}
}
/**
* Returns the bound {@link ThreadStream}, if one exists. Prefer
* {@link StreamController.rootStore} and selector projections for
* UI work; use this for low-level protocol access.
*/
getThread() {
return this.#thread;
}
/**
* Listen for `ThreadStream` lifecycle (swap on thread-id change,
* detach on dispose). The listener fires immediately with the
* current thread (may be `undefined`).
*
* @param listener - Callback invoked immediately and on every thread swap.
*/
subscribeThread(listener) {
this.#threadListeners.add(listener);
listener(this.#thread);
return () => {
this.#threadListeners.delete(listener);
};
}
/**
* Build the initial root snapshot from configured initial values.
*/
#createInitialSnapshot() {
const values = this.#options.initialValues ?? {};
return {
values,
messages: extractAndCoerceMessages(values, this.#messagesKey),
toolCalls: [],
interrupts: [],
interrupt: void 0,
isLoading: false,
isThreadLoading: this.#currentThreadId != null && !this.#selfCreatedThreadIds.has(this.#currentThreadId),
error: void 0,
threadId: this.#currentThreadId
};
}
/**
* Return the active thread stream, creating and binding one when needed.
*
* @param threadId - Thread id used when constructing the stream.
* @param deferRootPump - When `true`, build the ThreadStream and bind
* the registry but skip starting the persistent root SSE pump. Used
* for client-self-created thread ids whose server-side thread row
* doesn't exist yet — opening the pump's `subscription.subscribe`
* against a not-yet-existent thread produces a `404: Thread not
* found` protocol error that strands terminal lifecycle events and
* leaves the UI showing nothing until the user reloads. The pump is
* started later via {@link #startDeferredRootPump} after `submitRun`
* / `respondInput` commits the thread server-side.
*
* Note: PR 2381's `#runStartReady` gate covers the analogous race
* for the in-flight `run.start` send, but only when that send is
* already pending. `#ensureThread` runs *before* `submitRun` is
* called (and thus before the gate is armed), so on transports
* that subscribe synchronously (WebSocket) the deferred path is
* still required.
*/
#ensureThread(threadId, deferRootPump = false) {
if (this.#thread != null) {
if (!deferRootPump && !this.#rootPumpDeferred && this.#rootPump == null) this.#startRootPump(this.#thread);
return this.#thread;
}
this.#thread = this.#options.client.threads.stream(threadId, {
assistantId: this.#options.assistantId,
transport: this.#options.transport,
fetch: this.#options.fetch,
webSocketFactory: this.#options.webSocketFactory,
maxReconnectAttempts: this.#options.maxReconnectAttempts,
streamIdleReconnect: this.#options.streamIdleReconnect,
reconnectDelayMs: this.#options.reconnectDelayMs,
onReconnect: this.#options.onReconnect,
onConnected: this.#options.onConnected
});
this.registry.bind(this.#thread);
if (deferRootPump) {
this.#rootPumpReady = Promise.resolve();
this.#rootPumpDeferred = true;
} else this.#startRootPump(this.#thread);
this.#notifyThreadListeners();
return this.#thread;
}
/**
* Start the previously-deferred root SSE pump after the first
* `submitRun` / `respondInput` has committed the thread server-side.
*
* No-op when the pump was started eagerly in {@link #ensureThread}
* (i.e. for hydrated existing threads, or for any thread whose pump
* has already been brought up).
*/
#startDeferredRootPump() {
if (!this.#rootPumpDeferred) return;
if (this.#thread == null) return;
this.#rootPumpDeferred = false;
this.#startRootPump(this.#thread);
}
/**
* Abandon a deferred root pump that never started because its
* triggering dispatch (`submitRun` / `respondInput`) failed.
*
* Without this, the controller would be wedged in a state where:
* - `#thread` is wired but no content pump is open
* - `#rootPumpDeferred` stays `true`
*
* A retry submit must re-run `#ensureThread` from scratch (with
* `deferRootPump` still true — the id stays in
* `#selfCreatedThreadIds` until a successful dispatch). Tearing
* down `#thread` is the simplest recovery; the failed dispatch
* means there was nothing to subscribe to anyway.
*/
#abandonDeferredRootPump() {
if (!this.#rootPumpDeferred) return;
this.#rootPumpDeferred = false;
this.#teardownThread();
}
/**
* Close the current thread stream and reset per-thread assembly state.
*/
async #teardownThread() {
/**
* Invalidate the in-flight root pump before `close()` unparks it.
* `SubscriptionHandle.close()` leaves queued events intact and the
* iterator drains them before observing `closed`, so without this
* bump a parked interrupted pump would still dispatch stale
* `values` / `messages` into a snapshot `hydrate()` already cleared.
*/
this.#rootPumpGeneration += 1;
const thread = this.#thread;
this.#thread = void 0;
this.registry.bind(void 0);
this.#threadEventUnsubscribe?.();
this.#threadEventUnsubscribe = void 0;
this.#threadErrorUnsubscribe?.();
this.#threadErrorUnsubscribe = void 0;
/**
* Persistent lifecycle driver is scoped to the current thread
* stream. Remove it so a swap to a new thread starts with a clean
* listener set (a new one is installed in `#startRootPump`).
*/
this.#rootEventListeners.delete(this.#lifecycleLoading.listener);
this.#rootEventListeners.delete(this.#runLifecycleListener);
/**
* Close first so a pump parked on `waitForResume()` (interrupted
* run) unparks even if the unsubscribe RPC is slow. `close()` is
* idempotent; `unsubscribe()` is then a no-op once closed.
*/
const subscription = this.#rootSubscription;
this.#rootSubscription = void 0;
subscription?.close();
this.#rootPumpReady = void 0;
this.#rootPumpDeferred = false;
this.#rootMessages.reset();
this.#rootToolAssembler = new require_tools.ToolCallAssembler();
this.#lifecycleLoading.reset();
this.#subagents.reset();
this.#subgraphs.reset();
this.#scopedHistorySeeds.clear();
this.#activeRunId = void 0;
this.#localRunDepth = 0;
this.#messageMetadata.reset();
this.#hydratedActiveInterruptIds = null;
this.#hydratedCheckpointTime = void 0;
this.#interruptReplayThroughSeq = void 0;
this.#pendingHydrationInterruptEvents = [];
this.#discardPendingInterruptEvents();
this.queueStore.setState(() => require_queue_adapter.EMPTY_QUEUE);
this.#submitter.detachQueue();
try {
await subscription?.unsubscribe();
} catch {}
try {
await this.#rootPump;
} catch {}
this.#rootPump = void 0;
if (thread != null) {
try {
await thread.close();
} catch {}
this.#notifyThreadListeners();
}
}
/**
* Determine whether the configured transport uses the resumable event-stream path.
*/
#usesEventStreamTransport() {
const transport = this.#options.transport;
if (transport === "websocket") return false;
if (transport == null || transport === "sse") return true;
return typeof transport.openEventStream === "function";
}
/**
* Start the always-on root subscription pump for the provided thread.
*
* @param thread - Thread stream to subscribe to and fan out from.
*/
#startRootPump(thread) {
if (this.#rootPump != null) return;
const pumpGeneration = this.#rootPumpGeneration;
let resolveReady;
this.#rootPumpReady = new Promise((resolve) => {
resolveReady = resolve;
});
/**
* Wildcard discovery + interrupt tracking is delivered via the
* thread's dedicated lifecycle watcher (see `ThreadStream.onEvent`).
* This callback fires once per globally-unique event across both
* the content pump AND the watcher, so we can drive discovery
* runners and nested HITL capture without widening the content
* pump's narrow filter.
*/
this.#threadEventUnsubscribe = thread.onEvent((event) => this.#onWildcardEvent(event));
this.#threadErrorUnsubscribe = thread.onError((error) => {
this.rootStore.setState((s) => ({
...s,
error,
isLoading: false
}));
this.#rootSubscription?.close();
});
/**
* Persistent isLoading driver. Drives `isLoading` from
* root-namespace lifecycle events so that in-flight runs observed
* via `hydrate()` (not initiated by a local `submit()`) still flip
* the UI to loading. `running` → true; terminals → false. The
* optimistic `isLoading = true` inside `submit()` stays because
* that fires before any subscription event arrives.
*/
this.#rootEventListeners.add(this.#lifecycleLoading.listener);
this.#rootEventListeners.add(this.#runLifecycleListener);
const pump = (async () => {
try {
/**
* Root content pump: depth 1 is required because the controller
* classifies tool events at namespace length ≤ 1 as root-level
* (see `#onWildcardEvent`'s `isRootLevelTool` check). The deep-
* agent `task` dispatcher fires `tools.tool-started` at
* `["tools:<id>"]` (length 1), so a depth-0 filter would drop
* those events server-side before they reached `root.toolCalls`.
*
* Deeper content (subagent message tokens, values snapshots) is
* pulled in on demand by per-namespace selector projections
* (e.g. `useMessages(sub)`).
*/
const subscriptionPromise = thread.subscribe({
channels: [...ROOT_PUMP_CHANNELS],
namespaces: [[]],
depth: 1
});
if (this.#usesEventStreamTransport())
/**
* SSE streams can legitimately withhold response headers until
* the first event is available. Waiting for `subscribe()` here
* would deadlock new-thread submits: the run is not dispatched
* until the root pump is "ready", but the pump does not become
* ready until the run emits. `thread.subscribe()` has already
* registered the local subscription and scheduled the stream
* rotation by this point; waiting one microtask lets the fetch
* get kicked off without requiring headers to arrive.
*/
queueMicrotask(() => {
resolveReady?.();
resolveReady = void 0;
});
const subscription = await subscriptionPromise;
if (pumpGeneration !== this.#rootPumpGeneration) {
resolveReady?.();
resolveReady = void 0;
try {
subscription.close();
} catch {}
return;
}
resolveReady?.();
resolveReady = void 0;
this.#rootSubscription = subscription;
/**
* The SSE transport pauses the underlying subscription when
* a terminal root lifecycle event arrives (so `for await`
* loops observing a single run exit cleanly) and re-opens
* the next run's server stream on `#prepareForNextRun`,
* resuming the subscription handle. The root pump needs to
* survive that hand-off: we re-enter the inner `for await`
* for every resumed iteration until the subscription is
* permanently closed or the controller is disposed.
*/
while (!this.#disposed && pumpGeneration === this.#rootPumpGeneration) {
for await (const event of subscription) {
if (this.#disposed || pumpGeneration !== this.#rootPumpGeneration) break;
/**
* Resilience: isolate per-event dispatch from the pump loop.
*
* `#onRootEvent` runs synchronously and, transitively,
* invokes every root-bus listener (selector projections that
* opted into the shared stream) plus every `rootStore`
* subscriber. Some of those subscribers live in a React
* render tree — `useStream` drives
* `useSyncExternalStore`, so a misbehaving component can
* surface a render-phase error ("Maximum update depth
* exceeded", "The result of getSnapshot should be cached",
* etc.) that propagates out here.
*
* Without this guard, a single throw bubbles through the
* `for await` loop and terminates the root pump permanently.
* That is catastrophic: no more root events get processed —
* the terminal `lifecycle: completed` never lands, so
* `#awaitNextTerminal` never resolves, `isLoading` stays
* `true`, composers stay disabled, and the final assistant
* turn never commits to `stream.messages`. The UI looks
* hung even though the server is still emitting events
* (and `ThreadStream.onEvent` keeps firing).
*
* We therefore swallow the error and keep pumping. The
* the pump's correctness guarantees do not depend on any
* consumer behaving well.
*/
try {
this.#onRootEvent(event);
} catch {}
}
if (this.#disposed || pumpGeneration !== this.#rootPumpGeneration) break;
if (!subscription.isPaused) {
const snapshot = this.rootStore.getSnapshot();
if (snapshot.isLoading && snapshot.error == null) this.rootStore.setState((s) => ({
...s,
error: /* @__PURE__ */ new Error("Thread event stream closed before the active run completed."),
isLoading: false
}));
break;
}
await subscription.waitForResume();
}
} catch {
resolveReady?.();
resolveReady = void 0;
}
})();
this.#rootPump = pump;
pump.finally(() => {
if (pumpGeneration !== this.#rootPumpGeneration) return;
if (this.#rootPump !== pump) return;
this.#rootPumpGeneration += 1;
this.#rootSubscription = void 0;
this.#rootPump = void 0;
this.#rootPumpReady = void 0;
this.#threadEventUnsubscribe?.();
this.#threadEventUnsubscribe = void 0;
this.#threadErrorUnsubscribe?.();
this.#threadErrorUnsubscribe = void 0;
this.#rootEventListeners.delete(this.#lifecycleLoading.listener);
this.#rootEventListeners.delete(this.#runLifecycleListener);
});
}
/**
* Handle an event delivered via {@link ThreadStream.onEvent}.
*
* `onEvent` fires once per globally-unique event across the content
* pump and the wildcard lifecycle watcher, so this is the single
* entry point for wildcard discovery / interrupt tracking. It does
* NOT fan events out to the root bus (that's driven by the content
* pump iterator so root-bus short-circuits stay depth-1 scoped) and
* it does NOT process root content — messages/tools/values at root
* are handled by `#onRootEvent` off the content pump.
*
* @param event - Raw protocol event observed by the thread-wide listener.
*/
#onWildcardEvent(event) {
try {
this.#subagents.push(event);
} catch {}
this.#subgraphs.push(event);
this.#lifecycleLoading.handle(event);
/**
* `input.requested` events (including HITL inside a subagent /
* subgraph) are not always observable via the narrow content pump.
* Mirror every namespace into `rootStore.interrupts` here so UI
* state does not depend on the content pump being the first
* consumer. `ThreadStream.interrupts` is updated in parallel for
* resume targeting.
*/
this.#recordInterrupt(event);
this.#settleParkedInterruptsOnTerminal(event);
}
/**
* Process one root-pump event and update all root projections.
*
* @param event - Event yielded by the root subscription.
*/
#onRootEvent(event) {
try {
this.#subagents.push(event);
} catch {}
/**
* Fan root-pump events out to every root-bus listener (selector
* projections that opted into the shared stream,
* `#awaitTerminal`, etc.). The root bus mirrors the content
* pump's narrow scope (depth 1 at root) so projections that
* short-circuit via the bus stay bounded.
*/
if (this.#rootEventListeners.size > 0) for (const listener of this.#rootEventListeners) try {
listener(event);
} catch {}
const floor = this.#hydratedCheckpointTime;
if (floor != null && event.params.timestamp < floor) return;
/**
* `messages` and `tools` events are emitted under a node's
* namespace — for a typical StateGraph the LLM's token deltas
* land on `["model:<uuid>"]`, tool executions on
* `["tools:<uuid>"]`, etc. The orchestrator's own turns (root
* agent, or an orchestrator-scoped subgraph like `model:*` /
* `model_request:*`) belong in `root.messages` and
* `root.toolCalls`.
*
* Subagent / tool-internal branches do NOT:
* - `task:*` segment — legacy subagent convention.
* - `tools:*` segment — every tool execution is wrapped in a
* `tools` subgraph. For simple tools its only content is
* the eventual tool result (also echoed verbatim by
* `values.messages` so we don't lose anything). For the
* deep-agent `task` tool its content IS the spawned
* subagent's full message + tool stream, which is surfaced
* separately via `useMessages(stream, subagent)` /
* `useToolCalls(stream, subagent)`.
*
* We therefore drop `messages` events from any namespace that
* contains a `task:*` or `tools:*` segment; the authoritative
* tool-result text lands in `root.messages` via the root
* `values.messages` snapshot merge in `#applyValues`.
*/
const isInternalNamespace = require_namespace.isInternalWorkNamespace(event.params.namespace);
const hasLegacySubagentNamespace = require_namespace.isLegacySubagentNamespace(event.params.namespace);
if (event.method === "messages") {
if (!isInternalNamespace) this.#rootMessages.handleMessage(event);
return;
}
if (event.method === "tools") {
if (event.params.namespace.length <= 1 && !hasLegacySubagentNamespace) {
/**
* Record the `namespace → tool_call_id` association so that
* the ensuing `message-start` (role: "tool") at the same
* namespace can recover the `tool_call_id` (the `messages`
* channel's start event doesn't carry it directly).
*/
const toolData = event.params.data;
if (toolData.event === "tool-started" && typeof toolData.tool_call_id === "string") this.#rootMessages.recordToolCallNamespace(event.params.namespace, toolData.tool_call_id);
const tc = this.#rootToolAssembler.consume(event);
if (tc != null) this.rootStore.setState((s) => ({
...s,
toolCalls: require_tool_calls.upsertToolCall(s.toolCalls, tc)
}));
}
return;
}
/**
* The `checkpoints` channel carries the lightweight envelope
* (`id`, `parent_id`, `step`, `source`) emitted immediately
* before its companion `values` event on the same superstep.
* Buffer the envelope per-namespace so the ensuing `values`
* event at the same namespace can pair with it in `#applyValues`.
* The buffer is read-and-cleared on consumption so a subsequent
* `values` event without a new checkpoint doesn't reuse stale
* metadata.
*/
if (event.method === "checkpoints") {
const data = event.params.data;
this.#messageMetadata.bufferCheckpoint(event.params.namespace, data);
return;
}
if (!require_namespace.isRootNamespace(event.params.namespace)) return;
if (event.method === "values") {
const valuesEvent = event;
const bufferedCheckpoint = this.#messageMetadata.consumeCheckpoint(event.params.namespace);
this.#applyValues(valuesEvent.params.data, bufferedCheckpoint);
return;
}
if (event.method === "input.requested") {
this.#recordInterrupt(event);
return;
}
if (event.method === "lifecycle") {
/**
* Root lifecycle transitions are observed elsewhere
* (#awaitTerminal) to unblock `submit`. Here they drop a closed
* run's never-checkpointed messages.
*/
const lifecycle = event.params.data;
const dropped = this.#rootMessages.applyLifecycle(lifecycle?.event);
if (dropped.size > 0) this.#messageMetadata.forget(dropped);
}
}
/**
* How a lifecycle event relates to the run a local `submit()` started.
* New subscriptions replay earlier runs' terminals, and accepting one
* would end the submitted run early: `"other"` marks those, `"pending"`
* an event that names a run before `run.start` has answered. `undefined`
* when it can't be told apart (no submit in flight, no run named, or no
* run id returned).
*/
#submittedRunMatch(event) {
if (event.method !== "lifecycle" || this.#localRunDepth === 0) return;
const runId = synthesizedEventRunId(event.event_id);
if (runId == null) return void 0;
if (this.#submittedRunIdPending) return "pending";
if (this.#submittedRunId == null) return void 0;
return runId === this.#submittedRunId ? void 0 : "other";
}
/**
* Merge a `values` payload into root values and root messages.
*
* @param raw - Raw `values` channel payload.
* @param checkpoint - Optional checkpoint envelope paired with the values event.
*/
/**
* Apply a submit input optimistically to the root projection before
* the server responds. Mints stable ids for id-less messages (so the
* server echo reconciles by id), appends them to the projection, and
* shallow-merges non-message input keys into `values`.
*
* Returns the dispatch payload (id-injected) for the coordinator to
* send, plus an {@link OptimisticHandle} for terminal reconciliation.
* Returns `undefined` when optimistic UI is disabled or there is
* nothing to echo, in which case the coordinator dispatches the raw
* input unchanged.
*
* @param input - Raw input passed to `submit()`.
*/
#beginOptimistic(input) {
if (this.#options.optimistic === false) return void 0;
if (input == null || typeof input !== "object" || Array.isArray(input)) return;
const prepared = require_optimistic_input.prepareOptimisticInput(input, this.#messagesKey, () => (0, _langchain_core_utils_uuid.v7)());
const extraKeys = Object.keys(prepared.extraValues);
if (prepared.echoedIds.length === 0 && extraKeys.length === 0) return;
const currentValues = this.rootStore.getSnapshot().values;
const restoreKeys = extraKeys.map((key) => ({
key,
hadKey: Object.prototype.hasOwnProperty.call(currentValues, key),
prevValue: currentValues[key]
}));
this.#sawValuesForRun = false;
this.#rootMessages.appendOptimistic(prepared.optimisticMessages, prepared.extraValues, { sync: true });
if (prepared.echoedIds.length > 0) this.#messageMetadata.markPending(prepared.echoedIds);
return {
dispatchInput: prepared.dispatchInput,
handle: {
echoedIds: prepared.echoedIds,
restoreKeys
}
};
}
/**
* Pick the `update` payload to dispatch on a resume (`respond` /
* `respondAll`).
*
* When the optimistic path ran ({@link #beginOptimistic} returned a handle),
* its `dispatchInput` already carries the minted message ids the server must
* echo back, so dispatch that — the echo reconciles the optimistic messages
* by id (no duplicate). Otherwise (optimistic UI disabled, or an `update`
* with no echoable messages — e.g. the tuple-entry form) fall back to
* serializing `BaseMessage` instances to dicts, exactly as before. Returns
* `undefined` when there is no `update`, so the server still sees a plain
* resume.
*/
#resolveDispatchUpdate(update, prepared) {
if (prepared != null) return prepared.dispatchInput;
if (update == null) return void 0;
return require_optimistic_input.serializeUpdateMessages(update, this.#messagesKey);
}
/**
* Reconcile optimistic state when a run terminates.
*
* - Messages: any echoed id still `"pending"` (never echoed by the
* server) is flipped to `"sent"` on success/interrupt, or
* `"failed"` on failure/abort. Ids the server already echoed were
* flipped to `"sent"` in {@link #applyValues} and are untouched.
* - Non-message keys: rolled back to their pre-submit values when no
* server `values` event landed during the run (otherwise the
* server snapshot already reconciled them). Skipped on abort,
* where a superseding run (or `stop()`) owns subsequent state.
*
* @param handle - Handle returned by {@link #beginOptimistic}.
* @param event - Terminal lifecycle event for the run.
*/
#settleOptimistic(handle, event) {
const failed = event === "failed" || event === "aborted";
this.#messageMetadata.resolvePending(handle.echoedIds, failed ? "failed" : "sent");
if (event !== "aborted" && !this.#sawValuesForRun) this.#rootMessages.restoreValueKeys(handle.restoreKeys);
}
#applyValues(raw, checkpoint) {
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return;
const state = raw;
this.#sawValuesForRun = true;
/**
* Surface parent_checkpoint per-message when the values event
* carries the lightweight checkpoint envelope (populated by
* `@langchain/langgraph-core`'s `_emitValuesWithCheckpointMeta` and
* forwarded through `convertToProtocolEvent`). Consumers surface
* this as `useMessageMetadata(stream, msg.id).parentCheckpointId`
* for fork / edit flows.
*/
const parentCheckpointId = checkpoint?.parent_id;
if (parentCheckpointId != null && Array.isArray(state[this.#messagesKey])) this.#messageMetadata.recordMessages(state[this.#messagesKey], { parentCheckpointId });
const maybeMessages = state[this.#messagesKey];
let nextValues;
let nextMessages;
if (Array.isArray(maybeMessages)) {
const coerced = require_message_coercion.ensureMessageInstances(maybeMessages);
nextValues = {
...state,
[this.#messagesKey]: coerced
};
nextMessages = coerced;
} else {
nextValues = state;
nextMessages = [];
}
this.#rootMessages.applyValues(nextValues, nextMessages, { step: checkpoint?.step });
if (nextMessages.length > 0) {
this.#messageMetadata.resolvePending(nextMessages.map((m) => m.id).filter((id) => typeof id === "string"), "sent");
this.rootStore.setState((s) => {
const toolCalls = require_tool_calls.reconcileToolCallsFromMessages(s.toolCalls, nextMessages);
if (toolCalls === s.toolCalls) return s;
return {
...s,
toolCalls
};
});
}
}
/**
* Mirror protocol interrupts (any namespace) into the root snapshot.
*
* This can be called from both the wildcard lifecycle/input watcher and the
* root content pump. Store-level dedup keeps the user-facing list stable.
*/
#recordInterrupt(event) {
if (event.method !== "input.requested") return;
const data = event.params.data;
const interruptId = data?.interrupt_id;
if (typeof interruptId !== "string") return;
const eventSeq = typeof event.seq === "number" ? event.seq : void 0;
if (this.#resolvedInterrupts.has(interruptId)) {
if (!(this.#hydratedActiveInterruptIds == null || typeof this.#interruptReplayThroughSeq === "number" && eventSeq != null && eventSeq > this.#interruptReplayThroughSeq)) return;
this.#resolvedInterrupts.delete(interruptId);
}
const isUnknownInterrupt = this.#hydratedActiveInterruptIds != null && !this.#hydratedActiveInterruptIds.has(interruptId);
if (isUnknownInterrupt && this.#interruptCommandPending) {
this.#pendingInterruptEvents.push(event);
return;
}
const barrier = this.#interruptReplayThroughSeq;
if (isUnknownInterrupt && barrier == null) {
this.#pendingHydrationInterruptEvents.push(event);
return;
}
if (isUnknownInterrupt && barrier != null && eventSeq != null && eventSeq <= barrier) return;
const namespace = Array.isArray(event.params.namespace) ? [...event.params.namespace] : [...ROOT_NAMESPACE];
const interrupt = require_interrupts.normalizeInterruptForClient({
id: interruptId,
value: data.payload,
namespace
});
this.rootStore.setState((s) => {
if (s.interrupts.some((entry) => entry.id === interruptId)) return s;
const interrupts = [...s.interrupts, interrupt];
return {
...s,
interrupts,
interrupt: interrupts[0]
};
});
}
/**
* Convert the hydrate-time allowlist into a replay barrier after a command
* is accepted. Built-in transports update `lastAppliedThroughSeq` from the
* command response before resolving `submitRun` / `respondInput`.
*/
#advanceInterruptReplayBarrier() {
if (this.#hydratedActiveInterruptIds == null) {
this.#discardPendingInterruptEvents();
return;
}
const throughSeq = this.#thread?.ordering?.lastAppliedThroughSeq;
if (typeof throughSeq === "number") this.#interruptReplayThroughSeq = throughSeq;
else {
this.#hydratedActiveInterruptIds = null;
this.#interruptReplayThroughSeq = void 0;
}
this.#interruptCommandPending = false;
const pendingEvents = this.#pendingInterruptEvents;
this.#pendingInterruptEvents = [];
for (const event of pendingEvents) this.#recordInterrupt(event);
}
#discardPendingInterruptEvents() {
this.#interruptCommandPending = false;
this.#pendingInterruptEvents = [];
}
/**
* Resolve the protocol namespace for a targeted resume.
*
* Prefer an explicit caller-supplied namespace, then the matching
* {@link ThreadStream.interrupts} entry, then the UI mirror on
* `rootStore.interrupts`, finally root (`[]`).
*/
#resolveNamespaceForInterruptId(interruptId, explicitNamespace) {
if (explicitNamespace != null) return [...explicitNamespace];
const fromThread = this.#thread?.interrupts.find((entry) => entry.interruptId === interruptId)?.namespace;
if (fromThread != null) return [...fromThread];
const fromRoot = this.rootStore.getSnapshot().interrupts.find((entry) => entry.id === interruptId)?.namespace;
if (fromRoot != null) return [...fromRoot];
return [...ROOT_NAMESPACE];
}
/**
* Mark an interrupt resolved for historical-replay filtering and
* mirror the removal into the root snapshot the framework hooks read.
*
* Tombstones are not permanent: a live `input.requested` after the
* resume barrier clears the id when the same interrupt is raised again.
*/
#markInterruptResolvedInRootStore(interruptId) {
this.#resolvedInterrupts.add(interruptId);
this.rootStore.setState((s) => {
const interrupts = s.interrupts.filter((entry) => entry.id !== interruptId);
if (interrupts.length === s.interrupts.length && s.interrupt?.id !== interruptId) return s;
return {
...s,
interrupts,
interrupt: interrupts[0]
};
});
}
/**
* Re-sync `rootStore.interrupts` from the server's authoritative
* `state.tasks[].interrupts` after a resumed run settles.
*
* Sequential `respond()` of parallel interrupts optimistically clears
* each targeted id client-side. The server checkpoint can continue to
* list the original interrupt batch after accepting a partial response,
* so locally-resolved ids remain filtered while untouched siblings are
* restored. This keeps `stream.interrupts` truthful without re-exposing
* an already-consumed interrupt.
*
* Uses the same transport-aware state fetch as {@link hydrate}, and
* drops the result if the thread swapped or a new submit/respond
* started while the fetch was in flight.
*/
async #reconcilePendingInterruptsFromServer() {
const threadId = this.#currentThreadId;
if (threadId == null || this.#disposed) return null;
const generationAtFetch = this.#submitGeneration;
try {
const state = await this.#fetchHydrationState();
if (this.#disposed || this.#currentThreadId !== threadId || this.#submitGeneration !== generationAtFetch) return null;
if (!Array.isArray(state?.tasks)) return null;
const { activeInterrupts, activeIds } = collectActiveInterruptsFromTasks(state.tasks);
const pendingInterrupts = activeInterrupts.filter((interrupt) => interrupt.id == null || !this.#resolvedInterrupts.has(interrupt.id));
this.rootStore.setState((s) => ({
...s,
interrupts: pendingInterrupts,
interrupt: pendingInterrupts[0]
}));
this.#hydratedActiveInterruptIds = activeIds;
return state;
} catch {
return null;
}
}
#settleParkedInterruptsOnTerminal(event) {
if (this.#pendingHydrationInterruptEvents.length === 0) return;
if (event.method !== "lifecycle") return;
if (!require_namespace.isRootNamespace(event.params.namespace)) return;
const status = event.params.data?.event;
if (status !== "interrupted" && status !== "completed" && status !== "failed") return;
this.#settleParkedInterrupts();
}
/**
* Decide parked interrupts with server state instead of a stream marker.
*
* After a passive rejoin the stream cannot tell a replayed, already
* resolved `input.requested` from one the current run just raised: the
* SSE replays the thread buffer on connect with no marker for where the
* replay ends. `state.tasks[].interrupts` is authoritative: parked ids it
* lists are live, the rest are history.
*/
async #settleParkedInterrupts() {
const makePromise = async () => {
try {
const threadId = this.#currentThreadId;
const generation = this.#submitGeneration;
const deadline = Date.now() + PARKED_INTERRUPT_SETTLE_MS;
for (;;) {
if (this.#disposed || this.#currentThreadId !== threadId || this.#submitGeneration !== generation || this.#pendingHydrationInterruptEvents.length === 0) return;
const parkedIds = new Set(this.#pendingHydrationInterruptEvents.map((event) => event.params.data.interrupt_id));
const state = await this.#reconcilePendingInterruptsFromServer();
if (state != null) {
if ([...this.#hydratedActiveInterruptIds ?? []].some((id) => parkedIds.has(id)) || !isThreadStateActive(state)) {
this.#pendingHydrationInterruptEvents = [];
return;
}
}
if (Date.now() >= deadline) return;
await new Promise((resolve) => {
setTimeout(resolve, PARKED_INTERRUPT_SETTLE_POLL_MS);
});
}
} finally {
this.#settleParkedInterruptsPromise = void 0;
}
};
this.#settleParkedInterruptsPromise ??= makePromise();
return this.#settleParkedInterruptsPromise;
}
/**
* Resolve on the next root-namespace terminal lifecycle event
* (`completed` / `failed` / `interrupted`) or on abort.
*
* Attaches to the controller's root event bus instead of opening
* a second server subscription. Callers should register the
* returned promise **before** dispatching the command that
* triggers the run (`thread.run.start` / `thread.input.respond`)
* — the root pump fans events out synchronously on arrival, so a
* late registration would miss the terminal for fast runs.
*
* @param signal - Abort signal for the local submit lifecycle.
*/
#awaitNextTerminal(signal) {
return this.#awaitRootTerminal(signal, { skipInterruptedUntilRunning: false });
}
/**
* Resolve on the resumed run's root terminal lifecycle.
*
* Unlike {@link #awaitNextTerminal}, ignores `interrupted` events until a
* root `running` lifecycle has been observed. Headless-tool flows can emit
* a stale `interrupted` for the run being resumed after `input.requested`
* but before `respondInput` calls `#prepareForNextRun`; accepting that
* terminal would unsubscribe the watcher before the resumed run's `failed`
* terminal arrives.
*
* On `interrupted` / `completed`, reconciles pending interrupts from
* server state so optimistic clears from {@link respond} cannot leave
* `stream.interrupts` empty while the checkpoint still has siblings.
*/
async #awaitResumedRunTerminal(signal) {
const terminal = await this.#awaitRootTerminal(signal, { skipInterruptedUntilRunning: true });
if (!signal.aborted && !this.#disposed && (terminal.event === "interrupted" || terminal.event === "completed")) await this.#reconcilePendingInterruptsFromServer();
return terminal;
}
#awaitRootTerminal(signal, options) {
return new Promise((resolve) => {
let settled = false;
let sawRunning = false;
const held = [];
const heldReleases = this.#heldLifecycleReleases;
function finish(result) {
if (settled) return;
settled = true;
heldReleases.delete(releaseHeld);
unsubscribeRoot?.();
unsubscribeThread?.();
unsubscribeError?.();
signal.removeEventListener("abort", finishAborted);
resolve(result);
}
const finishAborted = () => finish({ event: "aborted" });
const onEvent = (event) => {
if (settled) return;
if (event.method !== "lifecycle") return;
if (!require_namespace.isRootNamespace(event.params.namespace)) return;
const match = this.#submittedRunMatch(event);
if (match === "other") return;
if (match === "pending") {
held.push(event);
return;
}
const lifecycle = event.params.data;
if (lifecycle?.event === "running") {
sawRunning = true;
return;
}
if (lifecycle?.event === "completed") setTimeout(() => finish({ event: "completed" }), 0);
else if (lifecycle?.event === "failed") setTimeout(() => finish({
event: "failed",
error: lifecycle.error
}), 0);
else if (lifecycle?.event === "interrupted") {
if (options.skipInterruptedUntilRunning && !sawRunning) return;
setTimeout(() => finish({ event: "interrupted" }), 0);
}
};
const releaseHeld = () => {
for (const event of held.splice(0)) onEvent(event);
};
heldReleases.add(releaseHeld);
const unsubscribeRoot = this.#rootBus.subscribe(onEvent);
const unsubscribeThread = this.#thread?.onEvent(onEvent);
const unsubscribeError = this.#thread?.onError((error) => finish({
event: "failed",
error: error.message
}));
if (signal.aborted) finishAborted();
else signal.addEventListener("abort", finishAborted, { once: true });
});
}
/**
* Resolve which protocol interrupt a resume command should target.
* Headless-tool resumes are keyed by tool-call id; without matching
* on that id, parallel tool handlers would respond to the wrong
* interrupt (always the newest).
*/
#resolveInterruptForResume(resume) {
const thread = this.#thread;
if (thread == null) return null;
return require_headless_tools.resolveInterruptTargetForHeadlessResume(resume, thread.interrupts, this.#resolvedInterrupts);
}
/**
* Notify listeners that the underlying thread stream changed.
*/
#notifyThreadListeners() {
for (const listener of this.#threadListeners) listener(this.#thread);
}
};
/**
* True when a subagent still sits on its default `tools:<toolCallId>`
* namespace — i.e. no execution namespace has been observed (via SSE
* replay) or resolved (via history) yet. Used to gate lazy namespace
* resolution so already-promoted subagents aren't re-fetched.
*/
function namespaceIsDefaultOnly(entry) {
if (entry == null) return false;
return entry.namespace.length === 1 && entry.namespace[0] === `tools:${entry.id}`;
}
function defaultSubagentToolCallId(namespace) {
if (namespace.length !== 1) return void 0;
const segment = namespace[0];
if (!segment.startsWith("tools:")) return void 0;
const id = segment.slice(6);
return id.length > 0 ? id : void 0;
}
/**
* Extract and coerce the configured messages key from a values object.
*
* @param values - State values object to read from.
* @param messagesKey - Key that contains the message array.
*/
function extractAndCoerceMessages(values, messagesKey) {
const raw = values[messagesKey];
if (!Array.isArray(raw)) return [];
return require_message_coercion.ensureMessageInstances(raw);
}
function extractAndCoerceMessagesWithFallback(values, messagesKey) {
let raw = values[messagesKey];
if (!Array.isArray(raw) && messagesKey !== "messages") raw = values[require_constants.DEFAULT_MESSAGES_KEY];
if (!Array.isArray(raw)) return null;
return require_message_coercion.ensureMessageInstances(raw);
}
/**
* Collect pending interrupts from a `threads.getState()` `tasks` array.
*
* Shared by {@link StreamController.hydrate} and the post-resume
* reconcile path so both stay aligned on namespace derivation and
* id dedupe.
*/
function collectActiveInterruptsFromTasks(tasks) {
const activeInterrupts = [];
const activeIds = /* @__PURE__ */ new Set();
for (const task of tasks) {
if (!Array.isArray(task?.interrupts)) continue;
const typedTask = task;
const checkpointNs = typedTask.checkpoint?.checkpoint_ns;
const namespace = typeof checkpointNs === "string" && checkpointNs.length > 0 ? checkpointNs.split("|").filter((segment) => segment.length > 0) : [...ROOT_NAMESPACE];
for (const interrupt of typedTask.interrupts) {
const typed = interrupt;
const id = typed?.id;
if (typeof id !== "string" || activeIds.has(id)) continue;
activeIds.add(id);
activeInterrupts.push(require_interrupts.normalizeInterruptForClient({
id,
value: typed?.value,
namespace: Array.isArray(typed?.namespace) ? [...typed.namespace] : namespace
}));
}
}
return {
activeInterrupts,
activeIds
};
}
/**
* Run id from a `synth:<run_id>:...` event id, the form the agent server
* uses for events it synthesizes such as root `lifecycle`. Events carry no
* run id field.
*/
function synthesizedEventRunId(eventId) {
if (eventId == null || !eventId.startsWith("synth:")) return void 0;
return eventId.split(":")[1] || void 0;
}
//#endregion
exports.ROOT_PUMP_CHANNELS = ROOT_PUMP_CHANNELS;
exports.StreamController = StreamController;
//# sourceMappingURL=controller.cjs.map