@tanstack/ai-client 0.23.3 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -137,6 +137,84 @@ function resolveReconnectOptions(
137
137
  return { maxAttempts, delayMs }
138
138
  }
139
139
 
140
+ /**
141
+ * Reconnect bookkeeping shared by every resumable-stream driver: de-dupes
142
+ * offsets, tracks the last acknowledged offset, honors the SSE empty-id reset
143
+ * convention, and bounds consecutive no-progress reconnects behind a
144
+ * throttling delay. Extracted out of {@link resumableStream} so a WebSocket
145
+ * reconnect driver can reuse the exact same semantics.
146
+ */
147
+ export interface ReconnectTracker {
148
+ /** The most recently accepted (non-duplicate, non-empty) offset, if any. */
149
+ readonly lastEventId: string | undefined
150
+ /**
151
+ * Record an incoming offset. Returns `'reset'` for an empty id (SSE's
152
+ * resume-cursor reset — clears the de-dupe set and `lastEventId`),
153
+ * `'duplicate'` for an already-seen id, and `'new'` otherwise (including
154
+ * `undefined`, which is untracked — no offset to remember).
155
+ */
156
+ note: (id: string | undefined) => 'new' | 'duplicate' | 'reset'
157
+ /**
158
+ * Throttle before a reconnect attempt. Resets the no-progress counter when
159
+ * `madeProgress` is true; otherwise increments it and throws
160
+ * {@link StreamReconnectLimitError} once it exceeds the configured ceiling.
161
+ */
162
+ waitBeforeReconnect: (
163
+ madeProgress: boolean,
164
+ signal?: AbortSignal,
165
+ ) => Promise<void>
166
+ }
167
+
168
+ /** Create a {@link ReconnectTracker} bound to the given reconnect bounds. */
169
+ export function createReconnectTracker(
170
+ options?: ReconnectOptions,
171
+ ): ReconnectTracker {
172
+ const reconnect = resolveReconnectOptions(options)
173
+ // Retains every delivered offset for the run's lifetime. Intentionally
174
+ // bounded by run length (not evicted): a conforming server replays strictly
175
+ // after the acknowledged offset, so this only needs to catch the single
176
+ // boundary event on reconnect, but keeping the full set keeps de-dup
177
+ // correct even if a server replays a wider overlap.
178
+ const seen = new Set<string>()
179
+ let lastEventId: string | undefined
180
+ let reconnectAttempts = 0
181
+ return {
182
+ get lastEventId() {
183
+ return lastEventId
184
+ },
185
+ note(id) {
186
+ if (id === undefined) return 'new'
187
+ if (id === '') {
188
+ // SSE spec: an empty `id:` resets the resume cursor. Drop the last
189
+ // offset and clear the de-dupe set; the chunk itself still delivers.
190
+ lastEventId = undefined
191
+ seen.clear()
192
+ return 'reset'
193
+ }
194
+ if (seen.has(id)) return 'duplicate'
195
+ seen.add(id)
196
+ lastEventId = id
197
+ return 'new'
198
+ },
199
+ // Bound only CONSECUTIVE no-progress reconnects. A reconnect that made
200
+ // forward progress resets the counter, so a healthy long run (even one
201
+ // whose socket rolls after every event) never approaches the ceiling; it
202
+ // fires only when the run is genuinely stuck — reconnecting repeatedly
203
+ // with nothing new.
204
+ async waitBeforeReconnect(madeProgress, signal) {
205
+ if (madeProgress) {
206
+ reconnectAttempts = 0
207
+ } else {
208
+ reconnectAttempts += 1
209
+ if (reconnectAttempts > reconnect.maxAttempts) {
210
+ throw new StreamReconnectLimitError(reconnect.maxAttempts)
211
+ }
212
+ }
213
+ await abortableDelay(reconnect.delayMs, signal)
214
+ },
215
+ }
216
+ }
217
+
140
218
  /** Resolve after `ms`, or immediately once `signal` aborts. Never rejects. */
