@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.
- package/README.md +10 -1
- package/dist/esm/chat-client.d.ts +17 -13
- package/dist/esm/chat-client.js +49 -18
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +49 -0
- package/dist/esm/connection-adapters.js +269 -29
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/devtools.d.ts +0 -1
- package/dist/esm/devtools.js +4 -10
- package/dist/esm/devtools.js.map +1 -1
- package/dist/esm/generation-client.d.ts +5 -11
- package/dist/esm/generation-client.js +18 -5
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-types.d.ts +15 -45
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +2 -2
- package/dist/esm/interrupt-manager.d.ts +15 -10
- package/dist/esm/interrupt-manager.js +176 -83
- package/dist/esm/interrupt-manager.js.map +1 -1
- package/dist/esm/types.d.ts +66 -48
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +5 -11
- package/dist/esm/video-generation-client.js +18 -5
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +3 -3
- package/src/chat-client.ts +148 -53
- package/src/connection-adapters.ts +505 -47
- package/src/devtools.ts +6 -22
- package/src/generation-client.ts +36 -24
- package/src/generation-types.ts +15 -47
- package/src/index.ts +6 -0
- package/src/interrupt-manager.ts +386 -145
- package/src/types.ts +133 -50
- package/src/video-generation-client.ts +33 -20
|
@@ -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?: {
|
|
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
|
-
|
|
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
|
|
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
|
|
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: {
|
|
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.
|