@tanstack/ai-client 0.23.2 → 0.24.0

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()
@@ -598,39 +676,14 @@ async function* resumableStream(
598
676
  abortSignal?: AbortSignal,
599
677
  reconnectOptions?: ReconnectOptions,
600
678
  ): 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
- }
679
+ const tracker = createReconnectTracker(reconnectOptions)
629
680
 
630
681
  for (;;) {
631
682
  if (abortSignal?.aborted) return
632
683
  const extraHeaders: Record<string, string> =
633
- lastEventId !== undefined ? { 'Last-Event-ID': lastEventId } : {}
684
+ tracker.lastEventId !== undefined
685
+ ? { 'Last-Event-ID': tracker.lastEventId }
686
+ : {}
634
687
 
635
688
  let sawTerminal = false
636
689
  let progressed = false
@@ -639,18 +692,7 @@ async function* resumableStream(
639
692
  extraHeaders,
640
693
  abortSignal,
641
694
  )) {
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
- }
695
+ if (tracker.note(id) === 'duplicate') continue
654
696
  progressed = true
655
697
  if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
656
698
  sawTerminal = true
@@ -677,9 +719,9 @@ async function* resumableStream(
677
719
  if (
678
720
  (error instanceof StreamTruncatedError ||
679
721
  error instanceof StreamReadError) &&
680
- lastEventId !== undefined
722
+ tracker.lastEventId !== undefined
681
723
  ) {
682
- await waitBeforeReconnect(progressed)
724
+ await tracker.waitBeforeReconnect(progressed, abortSignal)
683
725
  continue
684
726
  }
685
727
  throw error
@@ -693,14 +735,14 @@ async function* resumableStream(
693
735
  // would re-open past the final offset and see an empty window.
694
736
  if (sawTerminal) return
695
737
 
696
- if (lastEventId !== undefined) {
738
+ if (tracker.lastEventId !== undefined) {
697
739
  // A durable (id-tagged) run.
698
740
  if (progressed) {
699
741
  // Clean end WITHOUT a terminal event but we advanced — the producer is
700
742
  // still going (or the socket rolled over). Reconnect from the last
701
743
  // offset (backing off to avoid a hot loop against the origin). Progress
702
744
  // resets the no-progress ceiling.
703
- await waitBeforeReconnect(true)
745
+ await tracker.waitBeforeReconnect(true, abortSignal)
704
746
  continue
705
747
  }
706
748
  // Ended without a terminal event AND made no forward progress on this
@@ -1898,6 +1940,410 @@ export function xhrHttpStream(
1898
1940
  }
1899
1941
  }
1900
1942
 
1943
+ export interface WebSocketConnectionOptions {
1944
+ protocols?: string | Array<string>
1945
+ body?: Record<string, unknown>
1946
+ reconnect?: ReconnectOptions
1947
+ /** Override the WebSocket implementation (tests / non-browser runtimes). */
1948
+ WebSocketImpl?: typeof WebSocket
1949
+ }
1950
+
1951
+ function runIdQuery(url: string, runId: string | undefined): string {
1952
+ return runId ? withSearchParams(url, { runId }) : url
1953
+ }
1954
+
1955
+ function isPingFrame(parsed: unknown): boolean {
1956
+ return (
1957
+ typeof parsed === 'object' &&
1958
+ parsed !== null &&
1959
+ (parsed as { type?: unknown }).type === 'ping'
1960
+ )
1961
+ }
1962
+
1963
+ /** A subscribe() consumer's registration: receives chunks or a fatal error. */
1964
+ interface WebSocketChunkSink {
1965
+ push: (chunk: StreamChunk) => void
1966
+ fail: (error: unknown) => void
1967
+ }
1968
+
1969
+ /**
1970
+ * A push→pull bridge from socket callbacks to an async iterable: chunks queue
1971
+ * until the consumer pulls, a recorded failure rejects the iterator, and
1972
+ * `end()` (or the abort signal) finishes it cleanly. Shared by `webSocket()`'s
1973
+ * `subscribe()` and `joinRun()`.
1974
+ */
1975
+ function createChunkPipe(
1976
+ abortSignal: AbortSignal | undefined,
1977
+ onFinally: () => void,
1978
+ ): {
1979
+ push: (chunk: StreamChunk) => void
1980
+ fail: (error: unknown) => void
1981
+ end: () => void
1982
+ iterable: AsyncIterable<StreamChunk>
1983
+ } {
1984
+ const queue: Array<StreamChunk> = []
1985
+ const waiters: Array<(c: StreamChunk | null) => void> = []
1986
+ let failure: unknown
1987
+ let ended = false
1988
+ const wake = () => waiters.shift()?.(null)
1989
+ const push = (chunk: StreamChunk) => {
1990
+ const w = waiters.shift()
1991
+ if (w) w(chunk)
1992
+ else queue.push(chunk)
1993
+ }
1994
+ const fail = (error: unknown) => {
1995
+ failure = error
1996
+ wake()
1997
+ }
1998
+ const end = () => {
1999
+ ended = true
2000
+ wake()
2001
+ }
2002
+ const onAbort = () => wake()
2003
+ abortSignal?.addEventListener('abort', onAbort)
2004
+ const iterable = (async function* () {
2005
+ try {
2006
+ while (!abortSignal?.aborted) {
2007
+ // Drain buffered chunks before ever awaiting a new promise — a
2008
+ // fatal drop that lands while chunks are still queued (fail()
2009
+ // finds no pending waiter, since the consumer hasn't caught up
2010
+ // to its buffer yet) must not be lost.
2011
+ const buffered = queue.shift()
2012
+ if (buffered !== undefined) {
2013
+ yield buffered
2014
+ continue
2015
+ }
2016
+ // Buffer exhausted: surface a failure recorded while we were
2017
+ // draining, rather than awaiting a promise that will never
2018
+ // resolve (the connection is dead — no future push/fail).
2019
+ if (failure !== undefined) throw failure
2020
+ if (ended) return
2021
+ const chunk = await new Promise<StreamChunk | null>((r) =>
2022
+ waiters.push(r),
2023
+ )
2024
+ // The wait resolved because fail() woke us — surface the error
2025
+ // instead of treating the null sentinel as a clean end. TS narrows
2026
+ // `failure` to `undefined` from the check above and doesn't know
2027
+ // the `fail()` closure can reassign it while we were awaiting —
2028
+ // this check is very much still reachable.
2029
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
2030
+ if (failure !== undefined) throw failure
2031
+ if (chunk === null) return
2032
+ yield chunk
2033
+ }
2034
+ } finally {
2035
+ abortSignal?.removeEventListener('abort', onAbort)
2036
+ onFinally()
2037
+ }
2038
+ })()
2039
+ return { push, fail, end, iterable }
2040
+ }
2041
+
2042
+ /**
2043
+ * The send()-driven run currently owning auto-reconnect for a `webSocket()`
2044
+ * connection: reconnect is scoped to the run `send()` is driving, so a drop
2045
+ * with no established run is surfaced to subscribers rather than auto-resumed.
2046
+ */
2047
+ interface WebSocketRunSession {
2048
+ runId: string | undefined
2049
+ readonly tracker: ReconnectTracker
2050
+ sawTerminal: boolean
2051
+ /** Made forward progress (a new, non-duplicate chunk) since the last (re)connect. */
2052
+ progressed: boolean
2053
+ signal: AbortSignal | undefined
2054
+ }
2055
+
2056
+ /**
2057
+ * Full-duplex, conversation-scoped WebSocket connection adapter. Pairs with the
2058
+ * server `toWebSocketResponse` / `toWebSocketStream`. `send()` writes a
2059
+ * RunAgentInput frame; `subscribe()` yields inbound chunks.
2060
+ *
2061
+ * Resumable: `send()` establishes a run session backed by a
2062
+ * {@link createReconnectTracker}. If the socket closes before a terminal
2063
+ * (`RUN_FINISHED`/`RUN_ERROR`) chunk is seen and the run is durable
2064
+ * (offset-tagged `{ id, chunk }` envelopes), the socket is reopened at
2065
+ * `?runId=&offset=<lastEventId>`, de-duping the replayed boundary. A drop with
2066
+ * no offset ever observed (non-durable) surfaces {@link StreamReadError}
2067
+ * instead of reconnecting — there is nothing to resume from.
2068
+ */
2069
+ export function webSocket(
2070
+ url: string | (() => string),
2071
+ options: WebSocketConnectionOptions = {},
2072
+ ): SubscribeConnectionAdapter & {
2073
+ joinRun: (
2074
+ runId: string,
2075
+ abortSignal?: AbortSignal,
2076
+ ) => AsyncIterable<StreamChunk>
2077
+ } {
2078
+ const Impl = options.WebSocketImpl ?? WebSocket
2079
+ let socket: WebSocket | undefined
2080
+ // Whether the current socket is the conversation socket ('run') or a
2081
+ // read-only replay connection opened by a reconnect ('resume'). Only the
2082
+ // conversation socket accepts run frames server-side.
2083
+ let socketMode: 'run' | 'resume' | undefined
2084
+ // Memoized per-socket open promise. `openOnce` sets `onopen`/`onerror`
2085
+ // exactly ONCE, at socket-creation time, and stores the resulting promise
2086
+ // here. Without this, `waitOpen` assigning `onopen`/`onerror` on every call
2087
+ // would clobber a still-pending prior caller's handlers: `openOnce` reuses
2088
+ // the same in-flight socket for concurrent callers (`readyState <= 1`), so a
2089
+ // second `send()` issued before the handshake completes would overwrite the
2090
+ // first call's handlers and leave its promise permanently unresolved.
2091
+ let openPromise: Promise<void> | undefined
2092
+ const listeners = new Set<WebSocketChunkSink>()
2093
+ let currentSession: WebSocketRunSession | undefined
2094
+
2095
+ function failAll(error: unknown): void {
2096
+ for (const l of listeners) l.fail(error)
2097
+ }
2098
+
2099
+ function openOnce(target: string, mode: 'run' | 'resume'): WebSocket {
2100
+ // Only the conversation socket is reused — it multiplexes many turns. A
2101
+ // 'resume' handshake carries ?offset and must reach the server as its own
2102
+ // connection (reusing any open socket would discard that query, so no
2103
+ // replay would ever be requested), and a run frame must never be written
2104
+ // to a read-only resume socket (the server registers no message listener
2105
+ // there, so the frame would be silently ignored).
2106
+ if (
2107
+ socket &&
2108
+ socket.readyState <= 1 &&
2109
+ mode === 'run' &&
2110
+ socketMode === 'run'
2111
+ ) {
2112
+ return socket
2113
+ }
2114
+ const prior = socket
2115
+ const ws = options.protocols
2116
+ ? new Impl(target, options.protocols)
2117
+ : new Impl(target)
2118
+ socket = ws
2119
+ socketMode = mode
2120
+ openPromise = new Promise<void>((resolve, reject) => {
2121
+ ws.onopen = () => resolve()
2122
+ ws.onerror = (e) => reject(new StreamReadError(e))
2123
+ })
2124
+ // Attach a no-op handler so a socket nobody awaits can't raise an
2125
+ // unhandled rejection if it errors. Awaiters of openPromise still see the rejection.
2126
+ openPromise.catch(() => {})
2127
+ ws.onmessage = (event: MessageEvent) => {
2128
+ // A retired socket (a newer connection took over below) must not keep
2129
+ // feeding the shared listeners.
2130
+ if (ws !== socket) return
2131
+ let parsed: unknown
2132
+ try {
2133
+ parsed = JSON.parse(String(event.data))
2134
+ } catch (error) {
2135
+ failAll(new StreamReadError(error))
2136
+ return
2137
+ }
2138
+ if (isPingFrame(parsed)) return
2139
+ const envelopeId = isNdjsonEnvelope(parsed) ? parsed.id : undefined
2140
+ const chunk = isNdjsonEnvelope(parsed)
2141
+ ? parsed.chunk
2142
+ : (parsed as StreamChunk)
2143
+
2144
+ // Thread durable chunks through the active run session's tracker (if
2145
+ // any) so a later reconnect knows the last offset and can skip a
2146
+ // replayed boundary. A socket with no active session dispatches chunks
2147
+ // as-is.
2148
+ const session = currentSession
2149
+ if (session) {
2150
+ if (session.tracker.note(envelopeId) === 'duplicate') return
2151
+ session.progressed = true
2152
+ if (session.runId === undefined) {
2153
+ session.runId = getChunkRunId(chunk)
2154
+ }
2155
+ if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
2156
+ session.sawTerminal = true
2157
+ }
2158
+ }
2159
+ for (const l of listeners) l.push(chunk)
2160
+ }
2161
+ ws.onclose = () => {
2162
+ // Retired deliberately in favor of a newer connection — not a drop.
2163
+ if (ws !== socket) return
2164
+ const session = currentSession
2165
+ if (!session) {
2166
+ // No run session (never established, or cleared by a prior failure).
2167
+ // Surface the drop so subscribers do not stay parked on a dead socket.
2168
+ failAll(new StreamReadError(new Error('WebSocket connection closed')))
2169
+ return
2170
+ }
2171
+ if (session.signal?.aborted || session.sawTerminal) return
2172
+ const lastEventId = session.tracker.lastEventId
2173
+ if (lastEventId === undefined) {
2174
+ // Non-durable run (no offset ever observed) — nothing to resume
2175
+ // from. Surface a hard failure rather than silently reconnecting
2176
+ // forever against a server that never tags its events.
2177
+ currentSession = undefined
2178
+ failAll(new StreamReadError(new Error('WebSocket connection closed')))
2179
+ return
2180
+ }
2181
+ void reconnect(session, lastEventId)
2182
+ }
2183
+ // Retire a superseded socket (e.g. a lingering resume socket when send()
2184
+ // opens the next conversation socket) so two sockets never feed the
2185
+ // shared listeners at once. Its handlers see it is no longer current and
2186
+ // ignore the close.
2187
+ if (prior && prior.readyState <= 1) prior.close()
2188
+ return ws
2189
+ }
2190
+
2191
+ async function reconnect(
2192
+ session: WebSocketRunSession,
2193
+ offset: string,
2194
+ ): Promise<void> {
2195
+ try {
2196
+ // Bounded by the shared tracker's consecutive-no-progress ceiling —
2197
+ // mirrors resumableStream so a flapping server can't reconnect forever.
2198
+ await session.tracker.waitBeforeReconnect(
2199
+ session.progressed,
2200
+ session.signal,
2201
+ )
2202
+ } catch (error) {
2203
+ if (currentSession === session) currentSession = undefined
2204
+ failAll(error)
2205
+ return
2206
+ }
2207
+ if (session.signal?.aborted) return
2208
+ // A send() issued during the backoff supersedes this resume: a newer run
2209
+ // (or a resubmit of this one) already owns a fresh conversation socket,
2210
+ // and its turn re-delivers from the durability log — the tracker de-dupes
2211
+ // any overlap. Opening the resume socket anyway would retire that live
2212
+ // conversation socket.
2213
+ if (currentSession !== session) return
2214
+ if (socket && socket.readyState <= 1) return
2215
+ session.progressed = false
2216
+ const base = typeof url === 'function' ? url() : url
2217
+ const target = withSearchParams(base, {
2218
+ ...(session.runId !== undefined ? { runId: session.runId } : {}),
2219
+ offset,
2220
+ })
2221
+ openOnce(target, 'resume')
2222
+ }
2223
+
2224
+ function waitOpen(ws: WebSocket): Promise<void> {
2225
+ if (ws.readyState === 1) return Promise.resolve()
2226
+ // Concurrent callers awaiting the SAME in-flight socket share the SAME
2227
+ // memoized promise (set once in `openOnce`), so none of them clobber
2228
+ // another's onopen/onerror handler.
2229
+ return openPromise ?? Promise.resolve()
2230
+ }
2231
+
2232
+ return {
2233
+ subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk> {
2234
+ const pipe = createChunkPipe(abortSignal, () => listeners.delete(sink))
2235
+ const sink: WebSocketChunkSink = { push: pipe.push, fail: pipe.fail }
2236
+ listeners.add(sink)
2237
+ return pipe.iterable
2238
+ },
2239
+ async send(messages, data, abortSignal, runContext) {
2240
+ const target = typeof url === 'function' ? url() : url
2241
+ const ws = openOnce(runIdQuery(target, runContext?.runId), 'run')
2242
+ await waitOpen(ws)
2243
+ // Establish (or continue) the run session this socket is driving, so
2244
+ // an unterminated drop can auto-resume it. A distinct runId starts a
2245
+ // fresh tracker (a new run's offsets are unrelated to the last one's);
2246
+ // the same runId reuses the tracker so a repeat send() on an
2247
+ // already-tracked run doesn't lose its de-dupe/offset state.
2248
+ if (!currentSession || currentSession.runId !== runContext?.runId) {
2249
+ currentSession = {
2250
+ runId: runContext?.runId,
2251
+ tracker: createReconnectTracker(options.reconnect),
2252
+ sawTerminal: false,
2253
+ progressed: false,
2254
+ signal: abortSignal,
2255
+ }
2256
+ } else {
2257
+ // Same-runId resubmit (e.g. a client-tool continuation): keep the
2258
+ // tracker, but this is a NEW turn — with the previous turn's
2259
+ // `sawTerminal` left set, a drop during the resubmitted turn would
2260
+ // neither reconnect nor surface an error.
2261
+ currentSession.signal = abortSignal
2262
+ currentSession.sawTerminal = false
2263
+ currentSession.progressed = false
2264
+ }
2265
+ const session = currentSession
2266
+ // stop() must reach the server: the conversation socket outlives the
2267
+ // turn, so without an abort frame the model keeps generating (and
2268
+ // billing) server-side. The frame aborts only this run's turn.
2269
+ abortSignal?.addEventListener(
2270
+ 'abort',
2271
+ () => {
2272
+ const abortRunId = session.runId
2273
+ const live = socket
2274
+ if (
2275
+ abortRunId === undefined ||
2276
+ session.sawTerminal ||
2277
+ socketMode !== 'run' ||
2278
+ live === undefined ||
2279
+ live.readyState !== 1
2280
+ ) {
2281
+ return
2282
+ }
2283
+ try {
2284
+ live.send(JSON.stringify({ type: 'abort', runId: abortRunId }))
2285
+ } catch {
2286
+ // Socket is CLOSING/CLOSED — the server aborts the turn on close.
2287
+ }
2288
+ },
2289
+ { once: true },
2290
+ )
2291
+ const body = buildRunAgentInputBody(messages, data, runContext, {
2292
+ body: options.body,
2293
+ })
2294
+ ws.send(JSON.stringify(body))
2295
+ },
2296
+ joinRun(runId, abortSignal): AsyncIterable<StreamChunk> {
2297
+ const target = withSearchParams(typeof url === 'function' ? url() : url, {
2298
+ offset: '-1',
2299
+ runId,
2300
+ })
2301
+ // A replay handshake must reach the server as its own connection:
2302
+ // reusing the conversation socket would discard the ?offset query (no
2303
+ // replay ever requested), and the conversation socket must not be
2304
+ // replaced by a read-only replay socket. So joinRun owns a dedicated
2305
+ // socket and never touches the shared socket or run session.
2306
+ const ws = options.protocols
2307
+ ? new Impl(target, options.protocols)
2308
+ : new Impl(target)
2309
+ const pipe = createChunkPipe(abortSignal, () => {
2310
+ if (ws.readyState <= 1) ws.close()
2311
+ })
2312
+ ws.onmessage = (event: MessageEvent) => {
2313
+ let parsed: unknown
2314
+ try {
2315
+ parsed = JSON.parse(String(event.data))
2316
+ } catch (error) {
2317
+ pipe.fail(new StreamReadError(error))
2318
+ return
2319
+ }
2320
+ if (isPingFrame(parsed)) return
2321
+ pipe.push(
2322
+ isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2323
+ )
2324
+ }
2325
+ ws.onclose = (event?: CloseEvent) => {
2326
+ // 1000 = the server finished replaying the log and closed cleanly.
2327
+ // Anything else is a drop or a policy refusal (e.g. 1008 "no resume
2328
+ // offset") and must surface — a joinRun socket never auto-reconnects.
2329
+ if (event?.code === 1000) {
2330
+ pipe.end()
2331
+ return
2332
+ }
2333
+ const detail = event
2334
+ ? `${event.code}${event.reason ? `: ${event.reason}` : ''}`
2335
+ : 'unknown'
2336
+ pipe.fail(
2337
+ new StreamReadError(
2338
+ new Error(`WebSocket connection closed (${detail})`),
2339
+ ),
2340
+ )
2341
+ }
2342
+ return pipe.iterable
2343
+ },
2344
+ }
2345
+ }
2346
+
1901
2347
  /**
1902
2348
  * Optional persistence handlers for the lightweight adapters (`stream()`,
1903
2349
  * `rpcStream()`). These are one-shot, request-scoped calls with no built-in
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.