/** * `Connection` — owns the WebSocket, multiplexes inbound envelopes onto * subscriptions, encodes outbound envelopes. * * One `Connection` per `QodoClient.connect()` call. `tasks.start` / * `tasks.continue` register a `TaskSubscription`; `client.receive()` registers * a raw subscription that taps every inbound envelope. The connection routes * inbound envelopes to subscriptions whose `parent_message_id` chain matches. * * **Reconnect + replay.** The Connection survives transport drops: * subscriptions stay alive while the underlying `WSTransport` is replaced via * the same factory. On a fresh transport, every active task subscription's * `task_id` + `lastSeenMessageId` is sent back to the server as a * `task.resubscribe` envelope; the server replays anything after that anchor * from its per-session ring buffer (cap 1000 envelopes per session). No * client-side outbox — durability is server-side. The 1.x `OutboxTurn` / * `Resume` / `ResumeAck` machinery does not exist here. */ import type { Envelope } from '../qar/envelopes.js'; import type { MessageId, SessionId, TaskId, ToolCallId } from '../qar/ids.js'; import type { TaskEvent } from '../qar/taskEvents.js'; import type { ClientEvent } from '../qar/clientEvents.js'; import type { SessionState, TaskCancelPayload, TaskContinuePayload, TaskForceResumePayload, TaskResubscribePayload, TaskStartPayload, ToolResponsePayload } from '../qar/payloads.js'; import type { ClientKeepaliveOptions, ClientReconnectOptions } from './options.js'; import { TraceContextProvider } from '../observability/traceContext.js'; import type { SpanLifecycle } from '../observability/spans.js'; import { TransportMetrics } from '../observability/transportMetrics.js'; import type { WSTransportFactory, WSTransportFactoryArgs } from './transport.js'; /** * Outbound envelopes the SDK constructs. */ type OutboundPayload = { readonly kind: 'task.start'; readonly payload: TaskStartPayload; } | { readonly kind: 'task.continue'; readonly payload: TaskContinuePayload; } | { readonly kind: 'task.cancel'; readonly payload: TaskCancelPayload; } | { readonly kind: 'task.resubscribe'; readonly payload: TaskResubscribePayload; } | { readonly kind: 'task.forceResume'; readonly payload: TaskForceResumePayload; } | { readonly kind: 'tool.response'; readonly payload: ToolResponsePayload; }; /** * A subscription on the inbound stream. The connection calls `consider` for * every inbound envelope; the subscription returns `true` if it absorbed the * envelope (so terminal envelopes don't double-fire elsewhere) and pushes the * envelope to its consumer. * * `task.resubscribe` and tool tunneling plug in via this shape too, so * the routing fabric is shared. */ export interface InboundSubscription { /** * Called for every inbound envelope. Returns `true` if this subscription * consumed the envelope (used so the connection knows it was routed to at * least one task subscriber). Subscriptions are independent — a `tool.request` * may belong to a task subscription AND surface on the raw `receive()` tap. */ consider(env: Envelope): boolean; /** * Push a synthetic SDK-internal `qar.client.*` event onto the consumer's * iterator. Distinct from `consider` because client events are not * envelopes — they have no `message_id` / `session_id` / `payload`. */ considerClient(ev: ClientEvent): void; /** Called when the underlying transport fails. Subscription should fail its consumer. */ fail(err: Error): void; /** Called when the connection closes cleanly. Subscription should close its consumer. */ close(): void; } /** * Argument shape for the `task.started` resolver. Includes the * idempotent-admission fields when the ack reports an existing session * (`isNew === false`) or a terminal re-dispatch (`previousTaskId` + * `previousState` populated). Old SDK call sites that ignore the new * fields keep working — only callers exposing the full * `TaskAdmissionResult` Promise on `TaskStartIterable` need them. */ export interface TaskStartedResolverArgs { readonly sessionId: SessionId; readonly taskId: TaskId; /** `true` for fresh admission; `false` for idempotent existing-session return. */ readonly isNew: boolean; /** Session state at ack time (present when `isNew === false`). */ readonly state?: SessionState; /** Prior task id on terminal re-dispatch (the current `taskId` is the NEW task). */ readonly previousTaskId?: TaskId; /** Prior task's terminal state on terminal re-dispatch. */ readonly previousState?: SessionState; } /** * Argument shape for the `task.force_resumed` resolver — the ack envelope * returned by QAR in response to an outbound `task.forceResume`. Carries * the recovered task's id, the post-recovery state (typically * `'auto-paused'` after in-flight tool/HITL cancellation; reports the * actual state on passthrough), and the inherited derived `session_id`. */ export interface TaskForceResumedResolverArgs { readonly sessionId: SessionId; readonly taskId: TaskId; readonly state: SessionState; } /** * A single task's inbound stream. Routes by parent-message chain: every * envelope whose `parent_message_id` is in the chain becomes part of the chain * (its `message_id` joins). Terminal envelopes (`task.done`, `error`) close the * iterator. * * On `Connection`-level reconnect, the matching subscription stays alive and * the `Connection` stitches the resubscribe envelope's id into `chain` so the * server's replayed envelopes route back here transparently. */ export declare class TaskSubscription implements InboundSubscription, AsyncIterable, AsyncIterator { private readonly rootMessageId; private readonly onEarlyReturn; private readonly onClose; /** * Optional transport-counter store. When provided, `consider` increments * `replay_envelopes_received_total` for every absorbed envelope that * descends from a replay anchor, and `replay_anchor_missing_total` for * every `error { code: 'replay_anchor_missing' }` envelope that routes * here — defensive only, since no released QAR emits that code. */ private readonly metrics?; /** * `true` when an admission-retry driver (`wrapWithAdmissionRetry`) owns * this `task.start` subscription. The driver re-dispatches on retryable * admission-conflict codes and settles the shared admission deferreds * itself, so on `session_already_running` the connection must NOT settle * the `task.started` resolver here (that would pre-empt the retry). When * `false` — the preflight/skills/MCP eager branch (no retry driver), * `tasks.forceResume`, plain non-keyed start — the connection settles the * resolver typed so a caller awaiting `stream.admissionResult` never * hangs. Default `false` so only the explicitly retry-managed start paths * opt in. */ private readonly admissionRetryManaged; private readonly queue; private readonly chain; /** * Lineage-ownership set — message_ids this subscription OWNS, used to * attribute an inbound `tool.request` to the turn that produced it (so the * SDK's `qar.client.tool.handler` span runs in the right task context). * * Distinct from {@link chain}, which is the broader ROUTING set: `chain` * also accepts envelopes via the `task_id` fallback (a passive same-task * subscription — e.g. an auto-paused prior `tasks.start` sub — absorbs * another turn's stream that way for observation + replay continuity). * `ownedMessages` grows ONLY through real `parent_message_id` lineage: this * subscription's own outbound root(s), plus every inbound envelope whose * parent it ALREADY owns. It never grows via the `task_id` fallback. * * Invariant (by construction): every message is owned by EXACTLY ONE * subscription. Roots are unique per outbound request; a child is owned by * its parent's unique owner. So a `tool.request` — which carries no * `task_id` and links only by `parent_message_id` — has a unique owner: the * subscription whose `ownedMessages` contains its parent. This is why a * passive same-task absorber, which holds the other turn's deltas in `chain` * but never in `ownedMessages` (their parent is a root it doesn't own), * never wins ownership of that turn's tool calls — at any depth. */ private readonly ownedMessages; private taskId; /** * `message_id` of the most recent inbound envelope that matched this * subscription's chain or `task_id`. Anchor for `task.resubscribe` on * reconnect — the server replays everything after this point. Stays * `undefined` until the first inbound envelope arrives. Connection-scoped * envelopes (`flow.pause` / `flow.resume`) don't update it: they're * session-scoped, not task-scoped, so they can't anchor a per-task replay * window. */ private lastSeen; /** * Outbound `task.resubscribe` message ids whose direct descendant marks * the start of replay accounting for this subscription. Seeded by * {@link attachOutboundMessageId} (auto-reconnect) and the constructor's * `rootIsReplayAnchor` flag (manual `tasks.resubscribe`). Stays small — * one entry per resubscribe envelope this subscription owns, not one * entry per replay envelope received. */ private readonly replayAnchors; /** * Once an envelope absorbed by this subscription has been identified as * a replay descendant (its `parent_message_id` was in `replayAnchors`), * every subsequently-absorbed envelope is also counted as a replay * envelope: QAR's per-session ring buffer replays a contiguous chain * after the anchor, so the post-anchor continuation is logically the * same wire window as the replay itself. * * Sticky boolean rather than a growing chain Set — cheaper memory * profile for long-running subscriptions that survive many reconnects. */ private replayCountingActive; /** * @param rootMessageId message_id of the outbound envelope that started the * task (the `task.start` or `task.continue`). * @param knownTaskId task_id known up front (only for `tasks.continue` / * `tasks.cancel` / `tasks.resubscribe` — `tasks.start` * doesn't know the id until the first inbound envelope * reveals it). Pre-seeding the matcher closes a * routing gap where envelopes that carry * `payload.task_id` but no chain link to our outbound * message would otherwise be missed. * @param onEarlyReturn called once if the consumer breaks the iterator * before terminal — best-effort `task.cancel`. Pass a * no-op for resubscribe-style subscriptions where * breaking the loop must NOT cancel the underlying * task (the consumer is observing, not driving). * @param onClose called once when the subscription is no longer * routable (terminal received OR consumer broke). */ /** Span lifecycle for the task this subscription represents. Closed on terminal/fail/early-return. */ private readonly span; /** * Resolver for the `task.started` admission ack. Receives both the * inherited `session_id` (server-derived UUID) AND the canonical * `task_id` from the envelope's payload — the two ids are distinct on * the admission-retry path, where a retried `task.start` carries a * fresh `message_id` while the server's canonical task_id stays bound * to the FIRST winning attempt. * * Provided by `TaskClient.subscribeAndSend` for `task.start` * subscriptions only — `task.continue` / `task.cancel` / * `task.resubscribe` already know the session_id + task_id from a * prior admission and don't wire a resolver. `null` once the resolver * has fired (resolved OR rejected) — prevents a second resolution * from a spec-violating duplicate ack. */ private taskStartedResolver; /** * Resolver for the `task.force_resumed` ack — fires when the SDK's * outbound `task.forceResume` envelope's response arrives. Wired by * `TaskClient.forceResume`. Mirrors {@link taskStartedResolver} in * shape and lifecycle (null after fire; rejected if the subscription * closes before the ack lands). */ private taskForceResumedResolver; constructor(rootMessageId: MessageId, knownTaskId: TaskId | undefined, onEarlyReturn: (taskId: TaskId | undefined) => void, onClose: (sub: TaskSubscription) => void, span?: SpanLifecycle, /** * Optional transport-counter store. When provided, `consider` increments * `replay_envelopes_received_total` for every absorbed envelope that * descends from a replay anchor, and `replay_anchor_missing_total` for * every `error { code: 'replay_anchor_missing' }` envelope that routes * here — defensive only, since no released QAR emits that code. */ metrics?: TransportMetrics | undefined, /** * `true` when `rootMessageId` is itself a `task.resubscribe` envelope — * its descendants count as replay envelopes for * `replay_envelopes_received_total`. Set by the manual * `client.tasks.resubscribe(...)` seam; the `tasks.start` / * `tasks.continue` paths leave it `false` so live envelopes don't * masquerade as replay until an auto-replay anchor is stitched in via * {@link attachOutboundMessageId}. */ rootIsReplayAnchor?: boolean, /** * Optional resolver fired with the server-derived `session_id` when * the `task.started` admission ack arrives on this subscription's * chain. Wired by `task.start` paths so the caller's `TaskStartIterable` * can expose the derived id via its `sessionId` Promise. * * Resolver lifecycle: * - Resolved on inbound `task.started` whose `parent_message_id` * matches the outbound `task.start.message_id`. * - Rejected if the subscription terminates (clean close, transport * fail, terminal `task.done` / `error`) before any `task.started` * arrives — keeps the consumer's `await stream.sessionId` from * hanging forever. */ taskStartedResolver?: { readonly resolve: (args: TaskStartedResolverArgs) => void; readonly reject: (err: Error) => void; }, /** * Optional resolver fired with the recovered task's id + * post-recovery state when an inbound `task.force_resumed` ack * arrives on this subscription's chain. Wired by * `TaskClient.forceResume`. Same lifecycle as * {@link taskStartedResolver}: rejected on close/error before * the ack lands. */ taskForceResumedResolver?: { readonly resolve: (args: TaskForceResumedResolverArgs) => void; readonly reject: (err: Error) => void; }, /** * `true` when an admission-retry driver (`wrapWithAdmissionRetry`) owns * this `task.start` subscription. The driver re-dispatches on retryable * admission-conflict codes and settles the shared admission deferreds * itself, so on `session_already_running` the connection must NOT settle * the `task.started` resolver here (that would pre-empt the retry). When * `false` — the preflight/skills/MCP eager branch (no retry driver), * `tasks.forceResume`, plain non-keyed start — the connection settles the * resolver typed so a caller awaiting `stream.admissionResult` never * hangs. Default `false` so only the explicitly retry-managed start paths * opt in. */ admissionRetryManaged?: boolean); /** The `task_id` once we've seen the first envelope that carries it. */ get currentTaskId(): TaskId | undefined; /** * Most-recent inbound envelope's `message_id`, or `undefined` if no inbound * matched yet. Public so `Connection.replayActiveTasks` can read it without * pinning to a specific subscription class. */ get lastSeenMessageId(): MessageId | undefined; /** Whether this subscription has terminated and stopped accepting envelopes. */ get isTerminated(): boolean; /** * Stitch a new outbound `message_id` (typically a `task.resubscribe`) into * this subscription's chain so the server's replies routed via * `parent_message_id` link back here. Idempotent — Set.add no-ops on dups. * * Called by `Connection` after sending a resubscribe on behalf of an * already-live subscription (the auto-replay path on reconnect). */ attachOutboundMessageId(messageId: MessageId): void; consider(env: Envelope): boolean; /** * Reject the pending `task.started` resolver, if any. No-op when the * resolver has already fired (resolved earlier on the ack, or rejected * by a prior terminal). Idempotent — every terminal path can call * this without guarding. */ private rejectTaskStartedIfPending; /** * Reject the pending `task.force_resumed` resolver, if any. Mirrors * {@link rejectTaskStartedIfPending}: idempotent; called from every * terminal path so `tasks.forceResume()` Promises don't hang on * close / transport failure. */ private rejectTaskForceResumedIfPending; /** * End the task span for a `task.done` envelope. `completed` and `canceled` * are both clean terminal states (the consumer asked for the cancel, or the * task ran to completion); `failed` records an error. */ private endSpanForDone; considerClient(ev: ClientEvent): void; fail(err: Error): void; close(): void; next(): Promise>; return(): Promise>; throw(err: unknown): Promise>; [Symbol.asyncIterator](): AsyncIterator; private matches; /** * Whether `env`'s `parent_message_id` links into this subscription's chain * — i.e. the envelope is a direct reply to an outbound message this * subscription owns (`task.start` / `task.continue` / `task.resubscribe`, * or a prior inbound that already joined the chain). * * Distinct from the broader {@link matches}, which also accepts an envelope * solely because its payload `task_id` equals ours. The admission / * recovery acks (`task.started`, `task.force_resumed`) use THIS predicate — * not the `task_id` fallback — to decide whether to resolve admission or * self-close: those acks carry the canonical `task_id`, which on an * idempotent re-admission (a second `task.start` on an already-dispatched * session) or a buffer replay equals the id of a DIFFERENT subscription * whose task is still streaming. A `task_id`-only match must still absorb * the ack (so a cold resubscribe's replayed `task.started` seeds the chain * for the chain-only events that follow), but must NOT drive that other * subscription's admission/close — see {@link consider}. */ private matchesByChain; /** * Whether this subscription OWNS `env`'s turn — i.e. its `parent_message_id` * is in {@link ownedMessages} (a real lineage link back to our outbound * root, never a `task_id`-fallback absorb). Used by * {@link Connection.runInOwningTaskContext} to attribute an inbound * `tool.request` to the turn that produced it: the owner is unique (each * message is owned by exactly one subscription), so this is unambiguous even * when multiple same-task subscriptions overlap (e.g. a live auto-paused * prior `tasks.start` sub alongside an active `tasks.continue`). A passive * absorber holds the other turn's stream in {@link chain} but never in * {@link ownedMessages}, so it never wins ownership — at any depth. */ ownsLineage(env: Envelope): boolean; /** * Public alias for `matches` — exposed so `Connection.dispatchCancelAcked` * can route a `task.canceling` envelope to the correct subscription * before broadcasting the synthetic `qar.client.cancel_acked` client * event. Same chain + `task_id` matcher used for normal envelope * routing. */ matchesEnvelope(env: Envelope): boolean; /** * Run `fn` inside this subscription's task span context. When the * subscription has no span (no OTel provider configured, or a call site * that didn't supply a span builder), runs `fn` directly — a strict * pass-through. * * Used by {@link Connection.runInOwningTaskContext} to re-attach the * originating task's context around an inbound `tool.request` handler * dispatch, so the handler's `qar.client.tool.handler` span (and any * outbound work it triggers) parents under the task span rather than the * bare WS-receive context. */ runInTaskContext(fn: () => T): T; } /** * Raw subscription from `client.receive()` — taps every inbound envelope plus * the synthetic `qar.client.*` lifecycle events. The yielded type is * `Envelope | ClientEvent`; consumers narrow on `kind` (the QAR `kind` * discriminator stays disjoint from `qar.client.*`). */ export declare class RawSubscription implements InboundSubscription, AsyncIterable, AsyncIterator { private readonly onClose; private readonly queue; constructor(onClose: (sub: RawSubscription) => void); consider(env: Envelope): boolean; considerClient(ev: ClientEvent): void; fail(err: Error): void; close(): void; next(): Promise>; return(): Promise>; throw(err: unknown): Promise>; [Symbol.asyncIterator](): AsyncIterator; } /** * Live connection state. Owns the (replaceable) transport and the set of * subscriptions; routes inbound envelopes to subscriptions via `consider`. * * **No connection-level `sessionId`.** Every `task.start` creates a NEW * server-derived session, so an envelope's `session_id` is the TASK's * session — not a property of the WebSocket. The SDK tracks * `task_id → session_id` and `tool_call_id → session_id` here so that * outbound ongoing envelopes can be stamped with the right session at * encode time without coupling consumer code to per-task session * bookkeeping. */ export declare class Connection { private readonly factory; private readonly url; private readonly headers; private readonly traceContext; readonly metrics: TransportMetrics; private readonly subscriptions; /** * Map of `task_id → session_id` populated from inbound `task.started` * envelopes. Read by {@link sendEnvelope} when encoding `task.continue` * / `task.cancel` / `task.resubscribe` so each ongoing envelope * carries the same server-derived `session_id` the server bound during * admission — preventing multi-task `session_mismatch` without * exposing per-task session bookkeeping on the public API. * * Lifetime: entries are added when `task.started` lands and PRUNED on * `task.done` (the wire's terminal ack — task is fully finished). Any * later `task.resubscribe` against the same `task_id` would be a * cross-process recovery path that has no in-memory session anyway, so * pruning doesn't regress that scenario. Cleared on transport close. * The auto-reconnect/replay path uses LIVE (non-terminated) * subscriptions and adds the session back via the server's replayed * `task.started`, so it's unaffected. * * **Pre-admission invariant.** A live `TaskSubscription` MAY lack an * entry in this map: between `tasks.start` writing its outbound * `task.start` and the inbound `task.started` ack landing, the * subscription is registered in {@link subscriptions} but the * `session_id` is not yet pinned here. A WS drop during this window * leaves the subscription in an unrecoverable state per the * session-identity contract. {@link replayActiveTasks} pre-checks this * map before attempting `task.resubscribe` and short-circuits to * `sub.fail(QodoColdAddressError)` for un-pinned subs — see that * method's JSDoc for the state-machine narrative. */ private readonly taskSessions; /** * Map of `tool_call_id → session_id` populated from inbound `tool.request` * envelopes. Read by {@link sendEnvelope} when encoding `tool.response`, * which carries `responses: ToolResponseItem[]` and inherits the parent * `tool.request`'s session via the first response item's `tool_call_id`. * * Lifetime: entries are added when `tool.request` lands and PRUNED on * EITHER (a) the outbound `tool.response` hits the wire (single-use * — one request, one response), OR (b) the owning task terminates * via `task.done` without the consumer ever responding (manual handler * returned `undefined` to indicate async-respond-later, then the task * timed out / canceled / completed without the response). Without (b) * the map would accumulate orphan `tool_call_id` entries on long-lived * connections. Cleared on transport close. */ private readonly toolCallSessions; /** * Reverse index of `session_id → Set`. Populated on * inbound `tool.request` * (one entry per call). Used to sweep {@link toolCallSessions} when * the owning task terminates without a `tool.response` ever being * sent for some of its outstanding calls — the manual-handler * respond-later path explicitly allows this, and without the sweep * the per-call map entries would survive forever on long-lived * connections. * * Indexed by `session_id` (not `task_id`) because that's the * dimension `tool.request` envelopes carry on the wire — the SDK * derives the owning session via the envelope's `session_id` field, * not via any task_id field on the payload. */ private readonly outstandingToolCallsBySession; /** * Reverse index of outbound `task.continue` message_id → * `{ taskId, sessionId }` for envelopes * that carried a caller-supplied cold-address `sessionId` override. * Populated AFTER the wire write (or queued-drain) succeeds; used to * identify which `taskSessions` entry to prune when the server rejects * the override with `error { code: 'session_mismatch' }`. * * Without this index, a wrong cold-address override would pollute * {@link taskSessions} (because `sendEnvelope`'s post-send write * persists the consumer-supplied value as authoritative) and a * subsequent no-override call for the same task would silently reuse * the already-rejected session instead of raising * {@link QodoColdAddressError}. * * **Generation safety:** the entry stores the sessionId of the * override the offending message carried. Pruning is * conditional: we delete `taskSessions[taskId]` ONLY when the * authoritative entry still matches the rejected sessionId. If a * later send for the same task superseded the override with a fresh * (valid) sessionId, the rejection for the stale message must NOT * blow away the newer authoritative entry. The reverse-map entry for * the offending message_id is deleted unconditionally (it's a per- * message tombstone; the message is finished one way or the other). * * Entries are pruned (a) on inbound `error { code: 'session_mismatch' }` * for a known offending_message_id (with generation check), and (b) * on inbound `task.done` for the matching task_id (terminal sweep). * Cleared on transport-terminal cleanup. */ private readonly outboundOverrideRefs; private state; /** App-level backpressure flag. Flipped by inbound `flow.pause` / `flow.resume`. */ private paused; /** * FIFO of pre-encoded JSON frames that arrived while paused. Drained in * insertion order on `flow.resume`. Pre-encoding at queue-time keeps the * drain path a thin pass-through to the transport. * * Each entry is tagged with the envelope's `messageId` so the owning * `TaskSubscription` can retract its frame on early termination (abort or * iterator break) — without that link, a queued `task.continue` would * still go to the wire on `flow.resume` after the consumer has already * canceled, producing `cancel → continue` reordering on the server. */ private readonly pausedQueue; private readonly pausedQueueMax; /** Reconnect policy. */ private readonly reconnectMaxAttempts; private readonly reconnectInitialBackoffMs; private readonly reconnectBackoffMultiplier; private readonly reconnectMaxBackoffMs; /** * Resolved keepalive config threaded to the transport factory on every * (re)connect, or `undefined` when keepalive is disabled. The default * `ws` transport honors it; a BYO transport may ignore it. */ private readonly keepalive; /** * Pending backoff sleeps. Each entry pairs the timer handle with the * Promise's resolve callback so `disconnect()` mid-reconnect can both * cancel the timer AND wake the loop up so it observes the new state on * the next iteration — without the resolver, clearing the timer would * leave the await pending forever. */ private readonly pendingSleeps; /** The active transport. Mutable — replaced on every successful reconnect. */ private transport; /** * Monotonic id of the transport that currently owns {@link transport}. The * initial transport is epoch 1; each successful reconnect installs a fresh * transport and bumps this. Every transport's event handlers capture their * own epoch and no-op once it no longer matches — so a delayed `close` / * `error` / `message` from a SUPERSEDED socket can't be mistaken for the * live one. Without this guard, a late event from the old socket (more * likely now that the first reconnect attempt is immediate) would re-enter * `transportClosed` while `state === 'connected'` and tear down the new, * healthy connection. The `state !== 'connected'` guard in * `transportFailed`/`transportClosed` only catches SYNCHRONOUS re-entry * during a single dispose; it cannot tell apart "the live socket closed" * from "a dead predecessor closed late", which is exactly what the epoch * disambiguates. */ private transportEpoch; private constructor(); /** * Construct + open a fresh connection through the factory. The connection * captures the factory + url + headers so it can re-open on transport drops * without the caller threading them through again. */ static open(args: { factory: WSTransportFactory; url: string; headers: Readonly>; maxPausedQueueSize?: number; reconnect?: ClientReconnectOptions; keepalive?: ClientKeepaliveOptions; traceContext: TraceContextProvider; metrics: TransportMetrics; }): Promise; /** * Encode and send an outbound envelope. Returns the assigned `message_id` so * the caller can track the parent_message_id chain. * * Optionally accepts a pre-allocated `messageId` so subscriptions can be * registered against a known root before the envelope hits the wire — avoids * a race where an inbound reply could be dispatched before the subscription * is in place. * * If the connection is in `flow.pause` and `out.kind` is throttled * (`task.start` / `task.continue`), the encoded frame is enqueued and drained * once `flow.resume` arrives. The returned `messageId` is still allocated * synchronously so callers can register subscriptions against it before the * envelope hits the wire — same invariant as the unpaused path. * * Throws `QodoBackpressureError` when the queue is at its cap. */ sendEnvelope(out: OutboundPayload, opts?: { readonly messageId?: MessageId; readonly parentMessageId?: MessageId; /** * Explicit per-Task `session_id` override. Used by the * `ToolRequestDispatcher` to stamp the auto-emitted `tool.response` * with the session it captured from the inbound `tool.request` envelope * (the source of truth for that response's session). When omitted, * {@link sendEnvelope} resolves the `session_id` from * {@link taskSessions} or {@link toolCallSessions} based on the * envelope's `kind`. */ readonly sessionId?: SessionId; }): MessageId; /** * Resolve the per-Task `session_id` for an outbound ongoing envelope. * Caller-supplied `override` wins (the dispatcher path passes the * captured inbound `session_id` directly, and the cold-address * public-API path passes the consumer-supplied option); otherwise look * up by the payload's task_id (`task.continue` / `task.cancel` / * `task.resubscribe`) or by the first response item's tool_call_id * (`tool.response`). * * When neither an override NOR an in-memory entry is available, throw * {@link QodoColdAddressError} synchronously. Throwing locally: * * - puts the cold-address remediation right in the error message * (consumer should pass `sessionId` from durable storage), * - saves a wire round-trip + the misleading `session_mismatch` * error message ("envelope session_id differs from connection * session" — true but unactionable), * - matches the doctrinal correct shape (make it right, not make * it work). * * The primary cross-task session-leak guard is the lookup itself when * admission HAS completed. */ private resolveOutboundSessionId; /** * Retract a queued throttled envelope by its `message_id`. Called by * `TaskSubscription` when it terminates early (abort signal, iterator * break, transport failure) so the consumer's cancel intent doesn't race * with their own queued frame on the next `flow.resume`. * * No-op if the messageId isn't queued (already drained, never queued, or * a different envelope kind that doesn't enter the throttle path). * Returns whether an entry was removed — informational only. */ dropQueued(messageId: MessageId): boolean; /** Send a raw envelope verbatim — escape hatch. */ sendRawEnvelope(env: Envelope): void; /** * Look up the server-derived `session_id` for a known `task_id`. * Used by span recorders that want to stamp `qar.session_id` * on per-Task spans without coupling caller code to the per-Task lookup * map. Returns `undefined` when no `task.started` ack has arrived for * this `task_id` yet (the start path's span is opened pre-admission and * must tolerate this). */ getSessionForTask(taskId: TaskId): SessionId | undefined; /** * Look up the `session_id` of the inbound `tool.request` a given * `tool_call_id` came from. Used by `ToolClient.respond` to stamp * its outbound `tool.response` span with the right session without * threading the value through every call site. Returns `undefined` when * no matching `tool.request` arrived (test-only path, or a malformed * consumer call). */ getSessionForToolCall(toolCallId: ToolCallId): SessionId | undefined; /** * Run `fn` inside the span context of the live `TaskSubscription` that * OWNS `env` by lineage, so an inbound `tool.request` dispatched to the * consumer's handler inherits the originating turn's trace context (its * `qar.client.task.*` span). Without this, the fanout invokes the handler * under the bare WS-receive context and the SDK's `qar.client.tool.handler` * span — plus the consumer's outbound MCP/fetch traceparent — are orphaned * from the task trace. * * Ownership is the unique subscription whose {@link TaskSubscription.ownsLineage} * holds for `env` — its `parent_message_id` traces (through real * `parent_message_id` links) back to that subscription's own outbound root. * This is unambiguous even when same-task subscriptions overlap (a live * auto-paused `tasks.start` sub alongside an active `tasks.continue`): the * passive sub absorbs the active turn's stream into its routing chain via * the `task_id` fallback, but never into its ownership lineage, so it never * wins — at any tool-call depth (multi-round T1→D2→T2). * * Terminated subscriptions are skipped (a live task owns an in-flight tool * call). Falls through to `fn()` unchanged when no owner is found — a strict * no-op for non-OTel consumers, for a `tool.request` whose lineage root was * never sent by this process (cold `task.resubscribe` replay), and for an * owner with no span. */ runInOwningTaskContext(env: Envelope, fn: () => T): T; /** Register a subscription. Caller is responsible for unregistering on close. */ subscribe(sub: InboundSubscription): void; unsubscribe(sub: InboundSubscription): void; /** * Tear down: close transport, fail all subscriptions cleanly. Idempotent. * Called by `QodoClient.disconnect()` and by the `onClose` transport handler * for clean closes. Mid-reconnect calls switch state to `disconnecting` so * the in-flight loop bails on the next tick. */ close(): void; /** * Drop any queued envelopes and clear the paused flag. Called on every * tear-down path (clean close, transport error, transport close) — leaving * frames in the queue past disconnect would either leak memory or surface * stale outbound on a future connection that reused this object. * * Does NOT clear the per-Task / per-Tool-call session-id lookup maps — * those must survive the reconnect window so `replayActiveTasks` can * stamp the right `session_id` on each `task.resubscribe` envelope it * emits. Final cleanup of the session maps lives in * {@link clearTerminalState}, invoked from the actual tear-down paths. */ private clearBackpressureState; /** * Drop the per-Task and per-Tool-call session-id lookup maps. Called * from terminal cleanup paths (`close`, * `cleanCloseAndCleanup`, `failHardAndCleanup`, `finalizeDisconnect`) so * a future `connect()` reusing the same `Connection` object starts with * an empty session-id table. Distinct from {@link clearBackpressureState} * because the reconnect path MUST keep these populated for replay. */ private clearTerminalState; /** Whether the connection accepts outbound sends right now. */ get isOpen(): boolean; /** * Whether the connection is mid-reconnect. `isOpen` is false during this * window; outbound sends throw. Tests + observability surfaces read this * to render a "reconnecting" indicator. */ get isReconnecting(): boolean; /** * Whether the server has signalled `flow.pause` and we have not yet seen * `flow.resume`. While `true`, throttled outbound kinds are queued. */ get isPaused(): boolean; /** * Snapshot of the paused-queue depth. Useful for tests and observability; * not a live counter (no subscription, no event). */ get pausedQueueDepth(): number; /** Raw outbound predicate — connected AND transport is in OPEN state. */ private canSend; private dispatch; /** * Translate an inbound `task.canceling` envelope into a typed * `ClientCancelAckedEvent` and route it to the subscription whose chain * (or `task_id`) matches. Scoped — not connection-wide — so a consumer * running multiple tasks only sees the ack for the task they're * iterating. * * Skips taps (`RawSubscription` already received the envelope through * `fanout`); fans into `TaskSubscription` (matching by `task_id` / * chain) and observer ports (no-op `considerClient`). */ private dispatchCancelAcked; /** * Flush the paused queue in FIFO order. Stops if the transport closes * mid-drain (a `transport.send` after close would throw and lose the * remaining frames silently). Caller is responsible for ensuring `paused` * is `false` before invoking — this method does not reset the flag. * * Drain failure preserves queue state. The earlier shape ("slice + * clear + iterate") dropped every * REMAINING entry (including their `overrideToSave` metadata) when * `transport.send` threw mid-drain. The post-send-success invariant * says the override write is conditional on wire-success; an entry * that * never reaches the wire MUST stay in the queue so the next drain * (after the transport recovers and `flow.resume` arrives again, * or via the reconnect-loop's eventual retry) can replay it with * the same metadata intact. * * New shape: walk by INDEX without clearing the queue first. * - For each entry, send + commit override. If both succeed, * mark the index as drained. * - If `transport.send` throws OR `canSend()` flips mid-drain, * stop immediately, splice off only the successfully drained * prefix. Everything from the failing index onward stays in * `pausedQueue` for the next drain attempt. * * The naive `while (length > 0) shift()` form is O(n²) because each * `shift()` reindexes the whole array; with a user-configurable cap * the blocking time on a large drain shows up in the inbound-message * handler. Index-walk + single splice at the end keeps the drain O(n). */ private drainPausedQueue; private fanout; private broadcastClient; /** * Wire-level error from the transport. If the user hasn't disconnected and * we have something worth replaying, kick off the reconnect loop. Otherwise * fail the subscriptions and shut down. * * Re-entrancy: a custom `WSTransport.close()` may synchronously invoke * `handlers.onClose` (the `WSTransport` interface places no async-vs-sync * contract on close-event timing — only that one fires). If we called * `disposeTransportSafely()` while `state` was still `'connected'`, the * synchronous `onClose` would re-enter `transportClosed`, see state still * `'connected'`, and start its own reconnect loop. Then this method would * return and start ANOTHER one. To prevent this, we transition state OUT * of `'connected'` before disposing — any re-entrant `transportClosed` / * `transportFailed` then sees the guard and returns early. */ private transportFailed; /** * Transport closed. A live task takes precedence over the close code: load * balancers and rolling servers can surface 1000/1001/1005 while the task is * still running, so those closures must reconnect and replay just like an * abnormal close. Clean codes end subscriptions only when there is no active * task worth recovering. * * Re-entrancy: same guard as `transportFailed` — state transitions before * disposal so a synchronous `onClose` from `transport.close()` can't fire * a second reconnect loop. Clean closes use a separate guard state * (`'disconnecting'` → `'disconnected'`) for the same reason. */ private transportClosed; /** * Reconnect is worth attempting only when there's at least one in-flight * task subscription that can be replayed. A connection with no live tasks * and only raw taps gains nothing from a reconnect — the user can call * `connect()` fresh next time they need it. */ private shouldAttemptReconnect; /** * Build event handlers bound to a specific transport `epoch`. Each handler * no-ops unless its epoch still matches {@link transportEpoch} — i.e. its * transport is still the live one — so a delayed event from a superseded * socket is dropped instead of corrupting the current connection. Used for * reconnect transports; the initial transport's handlers (built in * {@link open}) apply the same epoch-1 guard inline. */ private makeTransportHandlers; /** * Backoff for reconnect `attempt` (1-based), in milliseconds. * * - **Attempt 1 fires immediately (0ms).** A transient blip — the common * case, including the WS being dropped a moment before a healthy * replica is ready — recovers in ~1 RTT instead of paying a full * backoff. The dead sleep before the first probe is exactly the window * a server-side task-drop-on-disconnect grace can lose, so we close it. * - **Attempt ≥2** uses the exponential schedule * `initialBackoff * multiplier^(attempt-2)`, capped at * {@link reconnectMaxBackoffMs}, with one-sided +{@link * RECONNECT_BACKOFF_JITTER} jitter on top so a fleet dropped by the * same rolling deploy doesn't reconnect in lockstep. The cap is applied * after jitter so the result can never exceed `reconnectMaxBackoffMs`. * * `Math.random()` is the only impurity (the jitter source), matching the * admission-retry backoff helper. */ private computeReconnectDelayMs; /** Start the async reconnect loop without allowing a rejection to escape. */ private startReconnectLoop; /** * Reconnect loop. Backs off exponentially, retries up to `maxAttempts`. On * success: swap the transport, attempt active-task replay, then broadcast * `qar.client.reconnected` with the actual wire-attempt count. On exhaustion: broadcast * `qar.client.reconnect_failed`, fail every subscription. * * Mid-loop user disconnect is honored — we check `state` after every async * boundary and bail with a clean close. */ private runReconnectLoop; /** * For each active task subscription, send a `task.resubscribe` envelope * using its `lastSeenMessageId` as the anchor. The new envelope's id is * stitched into the subscription's chain so the server's replayed * envelopes route back transparently. * * **Active-subscription state machine.** A `TaskSubscription` * registered in `this.subscriptions` can be in one of three states at * any point in a connection's lifetime: * * 1. **pre-admission** — `tasks.start` sent its outbound envelope on * the wire and the SDK eagerly registered the subscription so * inbound routing can find it. The server-derived `session_id` * is NOT yet in {@link taskSessions} because no `task.started` * ack has been observed. `currentTaskId` IS set (derived from * `task.start.message_id` per the wire contract `task_id == * task.start.message_id`). * 2. **admitted** — the `task.started` ack landed; the canonical * `task_id` is in `currentTaskId` and the matching `session_id` * is pinned in {@link taskSessions}. Resubscribe is fully * addressable. * 3. **terminated** — `task.done`, server-error, transport-fail, * or consumer-driven close has flipped `isTerminated` true. * `onClose` has already unsubscribed; subs in this state should * not be in the iterating snapshot. * * `replayActiveTasks` must handle states (1) and (2) explicitly. * State (1) is **unrecoverable** per the session-identity contract: * without an in-memory `session_id`, the SDK cannot construct a * wire-valid `task.resubscribe` for the server's pre-merge * bind-and-derive ingress (the resubscribe envelope MUST carry the * correct `session_id` field per the QAR wire schema). * * **Implementation note.** The implementation pre-checks the per-Task * session map BEFORE attempting the wire send. A state-(1) sub gets * a freshly-constructed `QodoColdAddressError` whose delivery is deferred * until after the reconnect-completion event, and whose * stack starts cleanly inside `replayActiveTasks` — no wire * round-trip is attempted; the failure is local, typed, and * origin-traceable. An earlier implementation called * {@link sendEnvelope} first and relied on the synchronous throw * from {@link resolveOutboundSessionId} to route the failure into * `sub.fail(err)` — functionally correct but the thrown error * carried a misleading stack (the wire-send frames). The current * pre-check approach surfaces a typed local failure instead. * The consumer-facing API surface is unchanged: state-(1) subs still * surface `QodoColdAddressError` on their iterator. Lost-ack * idempotent retry (re-issuing the original `task.start` on the new * transport with the same idempotency-key) lives at the layer * above; the raw `Connection` ships the typed failure. * * Every `TaskSubscription` carries a known `task_id` from construction: * `tasks.continue` / `tasks.cancel` / `tasks.resubscribe` receive it from * the caller, and `tasks.start` derives it from the outbound * `task.start.message_id`. The `currentTaskId === undefined` case is * therefore unreachable here — we keep an invariant assertion rather * than the prior "in-flight task is unrecoverable" `sub.fail` path. */ private replayActiveTasks; private cleanCloseAndCleanup; private failHardAndCleanup; /** * Walk the user-initiated disconnect path from inside the reconnect loop. * The subs are still registered (we deferred their close); end them * cleanly so `for await` consumers see `done: true` rather than an error. */ private finalizeDisconnect; private disposeTransportSafely; private clearPendingTimers; /** * Sleep for `ms` milliseconds, cancellable by `clearPendingTimers`. Returns * even if the timer is cleared early — the caller checks `state` after the * await to decide whether to proceed. Cancelling resolves the Promise * rather than rejecting it: cancellation is a flow-control signal, not an * error, and the reconnect loop's post-await state check handles it. */ private sleepCancellable; } export type { WSTransportFactoryArgs }; //# sourceMappingURL=connection.d.ts.map