141
219
  function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
142
220
  if (ms <= 0 || signal?.aborted) return Promise.resolve()
@@ -430,7 +508,10 @@ async function fetchThreadHydration(
430
508
  const data = (await response.json()) as {
431
509
  messages?: Array<UIMessage>
432
510
  activeRun?: { runId?: unknown } | null
433
- interrupts?: { runId?: unknown; pending?: unknown } | null
511
+ interrupts?: {
512
+ runId?: unknown
513
+ pending?: unknown
514
+ } | null
434
515
  }
435
516
  const activeRun =
436
517
  data.activeRun && typeof data.activeRun.runId === 'string'
@@ -598,39 +679,14 @@ async function* resumableStream(
598
679
  abortSignal?: AbortSignal,
599
680
  reconnectOptions?: ReconnectOptions,
600
681
  ): AsyncGenerator<StreamChunk> {
601
- // Retains every delivered offset for the run's lifetime. Intentionally bounded
602
- // by run length (not evicted): a conforming server replays strictly after the
603
- // acknowledged offset, so this only needs to catch the single boundary event
604
- // on reconnect, but keeping the full set keeps de-dup correct even if a server
605
- // replays a wider overlap.
606
- const seen = new Set<string>()
607
- let lastEventId: string | undefined
608
- const reconnect = resolveReconnectOptions(reconnectOptions)
609
- let reconnectAttempts = 0
610
-
611
- // Throttle before re-issuing the request, and enforce the total ceiling so a
612
- // producer that keeps dropping after each event is bounded rather than
613
- // reconnecting forever.
614
- // Bound only CONSECUTIVE no-progress reconnects. A reconnect that made forward
615
- // progress resets the counter, so a healthy long run (even one whose socket
616
- // rolls after every event) never approaches the ceiling; it fires only when
617
- // the run is genuinely stuck — reconnecting repeatedly with nothing new.
618
- async function waitBeforeReconnect(madeProgress: boolean): Promise<void> {
619
- if (madeProgress) {
620
- reconnectAttempts = 0
621
- } else {
622
- reconnectAttempts += 1
623
- if (reconnectAttempts > reconnect.maxAttempts) {
624
- throw new StreamReconnectLimitError(reconnect.maxAttempts)
625
- }
626
- }
627
- await abortableDelay(reconnect.delayMs, abortSignal)
628
- }
682
+ const tracker = createReconnectTracker(reconnectOptions)
629
683
 
630
684
  for (;;) {
631
685
  if (abortSignal?.aborted) return
632
686
  const extraHeaders: Record<string, string> =
633
- lastEventId !== undefined ? { 'Last-Event-ID': lastEventId } : {}
687
+ tracker.lastEventId !== undefined
688
+ ? { 'Last-Event-ID': tracker.lastEventId }
689
+ : {}
634
690
 
635
691
  let sawTerminal = false
636
692
  let progressed = false
@@ -639,18 +695,7 @@ async function* resumableStream(
639
695
  extraHeaders,
640
696
  abortSignal,
641
697
  )) {
642
- if (id !== undefined) {
643
- if (id === '') {
644
- // SSE spec: an empty `id:` resets the resume cursor. Drop the last
645
- // offset and clear the de-dupe set; the chunk itself still delivers.
646
- lastEventId = undefined
647
- seen.clear()
648
- } else {
649
- if (seen.has(id)) continue
650
- seen.add(id)
651
- lastEventId = id
652
- }
653
- }
698
+ if (tracker.note(id) === 'duplicate') continue
654
699
  progressed = true
655
700
  if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
656
701
  sawTerminal = true
@@ -677,9 +722,9 @@ async function* resumableStream(
677
722
  if (
678
723
  (error instanceof StreamTruncatedError ||
679
724
  error instanceof StreamReadError) &&
680
- lastEventId !== undefined
725
+ tracker.lastEventId !== undefined
681
726
  ) {
682
- await waitBeforeReconnect(progressed)
727
+ await tracker.waitBeforeReconnect(progressed, abortSignal)
683
728
  continue
684
729
  }
685
730
  throw error
@@ -693,14 +738,14 @@ async function* resumableStream(
693
738
  // would re-open past the final offset and see an empty window.
694
739
  if (sawTerminal) return
695
740
 
696
- if (lastEventId !== undefined) {
741
+ if (tracker.lastEventId !== undefined) {
697
742
  // A durable (id-tagged) run.
698
743
  if (progressed) {
699
744
  // Clean end WITHOUT a terminal event but we advanced — the producer is
700
745
  // still going (or the socket rolled over). Reconnect from the last
701
746
  // offset (backing off to avoid a hot loop against the origin). Progress
702
747
  // resets the no-progress ceiling.
703
- await waitBeforeReconnect(true)
748
+ await tracker.waitBeforeReconnect(true, abortSignal)
704
749
  continue
705
750
  }
706
751
  // Ended without a terminal event AND made no forward progress on this
@@ -830,7 +875,10 @@ export interface ChatHydrationResult {
830
875
  * so a reload (or another device) re-prompts the approval from the server. The
831
876
  * client restores them exactly as a persisted resume snapshot would.
832
877
  */
833
- interrupts: { runId: string; pending: Array<ChatPendingInterrupt> } | null
878
+ interrupts: {
879
+ runId: string
880
+ pending: Array<ChatPendingInterrupt>
881
+ } | null
834
882
  }
835
883
 
836
884
  /**
@@ -1898,6 +1946,410 @@ export function xhrHttpStream(
1898
1946
  }
1899
1947
  }
1900
1948
 
1949
+ export interface WebSocketConnectionOptions {
1950
+ protocols?: string | Array<string>
1951
+ body?: Record<string, unknown>
1952
+ reconnect?: ReconnectOptions
1953
+ /** Override the WebSocket implementation (tests / non-browser runtimes). */
1954
+ WebSocketImpl?: typeof WebSocket
1955
+ }
1956
+
1957
+ function runIdQuery(url: string, runId: string | undefined): string {
1958
+ return runId ? withSearchParams(url, { runId }) : url
1959
+ }
1960
+
1961
+ function isPingFrame(parsed: unknown): boolean {
1962
+ return (
1963
+ typeof parsed === 'object' &&
1964
+ parsed !== null &&
1965
+ (parsed as { type?: unknown }).type === 'ping'
1966
+ )
1967
+ }
1968
+
1969
+ /** A subscribe() consumer's registration: receives chunks or a fatal error. */
1970
+ interface WebSocketChunkSink {
1971
+ push: (chunk: StreamChunk) => void
1972
+ fail: (error: unknown) => void
1973
+ }
1974
+
1975
+ /**
1976
+ * A push→pull bridge from socket callbacks to an async iterable: chunks queue
1977
+ * until the consumer pulls, a recorded failure rejects the iterator, and
1978
+ * `end()` (or the abort signal) finishes it cleanly. Shared by `webSocket()`'s
1979
+ * `subscribe()` and `joinRun()`.
1980
+ */
1981
+ function createChunkPipe(
1982
+ abortSignal: AbortSignal | undefined,
1983
+ onFinally: () => void,
1984
+ ): {
1985
+ push: (chunk: StreamChunk) => void
1986
+ fail: (error: unknown) => void
1987
+ end: () => void
1988
+ iterable: AsyncIterable<StreamChunk>
1989
+ } {
1990
+ const queue: Array<StreamChunk> = []
1991
+ const waiters: Array<(c: StreamChunk | null) => void> = []
1992
+ let failure: unknown
1993
+ let ended = false
1994
+ const wake = () => waiters.shift()?.(null)
1995
+ const push = (chunk: StreamChunk) => {
1996
+ const w = waiters.shift()
1997
+ if (w) w(chunk)
1998
+ else queue.push(chunk)
1999
+ }
2000
+ const fail = (error: unknown) => {
2001
+ failure = error
2002
+ wake()
2003
+ }
2004
+ const end = () => {
2005
+ ended = true
2006
+ wake()
2007
+ }
2008
+ const onAbort = () => wake()
2009
+ abortSignal?.addEventListener('abort', onAbort)
2010
+ const iterable = (async function* () {
2011
+ try {
2012
+ while (!abortSignal?.aborted) {
2013
+ // Drain buffered chunks before ever awaiting a new promise — a
2014
+ // fatal drop that lands while chunks are still queued (fail()
2015
+ // finds no pending waiter, since the consumer hasn't caught up
2016
+ // to its buffer yet) must not be lost.
2017
+ const buffered = queue.shift()
2018
+ if (buffered !== undefined) {
2019
+ yield buffered
2020
+ continue
2021
+ }
2022
+ // Buffer exhausted: surface a failure recorded while we were
2023
+ // draining, rather than awaiting a promise that will never
2024
+ // resolve (the connection is dead — no future push/fail).
2025
+ if (failure !== undefined) throw failure
2026
+ if (ended) return
2027
+ const chunk = await new Promise<StreamChunk | null>((r) =>
2028
+ waiters.push(r),
2029
+ )
2030
+ // The wait resolved because fail() woke us — surface the error
2031
+ // instead of treating the null sentinel as a clean end. TS narrows
2032
+ // `failure` to `undefined` from the check above and doesn't know
2033
+ // the `fail()` closure can reassign it while we were awaiting —
2034
+ // this check is very much still reachable.
2035
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
2036
+ if (failure !== undefined) throw failure
2037
+ if (chunk === null) return
2038
+ yield chunk
2039
+ }
2040
+ } finally {
2041
+ abortSignal?.removeEventListener('abort', onAbort)
2042
+ onFinally()
2043
+ }
2044
+ })()
2045
+ return { push, fail, end, iterable }
2046
+ }
2047
+
2048
+ /**
2049
+ * The send()-driven run currently owning auto-reconnect for a `webSocket()`
2050
+ * connection: reconnect is scoped to the run `send()` is driving, so a drop
2051
+ * with no established run is surfaced to subscribers rather than auto-resumed.
2052
+ */
2053
+ interface WebSocketRunSession {
2054
+ runId: string | undefined
2055
+ readonly tracker: ReconnectTracker
2056
+ sawTerminal: boolean
2057
+ /** Made forward progress (a new, non-duplicate chunk) since the last (re)connect. */
2058
+ progressed: boolean
2059
+ signal: AbortSignal | undefined
2060
+ }
2061
+
2062
+ /**
2063
+ * Full-duplex, conversation-scoped WebSocket connection adapter. Pairs with the
2064
+ * server `toWebSocketResponse` / `toWebSocketStream`. `send()` writes a
2065
+ * RunAgentInput frame; `subscribe()` yields inbound chunks.
2066
+ *
2067
+ * Resumable: `send()` establishes a run session backed by a
2068
+ * {@link createReconnectTracker}. If the socket closes before a terminal
2069
+ * (`RUN_FINISHED`/`RUN_ERROR`) chunk is seen and the run is durable
2070
+ * (offset-tagged `{ id, chunk }` envelopes), the socket is reopened at
2071
+ * `?runId=&offset=<lastEventId>`, de-duping the replayed boundary. A drop with
2072
+ * no offset ever observed (non-durable) surfaces {@link StreamReadError}
2073
+ * instead of reconnecting — there is nothing to resume from.
2074
+ */
2075
+ export function webSocket(
2076
+ url: string | (() => string),
2077
+ options: WebSocketConnectionOptions = {},
2078
+ ): SubscribeConnectionAdapter & {
2079
+ joinRun: (
2080
+ runId: string,
2081
+ abortSignal?: AbortSignal,
2082
+ ) => AsyncIterable<StreamChunk>
2083
+ } {
2084
+ const Impl = options.WebSocketImpl ?? WebSocket
2085
+ let socket: WebSocket | undefined
2086
+ // Whether the current socket is the conversation socket ('run') or a
2087
+ // read-only replay connection opened by a reconnect ('resume'). Only the
2088
+ // conversation socket accepts run frames server-side.
2089
+ let socketMode: 'run' | 'resume' | undefined
2090
+ // Memoized per-socket open promise. `openOnce` sets `onopen`/`onerror`
2091
+ // exactly ONCE, at socket-creation time, and stores the resulting promise
2092
+ // here. Without this, `waitOpen` assigning `onopen`/`onerror` on every call
2093
+ // would clobber a still-pending prior caller's handlers: `openOnce` reuses
2094
+ // the same in-flight socket for concurrent callers (`readyState <= 1`), so a
2095
+ // second `send()` issued before the handshake completes would overwrite the
2096
+ // first call's handlers and leave its promise permanently unresolved.
2097
+ let openPromise: Promise<void> | undefined
2098
+ const listeners = new Set<WebSocketChunkSink>()
2099
+ let currentSession: WebSocketRunSession | undefined
2100
+
2101
+ function failAll(error: unknown): void {
2102
+ for (const l of listeners) l.fail(error)
2103
+ }
2104
+
2105
+ function openOnce(target: string, mode: 'run' | 'resume'): WebSocket {
2106
+ // Only the conversation socket is reused — it multiplexes many turns. A
2107
+ // 'resume' handshake carries ?offset and must reach the server as its own
2108
+ // connection (reusing any open socket would discard that query, so no
2109
+ // replay would ever be requested), and a run frame must never be written
2110
+ // to a read-only resume socket (the server registers no message listener
2111
+ // there, so the frame would be silently ignored).
2112
+ if (
2113
+ socket &&
2114
+ socket.readyState <= 1 &&
2115
+ mode === 'run' &&
2116
+ socketMode === 'run'
2117
+ ) {
2118
+ return socket
2119
+ }
2120
+ const prior = socket
2121
+ const ws = options.protocols
2122
+ ? new Impl(target, options.protocols)
2123
+ : new Impl(target)
2124
+ socket = ws
2125
+ socketMode = mode
2126
+ openPromise = new Promise<void>((resolve, reject) => {
2127
+ ws.onopen = () => resolve()
2128
+ ws.onerror = (e) => reject(new StreamReadError(e))
2129
+ })
2130
+ // Attach a no-op handler so a socket nobody awaits can't raise an
2131
+ // unhandled rejection if it errors. Awaiters of openPromise still see the rejection.
2132
+ openPromise.catch(() => {})
2133
+ ws.onmessage = (event: MessageEvent) => {
2134
+ // A retired socket (a newer connection took over below) must not keep
2135
+ // feeding the shared listeners.
2136
+ if (ws !== socket) return
2137
+ let parsed: unknown
2138
+ try {
2139
+ parsed = JSON.parse(String(event.data))
2140
+ } catch (error) {
2141
+ failAll(new StreamReadError(error))
2142
+ return
2143
+ }
2144
+ if (isPingFrame(parsed)) return
2145
+ const envelopeId = isNdjsonEnvelope(parsed) ? parsed.id : undefined
2146
+ const chunk = isNdjsonEnvelope(parsed)
2147
+ ? parsed.chunk
2148
+ : (parsed as StreamChunk)
2149
+
2150
+ // Thread durable chunks through the active run session's tracker (if
2151
+ // any) so a later reconnect knows the last offset and can skip a
2152
+ // replayed boundary. A socket with no active session dispatches chunks
2153
+ // as-is.
2154
+ const session = currentSession
2155
+ if (session) {
2156
+ if (session.tracker.note(envelopeId) === 'duplicate') return
2157
+ session.progressed = true
2158
+ if (session.runId === undefined) {
2159
+ session.runId = getChunkRunId(chunk)
2160
+ }
2161
+ if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
2162
+ session.sawTerminal = true
2163
+ }
2164
+ }
2165
+ for (const l of listeners) l.push(chunk)
2166
+ }
2167
+ ws.onclose = () => {
2168
+ // Retired deliberately in favor of a newer connection — not a drop.
2169
+ if (ws !== socket) return
2170
+ const session = currentSession
2171
+ if (!session) {
2172
+ // No run session (never established, or cleared by a prior failure).
2173
+ // Surface the drop so subscribers do not stay parked on a dead socket.
2174
+ failAll(new StreamReadError(new Error('WebSocket connection closed')))
2175
+ return
2176
+ }
2177
+ if (session.signal?.aborted || session.sawTerminal) return
2178
+ const lastEventId = session.tracker.lastEventId
2179
+ if (lastEventId === undefined) {
2180
+ // Non-durable run (no offset ever observed) — nothing to resume
2181
+ // from. Surface a hard failure rather than silently reconnecting
2182
+ // forever against a server that never tags its events.
2183
+ currentSession = undefined
2184
+ failAll(new StreamReadError(new Error('WebSocket connection closed')))
2185
+ return
2186
+ }
2187
+ void reconnect(session, lastEventId)
2188
+ }
2189
+ // Retire a superseded socket (e.g. a lingering resume socket when send()
2190
+ // opens the next conversation socket) so two sockets never feed the
2191
+ // shared listeners at once. Its handlers see it is no longer current and
2192
+ // ignore the close.
2193
+ if (prior && prior.readyState <= 1) prior.close()
2194
+ return ws
2195
+ }
2196
+
2197
+ async function reconnect(
2198
+ session: WebSocketRunSession,
2199
+ offset: string,
2200
+ ): Promise<void> {
2201
+ try {
2202
+ // Bounded by the shared tracker's consecutive-no-progress ceiling —
2203
+ // mirrors resumableStream so a flapping server can't reconnect forever.
2204
+ await session.tracker.waitBeforeReconnect(
2205
+ session.progressed,
2206
+ session.signal,
2207
+ )
2208
+ } catch (error) {
2209
+ if (currentSession === session) currentSession = undefined
2210
+ failAll(error)
2211
+ return
2212
+ }
2213
+ if (session.signal?.aborted) return
2214
+ // A send() issued during the backoff supersedes this resume: a newer run
2215
+ // (or a resubmit of this one) already owns a fresh conversation socket,
2216
+ // and its turn re-delivers from the durability log — the tracker de-dupes
2217
+ // any overlap. Opening the resume socket anyway would retire that live
2218
+ // conversation socket.
2219
+ if (currentSession !== session) return
2220
+ if (socket && socket.readyState <= 1) return
2221
+ session.progressed = false
2222
+ const base = typeof url === 'function' ? url() : url
2223
+ const target = withSearchParams(base, {
2224
+ ...(session.runId !== undefined ? { runId: session.runId } : {}),
2225
+ offset,
2226
+ })
2227
+ openOnce(target, 'resume')
2228
+ }
2229
+
2230
+ function waitOpen(ws: WebSocket): Promise<void> {
2231
+ if (ws.readyState === 1) return Promise.resolve()
2232
+ // Concurrent callers awaiting the SAME in-flight socket share the SAME
2233
+ // memoized promise (set once in `openOnce`), so none of them clobber
2234
+ // another's onopen/onerror handler.
2235
+ return openPromise ?? Promise.resolve()
2236
+ }
2237
+
2238
+ return {
2239
+ subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk> {
2240
+ const pipe = createChunkPipe(abortSignal, () => listeners.delete(sink))
2241
+ const sink: WebSocketChunkSink = { push: pipe.push, fail: pipe.fail }
2242
+ listeners.add(sink)
2243
+ return pipe.iterable
2244
+ },
2245
+ async send(messages, data, abortSignal, runContext) {
2246
+ const target = typeof url === 'function' ? url() : url
2247
+ const ws = openOnce(runIdQuery(target, runContext?.runId), 'run')
2248
+ await waitOpen(ws)
2249
+ // Establish (or continue) the run session this socket is driving, so
2250
+ // an unterminated drop can auto-resume it. A distinct runId starts a
2251
+ // fresh tracker (a new run's offsets are unrelated to the last one's);
2252
+ // the same runId reuses the tracker so a repeat send() on an
2253
+ // already-tracked run doesn't lose its de-dupe/offset state.
2254
+ if (!currentSession || currentSession.runId !== runContext?.runId) {
2255
+ currentSession = {
2256
+ runId: runContext?.runId,
2257
+ tracker: createReconnectTracker(options.reconnect),
2258
+ sawTerminal: false,
2259
+ progressed: false,
2260
+ signal: abortSignal,
2261
+ }
2262
+ } else {
2263
+ // Same-runId resubmit (e.g. a client-tool continuation): keep the
2264
+ // tracker, but this is a NEW turn — with the previous turn's
2265
+ // `sawTerminal` left set, a drop during the resubmitted turn would
2266
+ // neither reconnect nor surface an error.
2267
+ currentSession.signal = abortSignal
2268
+ currentSession.sawTerminal = false
2269
+ currentSession.progressed = false
2270
+ }
2271
+ const session = currentSession
2272
+ // stop() must reach the server: the conversation socket outlives the
2273
+ // turn, so without an abort frame the model keeps generating (and
2274
+ // billing) server-side. The frame aborts only this run's turn.
2275
+ abortSignal?.addEventListener(
2276
+ 'abort',
2277
+ () => {
2278
+ const abortRunId = session.runId
2279
+ const live = socket
2280
+ if (
2281
+ abortRunId === undefined ||
2282
+ session.sawTerminal ||
2283
+ socketMode !== 'run' ||
2284
+ live === undefined ||
2285
+ live.readyState !== 1
2286
+ ) {
2287
+ return
2288
+ }
2289
+ try {
2290
+ live.send(JSON.stringify({ type: 'abort', runId: abortRunId }))
2291
+ } catch {
2292
+ // Socket is CLOSING/CLOSED — the server aborts the turn on close.
2293
+ }
2294
+ },
2295
+ { once: true },
2296
+ )
2297
+ const body = buildRunAgentInputBody(messages, data, runContext, {
2298
+ body: options.body,
2299
+ })
2300
+ ws.send(JSON.stringify(body))
2301
+ },
2302
+ joinRun(runId, abortSignal): AsyncIterable<StreamChunk> {
2303
+ const target = withSearchParams(typeof url === 'function' ? url() : url, {
2304
+ offset: '-1',
2305
+ runId,
2306
+ })
2307
+ // A replay handshake must reach the server as its own connection:
2308
+ // reusing the conversation socket would discard the ?offset query (no
2309
+ // replay ever requested), and the conversation socket must not be
2310
+ // replaced by a read-only replay socket. So joinRun owns a dedicated
2311
+ // socket and never touches the shared socket or run session.
2312
+ const ws = options.protocols
2313
+ ? new Impl(target, options.protocols)
2314
+ : new Impl(target)
2315
+ const pipe = createChunkPipe(abortSignal, () => {
2316
+ if (ws.readyState <= 1) ws.close()
2317
+ })
2318
+ ws.onmessage = (event: MessageEvent) => {
2319
+ let parsed: unknown
2320
+ try {
2321
+ parsed = JSON.parse(String(event.data))
2322
+ } catch (error) {
2323
+ pipe.fail(new StreamReadError(error))
2324
+ return
2325
+ }
2326
+ if (isPingFrame(parsed)) return
2327
+ pipe.push(
2328
+ isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2329
+ )
2330
+ }
2331
+ ws.onclose = (event?: CloseEvent) => {
2332
+ // 1000 = the server finished replaying the log and closed cleanly.
2333
+ // Anything else is a drop or a policy refusal (e.g. 1008 "no resume
2334
+ // offset") and must surface — a joinRun socket never auto-reconnects.
2335
+ if (event?.code === 1000) {
2336
+ pipe.end()
2337
+ return
2338
+ }
2339
+ const detail = event
2340
+ ? `${event.code}${event.reason ? `: ${event.reason}` : ''}`
2341
+ : 'unknown'
2342
+ pipe.fail(
2343
+ new StreamReadError(
2344
+ new Error(`WebSocket connection closed (${detail})`),
2345
+ ),
2346
+ )
2347
+ }
2348
+ return pipe.iterable
2349
+ },
2350
+ }
2351
+ }
2352
+
1901
2353
  /**
1902
2354
  * Optional persistence handlers for the lightweight adapters (`stream()`,
1903
2355
  * `rpcStream()`). These are one-shot, request-scoped calls with no built-in
@@ -2009,6 +2461,12 @@ export function fetcherToConnectionAdapter(
2009
2461
  data,
2010
2462
  threadId: runContext.threadId,
2011
2463
  runId: runContext.runId,
2464
+ ...(runContext.parentRunId !== undefined
2465
+ ? { parentRunId: runContext.parentRunId }
2466
+ : {}),
2467
+ ...(runContext.resume !== undefined
2468
+ ? { resume: runContext.resume }
2469
+ : {}),
2012
2470
  },
2013
2471
  { signal: abortSignal },
2014
2472
  )
package/src/devtools.ts CHANGED
@@ -450,7 +450,6 @@ function getActiveBridgeRegistry(): Map<string, ActiveDevtoolsBridge> {
450
450
 
451
451
  export class ClientDevtoolsBridge<TSnapshot extends object> {
452
452
  protected readonly options: AIDevtoolsBridgeOptions<TSnapshot>
453
- private readonly bridgeId: string
454
453
  private readonly unsubscribers: Array<Unsubscribe> = []
455
454
  private disposed = false
456
455
  private superseded = false
@@ -458,7 +457,6 @@ export class ClientDevtoolsBridge<TSnapshot extends object> {
458
457
 
459
458
  constructor(options: AIDevtoolsBridgeOptions<TSnapshot>) {
460
459
  this.options = options
461
- this.bridgeId = createBridgeId(options.hookId)
462
460
  }
463
461
 
464
462
  emitRegistered(): void {
@@ -573,6 +571,12 @@ export class ClientDevtoolsBridge<TSnapshot extends object> {
573
571
  return
574
572
  }
575
573
 
574
+ const live = getActiveBridgeRegistry().get(this.options.hookId)
575
+ if (live !== this) {
576
+ this.deactivate()
577
+ return
578
+ }
579
+
576
580
  const payload = {
577
581
  ...this.createEnvelope('hook:unregistered'),
578
582
  ...this.createMetadataPayload(),
@@ -718,7 +722,6 @@ export class ClientDevtoolsBridge<TSnapshot extends object> {
718
722
  visibility,
719
723
  clientId: this.options.clientId,
720
724
  hookId: this.options.hookId,
721
- correlationId: this.bridgeId,
722
725
  ...(this.options.threadId ? { threadId: this.options.threadId } : {}),
723
726
  ...(context.runId ? { runId: context.runId } : {}),
724
727
  timestamp: Date.now(),
@@ -742,25 +745,6 @@ export class ClientDevtoolsBridge<TSnapshot extends object> {
742
745
  }
743
746
  }
744
747
 
745
- let bridgeIdSequence = 0
746
-
747
- function createBridgeId(hookId: string): string {
748
- const cryptoLike = (
749
- globalThis as {
750
- crypto?: {
751
- randomUUID?: () => string
752
- }
753
- }
754
- ).crypto
755
-
756
- if (cryptoLike?.randomUUID) {
757
- return `bridge:${hookId}:${cryptoLike.randomUUID()}`
758
- }
759
-
760
- bridgeIdSequence += 1
761
- return `bridge:${hookId}:${bridgeIdSequence}`
762
- }
763
-
764
748
  // Owns the chat-client devtools surface so the chat client itself stays a
765
749
  // pure transport. Fixture replay, per-run / per-stream event context, and
766
750
  // snapshot emission all live here; a no-op bridge can drop in for prod.