@tanstack/ai-client 0.22.1 → 0.23.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 +15 -1
- package/dist/esm/audio-recorder.js +190 -213
- package/dist/esm/audio-recorder.js.map +1 -1
- package/dist/esm/chat-client.d.ts +172 -3
- package/dist/esm/chat-client.js +1656 -1386
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/cleared-stream-tracker.d.ts +23 -0
- package/dist/esm/cleared-stream-tracker.js +97 -0
- package/dist/esm/cleared-stream-tracker.js.map +1 -0
- package/dist/esm/client-persistor.d.ts +25 -12
- package/dist/esm/client-persistor.js +260 -235
- package/dist/esm/client-persistor.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +231 -10
- package/dist/esm/connection-adapters.js +989 -574
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/devtools-noop.d.ts +1 -0
- package/dist/esm/devtools-noop.js +79 -139
- package/dist/esm/devtools-noop.js.map +1 -1
- package/dist/esm/devtools.d.ts +31 -1
- package/dist/esm/devtools.js +977 -1127
- package/dist/esm/devtools.js.map +1 -1
- package/dist/esm/events.js +224 -226
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/generation-client.d.ts +145 -2
- package/dist/esm/generation-client.js +659 -321
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-reconstruct.d.ts +21 -0
- package/dist/esm/generation-reconstruct.js +85 -0
- package/dist/esm/generation-reconstruct.js.map +1 -0
- package/dist/esm/generation-types.d.ts +289 -3
- package/dist/esm/generation-types.js +356 -13
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/index.d.ts +9 -4
- package/dist/esm/index.js +7 -39
- package/dist/esm/interrupt-manager.d.ts +77 -0
- package/dist/esm/interrupt-manager.js +787 -0
- package/dist/esm/interrupt-manager.js.map +1 -0
- package/dist/esm/mcp-app-bridge.js +56 -64
- package/dist/esm/mcp-app-bridge.js.map +1 -1
- package/dist/esm/realtime-client.js +366 -440
- package/dist/esm/realtime-client.js.map +1 -1
- package/dist/esm/response-stream.js +19 -26
- package/dist/esm/response-stream.js.map +1 -1
- package/dist/esm/sse-parser.js +44 -47
- package/dist/esm/sse-parser.js.map +1 -1
- package/dist/esm/sse-utils.js +8 -9
- package/dist/esm/sse-utils.js.map +1 -1
- package/dist/esm/storage-adapters.d.ts +62 -0
- package/dist/esm/storage-adapters.js +174 -0
- package/dist/esm/storage-adapters.js.map +1 -0
- package/dist/esm/types.d.ts +212 -10
- package/dist/esm/types.js +38 -7
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +113 -2
- package/dist/esm/video-generation-client.js +665 -379
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +7 -7
- package/src/chat-client.ts +1079 -61
- package/src/cleared-stream-tracker.ts +151 -0
- package/src/client-persistor.ts +102 -33
- package/src/connection-adapters.ts +1185 -142
- package/src/devtools-noop.ts +4 -3
- package/src/devtools.ts +121 -3
- package/src/generation-client.ts +563 -13
- package/src/generation-reconstruct.ts +121 -0
- package/src/generation-types.ts +727 -3
- package/src/index.ts +56 -1
- package/src/interrupt-manager.ts +1440 -0
- package/src/storage-adapters.ts +242 -0
- package/src/types.ts +301 -9
- package/src/video-generation-client.ts +479 -13
- package/dist/esm/index.js.map +0 -1
|
@@ -6,12 +6,13 @@ import {
|
|
|
6
6
|
import { parseSseDataLine } from './sse-utils'
|
|
7
7
|
import type {
|
|
8
8
|
ModelMessage,
|
|
9
|
+
RunAgentResumeItem,
|
|
9
10
|
RunErrorEvent,
|
|
10
11
|
RunFinishedEvent,
|
|
11
12
|
StreamChunk,
|
|
12
13
|
UIMessage,
|
|
13
14
|
} from '@tanstack/ai/client'
|
|
14
|
-
import type { ChatFetcher } from './types'
|
|
15
|
+
import type { ChatFetcher, ChatPendingInterrupt } from './types'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Associates connect-wrapped chunks with the run they were produced under.
|
|
@@ -28,9 +29,17 @@ const chunkRunIds = new WeakMap<StreamChunk, string>()
|
|
|
28
29
|
* run the connect wrapper stamped it with.
|
|
29
30
|
*/
|
|
30
31
|
export function getChunkRunId(chunk: StreamChunk): string | undefined {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Prefer the client's request run id (stamped in `chunkRunIds`) over a
|
|
33
|
+
// provider-assigned `chunk.runId`. Interrupt continuation correlation needs
|
|
34
|
+
// the client's run identity to win when a provider stamps its own id; for
|
|
35
|
+
// resumable reconnect/join the two ids match, so precedence is moot there.
|
|
36
|
+
const requestRunId = chunkRunIds.get(chunk)
|
|
37
|
+
return (
|
|
38
|
+
requestRunId ??
|
|
39
|
+
('runId' in chunk && typeof chunk.runId === 'string'
|
|
40
|
+
? chunk.runId
|
|
41
|
+
: undefined)
|
|
42
|
+
)
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
/**
|
|
@@ -47,6 +56,103 @@ export class StreamTruncatedError extends Error {
|
|
|
47
56
|
}
|
|
48
57
|
}
|
|
49
58
|
|
|
59
|
+
class StreamReadError extends Error {
|
|
60
|
+
constructor(cause: unknown) {
|
|
61
|
+
super('Stream response body read failed', { cause })
|
|
62
|
+
this.name = 'StreamReadError'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Thrown when a durable (id-tagged) run's stream ends with no terminal event
|
|
68
|
+
* and a reconnect makes no forward progress — the run cannot complete, so the
|
|
69
|
+
* consumer must not be left silently hanging on a stream that just stops.
|
|
70
|
+
*/
|
|
71
|
+
export class DurableStreamIncompleteError extends Error {
|
|
72
|
+
constructor() {
|
|
73
|
+
super(
|
|
74
|
+
'Durable run ended without a terminal event and could not resume — the run did not complete.',
|
|
75
|
+
)
|
|
76
|
+
this.name = 'DurableStreamIncompleteError'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Thrown when a durable run exceeds its reconnect ceiling. Bounds the
|
|
82
|
+
* otherwise-unbounded reconnect loop so a flapping producer (or a proxy that
|
|
83
|
+
* rolls the socket after every event) surfaces a failure instead of
|
|
84
|
+
* reconnecting without end.
|
|
85
|
+
*/
|
|
86
|
+
export class StreamReconnectLimitError extends Error {
|
|
87
|
+
constructor(attempts: number) {
|
|
88
|
+
super(
|
|
89
|
+
`Durable run exceeded its reconnect ceiling of ${attempts} attempts — giving up.`,
|
|
90
|
+
)
|
|
91
|
+
this.name = 'StreamReconnectLimitError'
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reconnect bounding for resumable streams. A constant throttle delay prevents a
|
|
97
|
+
* hot loop against the origin, and the ceiling bounds a pathologically failing
|
|
98
|
+
* run — but only counts CONSECUTIVE reconnects that made no forward progress.
|
|
99
|
+
*/
|
|
100
|
+
export interface ReconnectOptions {
|
|
101
|
+
/**
|
|
102
|
+
* Ceiling on the number of CONSECUTIVE reconnects that deliver no new events,
|
|
103
|
+
* before failing with {@link StreamReconnectLimitError}. The counter resets to
|
|
104
|
+
* zero whenever a reconnect makes forward progress, so a healthy long run —
|
|
105
|
+
* even one behind a proxy that rolls the socket after every event — never
|
|
106
|
+
* approaches it; the ceiling only fires when the run is genuinely stuck
|
|
107
|
+
* (reconnecting repeatedly without receiving anything new). Default 5.
|
|
108
|
+
*/
|
|
109
|
+
maxAttempts?: number
|
|
110
|
+
/** Delay between reconnect attempts, in ms, to avoid hammering. Default 250. */
|
|
111
|
+
delayMs?: number
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface ResolvedReconnectOptions {
|
|
115
|
+
maxAttempts: number
|
|
116
|
+
delayMs: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveReconnectOptions(
|
|
120
|
+
options: ReconnectOptions | undefined,
|
|
121
|
+
): ResolvedReconnectOptions {
|
|
122
|
+
const maxAttempts = options?.maxAttempts ?? 5
|
|
123
|
+
const delayMs = options?.delayMs ?? 250
|
|
124
|
+
// Reject non-finite / negative bounds up front: a NaN or Infinity maxAttempts
|
|
125
|
+
// would make the ceiling ineffective (unbounded reconnects), and a non-finite
|
|
126
|
+
// delayMs would remove throttling. Fail loudly on misconfiguration.
|
|
127
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 0) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Invalid reconnect.maxAttempts: ${maxAttempts}. Must be a non-negative integer.`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Invalid reconnect.delayMs: ${delayMs}. Must be a non-negative finite number.`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
return { maxAttempts, delayMs }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Resolve after `ms`, or immediately once `signal` aborts. Never rejects. */
|
|
141
|
+
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
142
|
+
if (ms <= 0 || signal?.aborted) return Promise.resolve()
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
const onAbort = () => {
|
|
145
|
+
clearTimeout(timer)
|
|
146
|
+
resolve()
|
|
147
|
+
}
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
signal?.removeEventListener('abort', onAbort)
|
|
150
|
+
resolve()
|
|
151
|
+
}, ms)
|
|
152
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
50
156
|
function generateRunId(prefix: string): string {
|
|
51
157
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
52
158
|
}
|
|
@@ -88,6 +194,36 @@ function mergeHeaders(
|
|
|
88
194
|
return customHeaders
|
|
89
195
|
}
|
|
90
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Request header carrying the client-chosen run id to a delivery-durability
|
|
199
|
+
* sink. The durable log is then keyed by the SAME id the client already holds,
|
|
200
|
+
* so a later join/resume can address the run without first reading back a
|
|
201
|
+
* server-generated id. Sent as a header — NOT a query param — so the POST URL
|
|
202
|
+
* stays byte-identical to a plain, non-durable request; a server that isn't
|
|
203
|
+
* durable simply ignores the header. (The GET join path keeps `?runId` in the
|
|
204
|
+
* query, since a GET has no body/handler contract to disturb.)
|
|
205
|
+
*/
|
|
206
|
+
const RUN_ID_HEADER = 'X-Run-Id'
|
|
207
|
+
|
|
208
|
+
function runIdHeader(runId: string | undefined): Record<string, string> {
|
|
209
|
+
return runId === undefined ? {} : { [RUN_ID_HEADER]: runId }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function withSearchParams(url: string, values: Record<string, string>): string {
|
|
213
|
+
const hashIndex = url.indexOf('#')
|
|
214
|
+
const hash = hashIndex === -1 ? '' : url.slice(hashIndex)
|
|
215
|
+
const withoutHash = hashIndex === -1 ? url : url.slice(0, hashIndex)
|
|
216
|
+
const queryIndex = withoutHash.indexOf('?')
|
|
217
|
+
const base =
|
|
218
|
+
queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex)
|
|
219
|
+
const search = new URLSearchParams(
|
|
220
|
+
queryIndex === -1 ? '' : withoutHash.slice(queryIndex + 1),
|
|
221
|
+
)
|
|
222
|
+
for (const [key, value] of Object.entries(values)) search.set(key, value)
|
|
223
|
+
const query = search.toString()
|
|
224
|
+
return `${base}${query.length === 0 ? '' : `?${query}`}${hash}`
|
|
225
|
+
}
|
|
226
|
+
|
|
91
227
|
/**
|
|
92
228
|
* Read lines from a stream (newline-delimited)
|
|
93
229
|
*/
|
|
@@ -100,7 +236,14 @@ async function* readStreamLines(
|
|
|
100
236
|
let buffer = ''
|
|
101
237
|
|
|
102
238
|
while (!abortSignal?.aborted) {
|
|
103
|
-
|
|
239
|
+
let result: ReadableStreamReadResult<Uint8Array>
|
|
240
|
+
try {
|
|
241
|
+
result = await reader.read()
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (abortSignal?.aborted) return
|
|
244
|
+
throw new StreamReadError(error)
|
|
245
|
+
}
|
|
246
|
+
const { done, value } = result
|
|
104
247
|
if (done) break
|
|
105
248
|
|
|
106
249
|
buffer += decoder.decode(value, { stream: true })
|
|
@@ -110,12 +253,22 @@ async function* readStreamLines(
|
|
|
110
253
|
buffer = lines.pop() || ''
|
|
111
254
|
|
|
112
255
|
for (const line of lines) {
|
|
113
|
-
|
|
114
|
-
|
|
256
|
+
// Strip a trailing CR so a CRLF stream matches the LF path (and the
|
|
257
|
+
// XHR reader). Without this an exact-equality check like the `[DONE]`
|
|
258
|
+
// sentinel in linesToSSEEvents would miss `data: [DONE]\r`.
|
|
259
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line
|
|
260
|
+
if (normalized.trim()) {
|
|
261
|
+
yield normalized
|
|
115
262
|
}
|
|
116
263
|
}
|
|
117
264
|
}
|
|
118
265
|
|
|
266
|
+
// Flush the decoder: a connection cut mid-multibyte-character leaves bytes
|
|
267
|
+
// held inside the streaming TextDecoder. Draining them here (as U+FFFD)
|
|
268
|
+
// makes the trailing-buffer check below see the incomplete tail and report
|
|
269
|
+
// truncation instead of silently swallowing it.
|
|
270
|
+
buffer += decoder.decode()
|
|
271
|
+
|
|
119
272
|
// A non-empty trailing buffer means the connection was cut mid-line.
|
|
120
273
|
// Surface this as an error so the chat client transitions to 'error'
|
|
121
274
|
// state instead of silently presenting a partial stream as success.
|
|
@@ -129,36 +282,71 @@ async function* readStreamLines(
|
|
|
129
282
|
}
|
|
130
283
|
}
|
|
131
284
|
|
|
285
|
+
/** A parsed stream chunk paired with its adapter-owned delivery offset (if any). */
|
|
286
|
+
interface StreamEvent {
|
|
287
|
+
chunk: StreamChunk
|
|
288
|
+
id?: string
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Type guard for a durable NDJSON envelope `{ id, chunk }`. NDJSON has no
|
|
293
|
+
* native event-id field, so durability rides the offset inside the payload.
|
|
294
|
+
* A bare `StreamChunk` always has a top-level `type`, and the envelope never
|
|
295
|
+
* does, so the two forms are unambiguous — a non-durable line stays bare.
|
|
296
|
+
*/
|
|
297
|
+
function isNdjsonEnvelope(
|
|
298
|
+
value: unknown,
|
|
299
|
+
): value is { id: string; chunk: StreamChunk } {
|
|
300
|
+
return (
|
|
301
|
+
typeof value === 'object' &&
|
|
302
|
+
value !== null &&
|
|
303
|
+
'chunk' in value &&
|
|
304
|
+
'id' in value &&
|
|
305
|
+
typeof (value as { id: unknown }).id === 'string' &&
|
|
306
|
+
!('type' in value)
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
132
310
|
/**
|
|
133
|
-
*
|
|
311
|
+
* Parse SSE-format lines into stream events, pairing each chunk with the `id:`
|
|
312
|
+
* offset of the event it arrived on. Shared by the fetch- and XHR-backed SSE
|
|
313
|
+
* adapters so both track delivery offsets identically.
|
|
134
314
|
*
|
|
135
315
|
* Accepts either `data: {...}` lines or bare JSON lines. Skips comments
|
|
136
316
|
* starting with `:` (proxies and CDNs inject these as keepalives) and the
|
|
137
|
-
* `event:` / `
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* a clean terminal event with real correlation ids.
|
|
317
|
+
* `event:` / `retry:` SSE control fields. A `[DONE]` sentinel is treated as a
|
|
318
|
+
* terminal event: a synthesized RUN_FINISHED is yielded using the most recent
|
|
319
|
+
* upstream `threadId` / `runId` (falling back to `fallbackIds`), so the
|
|
320
|
+
* consumer sees a clean terminal event with real correlation ids.
|
|
141
321
|
*
|
|
142
322
|
* A JSON parse failure throws — the consumer surfaces it as an error.
|
|
143
323
|
*/
|
|
144
|
-
async function*
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
): AsyncGenerator<
|
|
148
|
-
if (!response.ok) {
|
|
149
|
-
throw new Error(
|
|
150
|
-
`HTTP error! status: ${response.status} ${response.statusText}`,
|
|
151
|
-
)
|
|
152
|
-
}
|
|
153
|
-
const reader = getResponseStreamReader(response)
|
|
324
|
+
async function* linesToSSEEvents(
|
|
325
|
+
lines: AsyncIterable<string>,
|
|
326
|
+
fallbackIds?: { threadId?: string; runId?: string },
|
|
327
|
+
): AsyncGenerator<StreamEvent> {
|
|
154
328
|
let lastThreadId: string | undefined
|
|
155
329
|
let lastRunId: string | undefined
|
|
156
330
|
let lastModel: string | undefined
|
|
157
|
-
|
|
331
|
+
let pendingId: string | undefined
|
|
332
|
+
for await (const line of lines) {
|
|
333
|
+
if (line === 'id' || line.startsWith('id:')) {
|
|
334
|
+
// SSE spec: strip a single leading space after the colon, preserve the
|
|
335
|
+
// rest verbatim so an opaque adapter offset round-trips exactly (do NOT
|
|
336
|
+
// trim, which would mangle a legitimate offset). An empty value is kept as
|
|
337
|
+
// '' and resets the resume cursor downstream (see resumableStream).
|
|
338
|
+
const rawId = line === 'id' ? '' : line.slice(3)
|
|
339
|
+
pendingId = rawId.startsWith(' ') ? rawId.slice(1) : rawId
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
// Assumes the durability wire emits one `id:` immediately followed by one
|
|
343
|
+
// `data:` per event (both shipped sinks do). `pendingId` attaches to the
|
|
344
|
+
// next data line and is cleared after it; blank-line event boundaries are
|
|
345
|
+
// stripped upstream, so a hand-rolled server that emits an id-only event or
|
|
346
|
+
// a persistent `id:` across events is not supported here.
|
|
158
347
|
if (
|
|
159
348
|
line.startsWith(':') ||
|
|
160
349
|
line.startsWith('event:') ||
|
|
161
|
-
line.startsWith('id:') ||
|
|
162
350
|
line.startsWith('retry:')
|
|
163
351
|
) {
|
|
164
352
|
continue
|
|
@@ -167,13 +355,13 @@ async function* responseToSSEChunks(
|
|
|
167
355
|
if (data === '[DONE]') {
|
|
168
356
|
const synthetic: RunFinishedEvent = {
|
|
169
357
|
type: EventType.RUN_FINISHED,
|
|
170
|
-
threadId: lastThreadId ?? '',
|
|
171
|
-
runId: lastRunId ?? '',
|
|
358
|
+
threadId: lastThreadId ?? fallbackIds?.threadId ?? '',
|
|
359
|
+
runId: lastRunId ?? fallbackIds?.runId ?? '',
|
|
172
360
|
model: lastModel ?? '',
|
|
173
361
|
timestamp: Date.now(),
|
|
174
362
|
finishReason: 'stop',
|
|
175
363
|
}
|
|
176
|
-
yield synthetic
|
|
364
|
+
yield { chunk: synthetic }
|
|
177
365
|
return
|
|
178
366
|
}
|
|
179
367
|
const chunk = JSON.parse(data) as StreamChunk
|
|
@@ -186,10 +374,356 @@ async function* responseToSSEChunks(
|
|
|
186
374
|
if ('model' in chunk && typeof chunk.model === 'string') {
|
|
187
375
|
lastModel = chunk.model
|
|
188
376
|
}
|
|
377
|
+
const id = pendingId
|
|
378
|
+
pendingId = undefined
|
|
379
|
+
yield { chunk, ...(id !== undefined ? { id } : {}) }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Parse NDJSON-format lines into stream events. Durable streams emit each line
|
|
385
|
+
* as an `{ id, chunk }` envelope carrying the delivery offset; non-durable
|
|
386
|
+
* streams emit bare chunks. Both are auto-detected (see {@link isNdjsonEnvelope}),
|
|
387
|
+
* so an untagged stream behaves exactly as a plain single fetch used to.
|
|
388
|
+
*/
|
|
389
|
+
async function* linesToNdjsonEvents(
|
|
390
|
+
lines: AsyncIterable<string>,
|
|
391
|
+
): AsyncGenerator<StreamEvent> {
|
|
392
|
+
for await (const line of lines) {
|
|
393
|
+
const parsed = JSON.parse(line) as unknown
|
|
394
|
+
if (isNdjsonEnvelope(parsed)) {
|
|
395
|
+
yield { chunk: parsed.chunk, id: parsed.id }
|
|
396
|
+
} else {
|
|
397
|
+
yield { chunk: parsed as StreamChunk }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function assertResponseOk(response: Response): void {
|
|
403
|
+
if (!response.ok) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`HTTP error! status: ${response.status} ${response.statusText}`,
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* GET the hydration endpoint for a thread and parse its JSON `{ messages,
|
|
412
|
+
* activeRun }` body. This is the transport-agnostic reconnect probe: keyed on
|
|
413
|
+
* the STABLE thread id, it returns the stored transcript and — if a run is still
|
|
414
|
+
* generating — a cursor the caller tails via `joinRun`. Shared by every fetch/
|
|
415
|
+
* XHR adapter so the client never has to know which transport is in use.
|
|
416
|
+
*/
|
|
417
|
+
async function fetchThreadHydration(
|
|
418
|
+
fetchClient: typeof globalThis.fetch,
|
|
419
|
+
url: string,
|
|
420
|
+
headers: Record<string, string>,
|
|
421
|
+
credentials: RequestCredentials,
|
|
422
|
+
threadId: string,
|
|
423
|
+
): Promise<ChatHydrationResult> {
|
|
424
|
+
const response = await fetchClient(withSearchParams(url, { threadId }), {
|
|
425
|
+
method: 'GET',
|
|
426
|
+
headers: { Accept: 'application/json', ...headers },
|
|
427
|
+
credentials,
|
|
428
|
+
})
|
|
429
|
+
assertResponseOk(response)
|
|
430
|
+
const data = (await response.json()) as {
|
|
431
|
+
messages?: Array<UIMessage>
|
|
432
|
+
activeRun?: { runId?: unknown } | null
|
|
433
|
+
interrupts?: { runId?: unknown; pending?: unknown } | null
|
|
434
|
+
}
|
|
435
|
+
const activeRun =
|
|
436
|
+
data.activeRun && typeof data.activeRun.runId === 'string'
|
|
437
|
+
? { runId: data.activeRun.runId }
|
|
438
|
+
: null
|
|
439
|
+
const interrupts =
|
|
440
|
+
data.interrupts &&
|
|
441
|
+
typeof data.interrupts.runId === 'string' &&
|
|
442
|
+
Array.isArray(data.interrupts.pending) &&
|
|
443
|
+
data.interrupts.pending.length > 0
|
|
444
|
+
? {
|
|
445
|
+
runId: data.interrupts.runId,
|
|
446
|
+
pending: data.interrupts.pending as Array<ChatPendingInterrupt>,
|
|
447
|
+
}
|
|
448
|
+
: null
|
|
449
|
+
return {
|
|
450
|
+
messages: Array.isArray(data.messages) ? data.messages : [],
|
|
451
|
+
activeRun,
|
|
452
|
+
interrupts,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* GET the hydration endpoint for a generation thread and parse its JSON
|
|
458
|
+
* `{ resumeSnapshot, activeRun }` body. Mirrors {@link fetchThreadHydration} for
|
|
459
|
+
* the generation clients: keyed on the stable thread id, it returns the last
|
|
460
|
+
* generation's resume snapshot (re-validated client-side before adoption) and —
|
|
461
|
+
* if a run is still generating — a cursor. Shared by every fetch/XHR adapter.
|
|
462
|
+
*/
|
|
463
|
+
async function fetchGenerationHydration(
|
|
464
|
+
fetchClient: typeof globalThis.fetch,
|
|
465
|
+
url: string,
|
|
466
|
+
headers: Record<string, string>,
|
|
467
|
+
credentials: RequestCredentials,
|
|
468
|
+
threadId: string,
|
|
469
|
+
): Promise<GenerationHydrationResult> {
|
|
470
|
+
const response = await fetchClient(withSearchParams(url, { threadId }), {
|
|
471
|
+
method: 'GET',
|
|
472
|
+
headers: { Accept: 'application/json', ...headers },
|
|
473
|
+
credentials,
|
|
474
|
+
})
|
|
475
|
+
assertResponseOk(response)
|
|
476
|
+
const raw: unknown = await response.json()
|
|
477
|
+
// A 200 carrying `null` is a legitimate hydration miss — the server has no
|
|
478
|
+
// record for this thread — and reading `.activeRun` off `null` would throw.
|
|
479
|
+
if (raw === null) {
|
|
480
|
+
return { resumeSnapshot: null, activeRun: null }
|
|
481
|
+
}
|
|
482
|
+
// Any OTHER non-object body is a broken endpoint, not an empty thread.
|
|
483
|
+
// Reporting it as a miss would present a misconfigured route as a fresh
|
|
484
|
+
// thread; the client surfaces this through its own error channel instead.
|
|
485
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`Generation hydration expected a JSON object from ${url}, received ${Array.isArray(raw) ? 'an array' : typeof raw}.`,
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
const data = raw as {
|
|
491
|
+
resumeSnapshot?: GenerationHydrationResult['resumeSnapshot']
|
|
492
|
+
activeRun?: { runId?: unknown } | null
|
|
493
|
+
}
|
|
494
|
+
const activeRun =
|
|
495
|
+
data.activeRun && typeof data.activeRun.runId === 'string'
|
|
496
|
+
? { runId: data.activeRun.runId }
|
|
497
|
+
: null
|
|
498
|
+
return {
|
|
499
|
+
resumeSnapshot: data.resumeSnapshot ?? null,
|
|
500
|
+
activeRun,
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Yield SSE stream events (chunk + offset) from a fetch Response body. */
|
|
505
|
+
async function* responseToSSEEvents(
|
|
506
|
+
response: Response,
|
|
507
|
+
abortSignal?: AbortSignal,
|
|
508
|
+
fallbackIds?: { threadId?: string; runId?: string },
|
|
509
|
+
): AsyncGenerator<StreamEvent> {
|
|
510
|
+
assertResponseOk(response)
|
|
511
|
+
const reader = getResponseStreamReader(response)
|
|
512
|
+
yield* linesToSSEEvents(readStreamLines(reader, abortSignal), fallbackIds)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Yield NDJSON stream events (chunk + offset) from a fetch Response body. */
|
|
516
|
+
async function* responseToNdjsonEvents(
|
|
517
|
+
response: Response,
|
|
518
|
+
abortSignal?: AbortSignal,
|
|
519
|
+
): AsyncGenerator<StreamEvent> {
|
|
520
|
+
assertResponseOk(response)
|
|
521
|
+
const reader = getResponseStreamReader(response)
|
|
522
|
+
yield* linesToNdjsonEvents(readStreamLines(reader, abortSignal))
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function* responseToSSEChunks(
|
|
526
|
+
response: Response,
|
|
527
|
+
abortSignal?: AbortSignal,
|
|
528
|
+
): AsyncGenerator<StreamChunk> {
|
|
529
|
+
for await (const { chunk } of responseToSSEEvents(response, abortSignal)) {
|
|
189
530
|
yield chunk
|
|
190
531
|
}
|
|
191
532
|
}
|
|
192
533
|
|
|
534
|
+
/**
|
|
535
|
+
* A re-issuable event source. Given extra headers (a `Last-Event-ID` on a
|
|
536
|
+
* reconnect) and an abort signal, it opens the transport and yields stream
|
|
537
|
+
* events. {@link resumableStream} calls it once per attempt, so each call MUST
|
|
538
|
+
* open a fresh underlying request (a new fetch or a new XHR).
|
|
539
|
+
*/
|
|
540
|
+
type StreamEventSource = (
|
|
541
|
+
extraHeaders: Record<string, string>,
|
|
542
|
+
abortSignal?: AbortSignal,
|
|
543
|
+
) => AsyncIterable<StreamEvent>
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Build a fetch-backed {@link StreamEventSource}. `parseResponse` decodes the
|
|
547
|
+
* body into events (SSE or NDJSON) — the reconnect engine is identical for both.
|
|
548
|
+
*/
|
|
549
|
+
function fetchEventSource(
|
|
550
|
+
fetchClient: typeof globalThis.fetch,
|
|
551
|
+
url: string,
|
|
552
|
+
requestInit: RequestInit,
|
|
553
|
+
parseResponse: (
|
|
554
|
+
response: Response,
|
|
555
|
+
abortSignal?: AbortSignal,
|
|
556
|
+
) => AsyncIterable<StreamEvent>,
|
|
557
|
+
): StreamEventSource {
|
|
558
|
+
return async function* (extraHeaders, abortSignal) {
|
|
559
|
+
let response: Response
|
|
560
|
+
try {
|
|
561
|
+
response = await fetchClient(url, {
|
|
562
|
+
...requestInit,
|
|
563
|
+
headers: {
|
|
564
|
+
...(requestInit.headers as Record<string, string> | undefined),
|
|
565
|
+
...extraHeaders,
|
|
566
|
+
},
|
|
567
|
+
...(abortSignal ? { signal: abortSignal } : {}),
|
|
568
|
+
})
|
|
569
|
+
} catch (error) {
|
|
570
|
+
// A fetch REJECTION (device offline, DNS blip, connection refused) is a
|
|
571
|
+
// recoverable transport failure, not a fatal one — surface it as
|
|
572
|
+
// StreamReadError so resumableStream retries from the last offset, mirroring
|
|
573
|
+
// the XHR path (whose onerror wraps the same way). On a genuine abort this
|
|
574
|
+
// wraps the AbortError too, but that's harmless: resumableStream checks
|
|
575
|
+
// `abortSignal.aborted` first and returns, so the wrapped error's type is
|
|
576
|
+
// never inspected. Without an offset (initial connect / non-durable), it
|
|
577
|
+
// still surfaces as a hard failure.
|
|
578
|
+
throw new StreamReadError(error)
|
|
579
|
+
}
|
|
580
|
+
yield* parseResponse(response, abortSignal)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Drive a {@link StreamEventSource} with native-style resumability. Each event's
|
|
586
|
+
* adapter-owned delivery offset (its `id`) is remembered; if the connection
|
|
587
|
+
* drops or ends before a terminal event, the source is re-opened with a
|
|
588
|
+
* `Last-Event-ID` header so the server replays strictly after the last offset.
|
|
589
|
+
* Already-seen offsets are de-duped, so an overlapping replay is safe.
|
|
590
|
+
*
|
|
591
|
+
* When the server does NOT tag events (no durability), no offset is ever seen,
|
|
592
|
+
* so no reconnect happens — behaviour is identical to a plain single request.
|
|
593
|
+
* This engine is transport-agnostic: fetch/XHR × SSE/NDJSON all share it, the
|
|
594
|
+
* only difference being the {@link StreamEventSource} they pass in.
|
|
595
|
+
*/
|
|
596
|
+
async function* resumableStream(
|
|
597
|
+
openEventSource: StreamEventSource,
|
|
598
|
+
abortSignal?: AbortSignal,
|
|
599
|
+
reconnectOptions?: ReconnectOptions,
|
|
600
|
+
): 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
|
+
}
|
|
629
|
+
|
|
630
|
+
for (;;) {
|
|
631
|
+
if (abortSignal?.aborted) return
|
|
632
|
+
const extraHeaders: Record<string, string> =
|
|
633
|
+
lastEventId !== undefined ? { 'Last-Event-ID': lastEventId } : {}
|
|
634
|
+
|
|
635
|
+
let sawTerminal = false
|
|
636
|
+
let progressed = false
|
|
637
|
+
try {
|
|
638
|
+
for await (const { chunk, id } of openEventSource(
|
|
639
|
+
extraHeaders,
|
|
640
|
+
abortSignal,
|
|
641
|
+
)) {
|
|
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
|
+
}
|
|
654
|
+
progressed = true
|
|
655
|
+
if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
|
|
656
|
+
sawTerminal = true
|
|
657
|
+
}
|
|
658
|
+
yield chunk
|
|
659
|
+
// Do NOT stop on a terminal mid-source: an agent loop emits one
|
|
660
|
+
// RUN_STARTED/RUN_FINISHED pair PER turn, so a tool-calling run carries
|
|
661
|
+
// several RUN_FINISHED events before the run is truly done. Returning on
|
|
662
|
+
// the first one would drop every subsequent turn (the tool result and
|
|
663
|
+
// the final answer). Instead, drain the event source to its natural end
|
|
664
|
+
// — the server closes the response only when the run is actually
|
|
665
|
+
// complete — and use `sawTerminal` below to decide done-vs-reconnect.
|
|
666
|
+
}
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (abortSignal?.aborted) return
|
|
669
|
+
// A transport drop is resumable once we hold an offset — retry from it,
|
|
670
|
+
// even if THIS attempt made no new progress. A caught-up run whose parked
|
|
671
|
+
// long-poll socket drops (or a proxy that drops just after replaying the
|
|
672
|
+
// de-duped overlap) is transient, not fatal; the consecutive-no-progress
|
|
673
|
+
// ceiling in waitBeforeReconnect already bounds a genuinely stuck flapper,
|
|
674
|
+
// so a per-attempt progress requirement here would only convert
|
|
675
|
+
// recoverable drops into hard failures on flaky (mobile/edge) networks.
|
|
676
|
+
// Without an offset (a non-durable stream), surface the failure.
|
|
677
|
+
if (
|
|
678
|
+
(error instanceof StreamTruncatedError ||
|
|
679
|
+
error instanceof StreamReadError) &&
|
|
680
|
+
lastEventId !== undefined
|
|
681
|
+
) {
|
|
682
|
+
await waitBeforeReconnect(progressed)
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
throw error
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (abortSignal?.aborted) return
|
|
689
|
+
|
|
690
|
+
// The source ended after delivering a terminal event: the run is genuinely
|
|
691
|
+
// finished (for an agentic run this is the LAST turn's terminal, since we no
|
|
692
|
+
// longer stop on intermediate ones). Stop — reconnecting a durable run here
|
|
693
|
+
// would re-open past the final offset and see an empty window.
|
|
694
|
+
if (sawTerminal) return
|
|
695
|
+
|
|
696
|
+
if (lastEventId !== undefined) {
|
|
697
|
+
// A durable (id-tagged) run.
|
|
698
|
+
if (progressed) {
|
|
699
|
+
// Clean end WITHOUT a terminal event but we advanced — the producer is
|
|
700
|
+
// still going (or the socket rolled over). Reconnect from the last
|
|
701
|
+
// offset (backing off to avoid a hot loop against the origin). Progress
|
|
702
|
+
// resets the no-progress ceiling.
|
|
703
|
+
await waitBeforeReconnect(true)
|
|
704
|
+
continue
|
|
705
|
+
}
|
|
706
|
+
// Ended without a terminal event AND made no forward progress on this
|
|
707
|
+
// pass: the run cannot complete. Surface an error rather than returning
|
|
708
|
+
// silently, which would leave the consumer with neither a terminal event
|
|
709
|
+
// nor a failure.
|
|
710
|
+
//
|
|
711
|
+
// Invariant this relies on: a durable transport must never surface an
|
|
712
|
+
// empty long-poll window as a CLEAN end while the producer is still
|
|
713
|
+
// alive. Both shipped backends honor it — memoryStream parks until data
|
|
714
|
+
// or completion, and durableStream keeps one continuous response across
|
|
715
|
+
// windows — so this fires only on a genuinely complete-but-unterminated
|
|
716
|
+
// log. A custom StreamDurability transport that ends a response empty
|
|
717
|
+
// mid-run would trip this; keep the response open until data or terminal.
|
|
718
|
+
throw new DurableStreamIncompleteError()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// A non-durable (untagged) stream that ended cleanly. Legitimate — the
|
|
722
|
+
// upper layer synthesizes a terminal event. Stop.
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
193
727
|
/**
|
|
194
728
|
* Per-send context provided by the chat client to the connection adapter.
|
|
195
729
|
* The adapter combines this with serialized messages to build a full
|
|
@@ -199,6 +733,8 @@ export interface RunAgentInputContext {
|
|
|
199
733
|
threadId: string
|
|
200
734
|
runId: string
|
|
201
735
|
parentRunId?: string
|
|
736
|
+
/** AG-UI interrupt resume entries returned to the server on a follow-up run. */
|
|
737
|
+
resume?: Array<RunAgentResumeItem>
|
|
202
738
|
/** Client-declared tools to advertise in the request payload. */
|
|
203
739
|
clientTools?: Array<{
|
|
204
740
|
name: string
|
|
@@ -219,6 +755,106 @@ export interface ConnectConnectionAdapter {
|
|
|
219
755
|
abortSignal?: AbortSignal,
|
|
220
756
|
runContext?: RunAgentInputContext,
|
|
221
757
|
) => AsyncIterable<StreamChunk>
|
|
758
|
+
/**
|
|
759
|
+
* Fetch server-driven hydration for a generation `threadId`: the last
|
|
760
|
+
* generation's resume snapshot, plus a cursor to a run still generating if
|
|
761
|
+
* one exists. The generation client calls this itself on mount when
|
|
762
|
+
* `persistence: true` (no loader/prop) and repaints the snapshot — it never
|
|
763
|
+
* auto-starts a run. Read-only JSON GET (`?threadId`), so it is
|
|
764
|
+
* transport-agnostic. Optional and feature-detected exactly like the chat
|
|
765
|
+
* `hydrate` handler.
|
|
766
|
+
*/
|
|
767
|
+
hydrateGeneration?: (threadId: string) => Promise<GenerationHydrationResult>
|
|
768
|
+
/**
|
|
769
|
+
* Re-attach to a run that is still generating and replay it from the start
|
|
770
|
+
* (read-only `?offset=-1&runId` against the delivery-durability log). The
|
|
771
|
+
* generation client tails this on mount when hydration reports a run still in
|
|
772
|
+
* flight, so a dropped connection or a full reload finishes the generation in
|
|
773
|
+
* place — the same durability replay the chat client uses. Optional and
|
|
774
|
+
* feature-detected; present on `fetchServerSentEvents` / `fetchHttpStream`.
|
|
775
|
+
*/
|
|
776
|
+
joinRun?: (
|
|
777
|
+
runId: string,
|
|
778
|
+
abortSignal?: AbortSignal,
|
|
779
|
+
) => AsyncIterable<StreamChunk>
|
|
780
|
+
/**
|
|
781
|
+
* Fetch server-driven hydration for a chat `threadId`: the stored transcript
|
|
782
|
+
* plus a cursor to an in-flight run and any pending interrupts. The chat
|
|
783
|
+
* client calls this itself on mount when `persistence: true` (no loader/prop)
|
|
784
|
+
* and repaints it — it never auto-sends. Read-only JSON GET (`?threadId`), so
|
|
785
|
+
* it is transport-agnostic. Optional and feature-detected; present on
|
|
786
|
+
* `fetchServerSentEvents` / `fetchHttpStream`, and on `stream()` /
|
|
787
|
+
* `rpcStream()` when supplied via {@link StreamConnectionHandlers}.
|
|
788
|
+
*/
|
|
789
|
+
hydrate?: (threadId: string) => Promise<ChatHydrationResult>
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Server-resolved hydration for a generation thread. `resumeSnapshot` is the
|
|
794
|
+
* last generation's lightweight snapshot (validated client-side before it is
|
|
795
|
+
* adopted); `activeRun` is a cursor to a run still generating for the thread
|
|
796
|
+
* (or `null`).
|
|
797
|
+
*
|
|
798
|
+
* Field-for-field compatible with `@tanstack/ai-persistence`'s
|
|
799
|
+
* `ReconstructedGeneration` (the body `reconstructGeneration` returns) — the
|
|
800
|
+
* client never imports that package, so this is a structural contract, not a
|
|
801
|
+
* shared type. Two deliberate widenings on this side: `schemaVersion` is
|
|
802
|
+
* optional (the server always writes `1`, but a hand-written fixture need not),
|
|
803
|
+
* and `status` also admits `'idle'`, which the server's mapper never emits.
|
|
804
|
+
* Only a client-local snapshot reaches it, when `stop()` retires a cancelled
|
|
805
|
+
* run.
|
|
806
|
+
*/
|
|
807
|
+
export interface GenerationHydrationResult {
|
|
808
|
+
resumeSnapshot: {
|
|
809
|
+
schemaVersion?: 1
|
|
810
|
+
resumeState: { threadId: string; runId: string } | null
|
|
811
|
+
status: 'idle' | 'running' | 'complete' | 'error'
|
|
812
|
+
result?: unknown
|
|
813
|
+
error?: { message: string; code?: string }
|
|
814
|
+
activity?: string
|
|
815
|
+
} | null
|
|
816
|
+
activeRun: { runId: string } | null
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Server-resolved hydration for a thread. `messages` is the stored transcript;
|
|
821
|
+
* `activeRun` is a cursor to a run still generating for the thread (or `null`).
|
|
822
|
+
* Keyed on the STABLE thread id — the client never handles a run id, so a turn
|
|
823
|
+
* that spans several runs (interrupt/tool continuations) reconnects correctly.
|
|
824
|
+
*/
|
|
825
|
+
export interface ChatHydrationResult {
|
|
826
|
+
messages: Array<UIMessage>
|
|
827
|
+
activeRun: { runId: string } | null
|
|
828
|
+
/**
|
|
829
|
+
* Pending human-in-the-loop interrupts for the thread and the run they paused,
|
|
830
|
+
* so a reload (or another device) re-prompts the approval from the server. The
|
|
831
|
+
* client restores them exactly as a persisted resume snapshot would.
|
|
832
|
+
*/
|
|
833
|
+
interrupts: { runId: string; pending: Array<ChatPendingInterrupt> } | null
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* A {@link ConnectConnectionAdapter} that also supports joining an existing run
|
|
838
|
+
* (a second tab, or re-attaching after a full reload) via `joinRun`, replaying
|
|
839
|
+
* the ordered stream from the start off the server's delivery-durability sink.
|
|
840
|
+
*/
|
|
841
|
+
export interface ResumableConnectConnectionAdapter extends ConnectConnectionAdapter {
|
|
842
|
+
/**
|
|
843
|
+
* Join an in-flight or finished run by id, replaying from the start
|
|
844
|
+
* (`?offset=-1`). Read-only — sends no messages.
|
|
845
|
+
*/
|
|
846
|
+
joinRun: (
|
|
847
|
+
runId: string,
|
|
848
|
+
abortSignal?: AbortSignal,
|
|
849
|
+
) => AsyncIterable<StreamChunk>
|
|
850
|
+
/**
|
|
851
|
+
* Fetch server-authoritative hydration for `threadId`: the stored transcript,
|
|
852
|
+
* and a cursor to an in-flight run if one exists. The client calls this itself
|
|
853
|
+
* on mount (no loader/prop), then tails `activeRun` via `joinRun`. Read-only
|
|
854
|
+
* JSON GET (`?threadId`), so it is transport-agnostic regardless of how the
|
|
855
|
+
* delivery stream is served.
|
|
856
|
+
*/
|
|
857
|
+
hydrate?: (threadId: string) => Promise<ChatHydrationResult>
|
|
222
858
|
}
|
|
223
859
|
|
|
224
860
|
export interface SubscribeConnectionAdapter {
|
|
@@ -235,6 +871,22 @@ export interface SubscribeConnectionAdapter {
|
|
|
235
871
|
abortSignal?: AbortSignal,
|
|
236
872
|
runContext?: RunAgentInputContext,
|
|
237
873
|
) => Promise<void>
|
|
874
|
+
/**
|
|
875
|
+
* Re-attach to an existing run by id, replaying its stream from the start off
|
|
876
|
+
* the server's delivery-durability sink. Present only when the underlying
|
|
877
|
+
* connection is resumable (a `ResumableConnectConnectionAdapter`). Used to
|
|
878
|
+
* rejoin an in-flight run after a full page reload.
|
|
879
|
+
*/
|
|
880
|
+
joinRun?: (
|
|
881
|
+
runId: string,
|
|
882
|
+
abortSignal?: AbortSignal,
|
|
883
|
+
) => AsyncIterable<StreamChunk>
|
|
884
|
+
/**
|
|
885
|
+
* Server-authoritative hydration for a thread (transcript + in-flight-run
|
|
886
|
+
* cursor). Present only when the underlying connection supports it. The client
|
|
887
|
+
* calls it on mount to re-hydrate without any app-side loader or prop.
|
|
888
|
+
*/
|
|
889
|
+
hydrate?: (threadId: string) => Promise<ChatHydrationResult>
|
|
238
890
|
}
|
|
239
891
|
|
|
240
892
|
/**
|
|
@@ -269,9 +921,17 @@ export function normalizeConnectionAdapter(
|
|
|
269
921
|
}
|
|
270
922
|
|
|
271
923
|
if (hasSubscribe && hasSend) {
|
|
924
|
+
const joinRun = (connection as SubscribeConnectionAdapter).joinRun?.bind(
|
|
925
|
+
connection,
|
|
926
|
+
)
|
|
927
|
+
const hydrate = (connection as SubscribeConnectionAdapter).hydrate?.bind(
|
|
928
|
+
connection,
|
|
929
|
+
)
|
|
272
930
|
return {
|
|
273
931
|
subscribe: connection.subscribe.bind(connection),
|
|
274
932
|
send: connection.send.bind(connection),
|
|
933
|
+
...(joinRun ? { joinRun } : {}),
|
|
934
|
+
...(hydrate ? { hydrate } : {}),
|
|
275
935
|
}
|
|
276
936
|
}
|
|
277
937
|
|
|
@@ -372,26 +1032,56 @@ export function normalizeConnectionAdapter(
|
|
|
372
1032
|
}
|
|
373
1033
|
} catch (err) {
|
|
374
1034
|
if (!abortSignal?.aborted && !hasTerminalEvent) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
'
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
1035
|
+
// Guard synthesis: requireSyntheticId throws when no id is available,
|
|
1036
|
+
// and that must not replace the original `err` we are about to
|
|
1037
|
+
// rethrow. If we can't synthesize a terminal, the real failure still
|
|
1038
|
+
// surfaces below.
|
|
1039
|
+
try {
|
|
1040
|
+
const message =
|
|
1041
|
+
err instanceof Error ? err.message : 'Unknown error in connect()'
|
|
1042
|
+
const synthetic: RunErrorEvent = {
|
|
1043
|
+
type: EventType.RUN_ERROR,
|
|
1044
|
+
threadId: requireSyntheticId(
|
|
1045
|
+
upstreamThreadId ?? runContext?.threadId,
|
|
1046
|
+
'threadId',
|
|
1047
|
+
),
|
|
1048
|
+
runId: requireSyntheticId(
|
|
1049
|
+
upstreamRunId ?? runContext?.runId,
|
|
1050
|
+
'runId',
|
|
1051
|
+
),
|
|
1052
|
+
timestamp: Date.now(),
|
|
1053
|
+
message,
|
|
1054
|
+
}
|
|
1055
|
+
push(synthetic)
|
|
1056
|
+
} catch {
|
|
1057
|
+
// fall through to rethrow the original error
|
|
389
1058
|
}
|
|
390
|
-
push(synthetic)
|
|
391
1059
|
}
|
|
392
1060
|
throw err
|
|
393
1061
|
}
|
|
394
1062
|
},
|
|
1063
|
+
// Expose joinRun only when the underlying connection is resumable. Require
|
|
1064
|
+
// a real function — `'joinRun' in connection` is true for
|
|
1065
|
+
// `{ joinRun: undefined }`, which would wrap a non-callable and throw on
|
|
1066
|
+
// rehydration rejoin.
|
|
1067
|
+
...(typeof (connection as ResumableConnectConnectionAdapter).joinRun ===
|
|
1068
|
+
'function'
|
|
1069
|
+
? {
|
|
1070
|
+
joinRun: (runId: string, abortSignal?: AbortSignal) =>
|
|
1071
|
+
(connection as ResumableConnectConnectionAdapter).joinRun(
|
|
1072
|
+
runId,
|
|
1073
|
+
abortSignal,
|
|
1074
|
+
),
|
|
1075
|
+
}
|
|
1076
|
+
: {}),
|
|
1077
|
+
...(() => {
|
|
1078
|
+
// Capture under the typeof guard so `hydrate` narrows to the function type
|
|
1079
|
+
// (no non-null assertion). Present only when the connection supports it.
|
|
1080
|
+
const hydrate = (connection as ResumableConnectConnectionAdapter).hydrate
|
|
1081
|
+
return typeof hydrate === 'function'
|
|
1082
|
+
? { hydrate: (threadId: string) => hydrate(threadId) }
|
|
1083
|
+
: {}
|
|
1084
|
+
})(),
|
|
395
1085
|
}
|
|
396
1086
|
}
|
|
397
1087
|
|
|
@@ -404,6 +1094,8 @@ export interface FetchConnectionOptions {
|
|
|
404
1094
|
signal?: AbortSignal
|
|
405
1095
|
body?: Record<string, any>
|
|
406
1096
|
fetchClient?: typeof globalThis.fetch
|
|
1097
|
+
/** Bounding for resumable-SSE reconnection (throttle delay + attempt ceiling). */
|
|
1098
|
+
reconnect?: ReconnectOptions
|
|
407
1099
|
}
|
|
408
1100
|
|
|
409
1101
|
/**
|
|
@@ -415,6 +1107,8 @@ export interface XhrConnectionOptions {
|
|
|
415
1107
|
signal?: AbortSignal
|
|
416
1108
|
body?: Record<string, any>
|
|
417
1109
|
xhrFactory?: () => XMLHttpRequest
|
|
1110
|
+
/** Bounding for resumable reconnection (throttle delay + attempt ceiling). */
|
|
1111
|
+
reconnect?: ReconnectOptions
|
|
418
1112
|
}
|
|
419
1113
|
|
|
420
1114
|
type ResolvedConnectionOptions = Pick<
|
|
@@ -443,6 +1137,7 @@ function buildRunAgentInputBody(
|
|
|
443
1137
|
...(runContext?.parentRunId !== undefined && {
|
|
444
1138
|
parentRunId: runContext.parentRunId,
|
|
445
1139
|
}),
|
|
1140
|
+
...(runContext?.resume !== undefined && { resume: runContext.resume }),
|
|
446
1141
|
state: {},
|
|
447
1142
|
messages: wireMessages,
|
|
448
1143
|
tools: runContext?.clientTools ?? [],
|
|
@@ -482,7 +1177,7 @@ function buildRunAgentInputBody(
|
|
|
482
1177
|
* const connection = fetchServerSentEvents('/api/chat', async () => ({
|
|
483
1178
|
* body: {
|
|
484
1179
|
* provider: 'openai',
|
|
485
|
-
* model: 'gpt-
|
|
1180
|
+
* model: 'gpt-5.5',
|
|
486
1181
|
* }
|
|
487
1182
|
* }));
|
|
488
1183
|
* ```
|
|
@@ -492,7 +1187,7 @@ export function fetchServerSentEvents(
|
|
|
492
1187
|
options:
|
|
493
1188
|
| FetchConnectionOptions
|
|
494
1189
|
| (() => FetchConnectionOptions | Promise<FetchConnectionOptions>) = {},
|
|
495
|
-
):
|
|
1190
|
+
): ResumableConnectConnectionAdapter {
|
|
496
1191
|
return {
|
|
497
1192
|
async *connect(messages, data, abortSignal, runContext) {
|
|
498
1193
|
// Resolve URL and options if they are functions
|
|
@@ -503,6 +1198,7 @@ export function fetchServerSentEvents(
|
|
|
503
1198
|
const requestHeaders: Record<string, string> = {
|
|
504
1199
|
'Content-Type': 'application/json',
|
|
505
1200
|
...mergeHeaders(resolvedOptions.headers),
|
|
1201
|
+
...runIdHeader(runContext?.runId),
|
|
506
1202
|
}
|
|
507
1203
|
|
|
508
1204
|
// Build AG-UI RunAgentInput payload.
|
|
@@ -524,15 +1220,101 @@ export function fetchServerSentEvents(
|
|
|
524
1220
|
// under `exactOptionalPropertyTypes`), so spread it conditionally
|
|
525
1221
|
// rather than passing `undefined` explicitly.
|
|
526
1222
|
const signal = abortSignal || resolvedOptions.signal
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
1223
|
+
// POST URL is byte-identical to a plain request; the run id (when set)
|
|
1224
|
+
// rides in the X-Run-Id header so durability can key the log by it
|
|
1225
|
+
// without changing the request URL existing clients rely on.
|
|
1226
|
+
const requestUrl = resolvedUrl
|
|
1227
|
+
|
|
1228
|
+
// Resumable SSE: if the server tags events with `id:` offsets (delivery
|
|
1229
|
+
// durability), a dropped/rolled-over connection auto-reconnects with a
|
|
1230
|
+
// `Last-Event-ID` header and de-dupes the replayed prefix. With no tags,
|
|
1231
|
+
// this is a single plain fetch.
|
|
1232
|
+
yield* resumableStream(
|
|
1233
|
+
fetchEventSource(
|
|
1234
|
+
fetchClient,
|
|
1235
|
+
requestUrl,
|
|
1236
|
+
{
|
|
1237
|
+
method: 'POST',
|
|
1238
|
+
headers: requestHeaders,
|
|
1239
|
+
body: JSON.stringify(requestBody),
|
|
1240
|
+
credentials: resolvedOptions.credentials || 'same-origin',
|
|
1241
|
+
},
|
|
1242
|
+
// Thread the run's ids so a `[DONE]`-terminating server that doesn't
|
|
1243
|
+
// stamp them onto events still yields a correlated terminal (parity
|
|
1244
|
+
// with the XHR adapter's xhrSSEParser).
|
|
1245
|
+
(response, sseSignal) =>
|
|
1246
|
+
responseToSSEEvents(response, sseSignal, {
|
|
1247
|
+
...(runContext?.threadId !== undefined
|
|
1248
|
+
? { threadId: runContext.threadId }
|
|
1249
|
+
: {}),
|
|
1250
|
+
...(runContext?.runId !== undefined
|
|
1251
|
+
? { runId: runContext.runId }
|
|
1252
|
+
: {}),
|
|
1253
|
+
}),
|
|
1254
|
+
),
|
|
1255
|
+
signal,
|
|
1256
|
+
resolvedOptions.reconnect,
|
|
1257
|
+
)
|
|
1258
|
+
},
|
|
1259
|
+
async *joinRun(runId, abortSignal) {
|
|
1260
|
+
// Read an in-flight or finished run from the start. `?offset=-1` tells the
|
|
1261
|
+
// server's delivery-durability sink to replay from the beginning; `runId`
|
|
1262
|
+
// identifies which run. This is a read-only GET — no messages are sent.
|
|
1263
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1264
|
+
const resolvedOptions =
|
|
1265
|
+
typeof options === 'function' ? await options() : options
|
|
1266
|
+
|
|
1267
|
+
const joinUrl = withSearchParams(resolvedUrl, {
|
|
1268
|
+
offset: '-1',
|
|
1269
|
+
runId,
|
|
533
1270
|
})
|
|
534
1271
|
|
|
535
|
-
|
|
1272
|
+
const requestHeaders: Record<string, string> = {
|
|
1273
|
+
...mergeHeaders(resolvedOptions.headers),
|
|
1274
|
+
}
|
|
1275
|
+
const fetchClient = resolvedOptions.fetchClient ?? fetch
|
|
1276
|
+
const signal = abortSignal || resolvedOptions.signal
|
|
1277
|
+
|
|
1278
|
+
yield* resumableStream(
|
|
1279
|
+
fetchEventSource(
|
|
1280
|
+
fetchClient,
|
|
1281
|
+
joinUrl,
|
|
1282
|
+
{
|
|
1283
|
+
method: 'GET',
|
|
1284
|
+
headers: requestHeaders,
|
|
1285
|
+
credentials: resolvedOptions.credentials || 'same-origin',
|
|
1286
|
+
},
|
|
1287
|
+
// A `[DONE]` during a join correlates to the joined run id.
|
|
1288
|
+
(response, sseSignal) =>
|
|
1289
|
+
responseToSSEEvents(response, sseSignal, { runId }),
|
|
1290
|
+
),
|
|
1291
|
+
signal,
|
|
1292
|
+
resolvedOptions.reconnect,
|
|
1293
|
+
)
|
|
1294
|
+
},
|
|
1295
|
+
async hydrate(threadId) {
|
|
1296
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1297
|
+
const resolvedOptions =
|
|
1298
|
+
typeof options === 'function' ? await options() : options
|
|
1299
|
+
return fetchThreadHydration(
|
|
1300
|
+
resolvedOptions.fetchClient ?? fetch,
|
|
1301
|
+
resolvedUrl,
|
|
1302
|
+
mergeHeaders(resolvedOptions.headers),
|
|
1303
|
+
resolvedOptions.credentials || 'same-origin',
|
|
1304
|
+
threadId,
|
|
1305
|
+
)
|
|
1306
|
+
},
|
|
1307
|
+
async hydrateGeneration(threadId) {
|
|
1308
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1309
|
+
const resolvedOptions =
|
|
1310
|
+
typeof options === 'function' ? await options() : options
|
|
1311
|
+
return fetchGenerationHydration(
|
|
1312
|
+
resolvedOptions.fetchClient ?? fetch,
|
|
1313
|
+
resolvedUrl,
|
|
1314
|
+
mergeHeaders(resolvedOptions.headers),
|
|
1315
|
+
resolvedOptions.credentials || 'same-origin',
|
|
1316
|
+
threadId,
|
|
1317
|
+
)
|
|
536
1318
|
},
|
|
537
1319
|
}
|
|
538
1320
|
}
|
|
@@ -566,7 +1348,7 @@ export function fetchServerSentEvents(
|
|
|
566
1348
|
* const connection = fetchHttpStream('/api/chat', async () => ({
|
|
567
1349
|
* body: {
|
|
568
1350
|
* provider: 'openai',
|
|
569
|
-
* model: 'gpt-
|
|
1351
|
+
* model: 'gpt-5.5',
|
|
570
1352
|
* }
|
|
571
1353
|
* }));
|
|
572
1354
|
* ```
|
|
@@ -576,7 +1358,7 @@ export function fetchHttpStream(
|
|
|
576
1358
|
options:
|
|
577
1359
|
| FetchConnectionOptions
|
|
578
1360
|
| (() => FetchConnectionOptions | Promise<FetchConnectionOptions>) = {},
|
|
579
|
-
):
|
|
1361
|
+
): ResumableConnectConnectionAdapter {
|
|
580
1362
|
return {
|
|
581
1363
|
async *connect(messages, data, abortSignal, runContext) {
|
|
582
1364
|
// Resolve URL and options if they are functions
|
|
@@ -587,6 +1369,7 @@ export function fetchHttpStream(
|
|
|
587
1369
|
const requestHeaders: Record<string, string> = {
|
|
588
1370
|
'Content-Type': 'application/json',
|
|
589
1371
|
...mergeHeaders(resolvedOptions.headers),
|
|
1372
|
+
...runIdHeader(runContext?.runId),
|
|
590
1373
|
}
|
|
591
1374
|
|
|
592
1375
|
// Build AG-UI RunAgentInput payload.
|
|
@@ -608,26 +1391,85 @@ export function fetchHttpStream(
|
|
|
608
1391
|
// under `exactOptionalPropertyTypes`), so spread it conditionally
|
|
609
1392
|
// rather than passing `undefined` explicitly.
|
|
610
1393
|
const signal = abortSignal || resolvedOptions.signal
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
})
|
|
1394
|
+
// POST URL is byte-identical to a plain request; the run id (when set)
|
|
1395
|
+
// rides in the X-Run-Id header so durability can key the log by it
|
|
1396
|
+
// without changing the request URL existing clients rely on.
|
|
1397
|
+
const requestUrl = resolvedUrl
|
|
1398
|
+
|
|
1399
|
+
// Resumable NDJSON: if the server envelopes each line with an
|
|
1400
|
+
// `{ id, chunk }` offset (delivery durability), a dropped/rolled-over
|
|
1401
|
+
// connection auto-reconnects with a `Last-Event-ID` header and de-dupes
|
|
1402
|
+
// the replayed prefix. With bare lines (no durability), this is a single
|
|
1403
|
+
// plain fetch — identical to before.
|
|
1404
|
+
yield* resumableStream(
|
|
1405
|
+
fetchEventSource(
|
|
1406
|
+
fetchClient,
|
|
1407
|
+
requestUrl,
|
|
1408
|
+
{
|
|
1409
|
+
method: 'POST',
|
|
1410
|
+
headers: requestHeaders,
|
|
1411
|
+
body: JSON.stringify(requestBody),
|
|
1412
|
+
credentials: resolvedOptions.credentials || 'same-origin',
|
|
1413
|
+
},
|
|
1414
|
+
responseToNdjsonEvents,
|
|
1415
|
+
),
|
|
1416
|
+
signal,
|
|
1417
|
+
resolvedOptions.reconnect,
|
|
1418
|
+
)
|
|
1419
|
+
},
|
|
1420
|
+
async *joinRun(runId, abortSignal) {
|
|
1421
|
+
// Read an in-flight or finished run from the start. `?offset=-1` tells the
|
|
1422
|
+
// server's delivery-durability sink to replay from the beginning; `runId`
|
|
1423
|
+
// identifies which run. This is a read-only GET — no messages are sent.
|
|
1424
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1425
|
+
const resolvedOptions =
|
|
1426
|
+
typeof options === 'function' ? await options() : options
|
|
618
1427
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
)
|
|
1428
|
+
const joinUrl = withSearchParams(resolvedUrl, { offset: '-1', runId })
|
|
1429
|
+
const requestHeaders: Record<string, string> = {
|
|
1430
|
+
...mergeHeaders(resolvedOptions.headers),
|
|
623
1431
|
}
|
|
1432
|
+
const fetchClient = resolvedOptions.fetchClient ?? fetch
|
|
1433
|
+
const signal = abortSignal || resolvedOptions.signal
|
|
624
1434
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
1435
|
+
yield* resumableStream(
|
|
1436
|
+
fetchEventSource(
|
|
1437
|
+
fetchClient,
|
|
1438
|
+
joinUrl,
|
|
1439
|
+
{
|
|
1440
|
+
method: 'GET',
|
|
1441
|
+
headers: requestHeaders,
|
|
1442
|
+
credentials: resolvedOptions.credentials || 'same-origin',
|
|
1443
|
+
},
|
|
1444
|
+
responseToNdjsonEvents,
|
|
1445
|
+
),
|
|
1446
|
+
signal,
|
|
1447
|
+
resolvedOptions.reconnect,
|
|
1448
|
+
)
|
|
1449
|
+
},
|
|
1450
|
+
async hydrate(threadId) {
|
|
1451
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1452
|
+
const resolvedOptions =
|
|
1453
|
+
typeof options === 'function' ? await options() : options
|
|
1454
|
+
return fetchThreadHydration(
|
|
1455
|
+
resolvedOptions.fetchClient ?? fetch,
|
|
1456
|
+
resolvedUrl,
|
|
1457
|
+
mergeHeaders(resolvedOptions.headers),
|
|
1458
|
+
resolvedOptions.credentials || 'same-origin',
|
|
1459
|
+
threadId,
|
|
1460
|
+
)
|
|
1461
|
+
},
|
|
1462
|
+
async hydrateGeneration(threadId) {
|
|
1463
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1464
|
+
const resolvedOptions =
|
|
1465
|
+
typeof options === 'function' ? await options() : options
|
|
1466
|
+
return fetchGenerationHydration(
|
|
1467
|
+
resolvedOptions.fetchClient ?? fetch,
|
|
1468
|
+
resolvedUrl,
|
|
1469
|
+
mergeHeaders(resolvedOptions.headers),
|
|
1470
|
+
resolvedOptions.credentials || 'same-origin',
|
|
1471
|
+
threadId,
|
|
1472
|
+
)
|
|
631
1473
|
},
|
|
632
1474
|
}
|
|
633
1475
|
}
|
|
@@ -705,7 +1547,10 @@ function readXhrLines(
|
|
|
705
1547
|
|
|
706
1548
|
const finish = () => {
|
|
707
1549
|
enqueueDelta()
|
|
708
|
-
|
|
1550
|
+
// Tolerate a transient status === 0 (matches enqueueDelta): a real non-2xx
|
|
1551
|
+
// is an error, but status 0 here is not — treat the trailing buffer as a
|
|
1552
|
+
// truncation check instead of fabricating a bogus "status: 0" error.
|
|
1553
|
+
if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
|
|
709
1554
|
error = new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`)
|
|
710
1555
|
} else if (buffer.trim() && !aborted) {
|
|
711
1556
|
error = new StreamTruncatedError()
|
|
@@ -720,7 +1565,10 @@ function readXhrLines(
|
|
|
720
1565
|
}
|
|
721
1566
|
xhr.onload = finish
|
|
722
1567
|
xhr.onerror = () => {
|
|
723
|
-
|
|
1568
|
+
// Surface as StreamReadError so a durable (id-tagged) run whose socket
|
|
1569
|
+
// drops mid-stream is eligible for auto-reconnect, matching the fetch path.
|
|
1570
|
+
// A non-durable run has no offset, so resumableStream rethrows it as-is.
|
|
1571
|
+
error = new StreamReadError(new Error('XHR request failed'))
|
|
724
1572
|
done = true
|
|
725
1573
|
wake()
|
|
726
1574
|
}
|
|
@@ -787,9 +1635,11 @@ function createConfiguredXhrRequest(
|
|
|
787
1635
|
messages: Array<UIMessage> | Array<ModelMessage>,
|
|
788
1636
|
data: Record<string, any> | undefined,
|
|
789
1637
|
runContext: RunAgentInputContext | undefined,
|
|
1638
|
+
method: string = 'POST',
|
|
1639
|
+
extraHeaders: Record<string, string> = {},
|
|
790
1640
|
): ConfiguredXhrRequest {
|
|
791
1641
|
const xhr = options.xhrFactory?.() ?? createDefaultXMLHttpRequest()
|
|
792
|
-
xhr.open(
|
|
1642
|
+
xhr.open(method, url)
|
|
793
1643
|
if (options.withCredentials !== undefined) {
|
|
794
1644
|
xhr.withCredentials = options.withCredentials
|
|
795
1645
|
}
|
|
@@ -797,6 +1647,11 @@ function createConfiguredXhrRequest(
|
|
|
797
1647
|
const requestHeaders: Record<string, string> = {
|
|
798
1648
|
'Content-Type': 'application/json',
|
|
799
1649
|
...mergeHeaders(options.headers),
|
|
1650
|
+
// Client-chosen run id for durability (POST only; the GET join carries it
|
|
1651
|
+
// in the query instead).
|
|
1652
|
+
...(method === 'POST' ? runIdHeader(runContext?.runId) : {}),
|
|
1653
|
+
// Reconnect offset (`Last-Event-ID`) wins over static headers.
|
|
1654
|
+
...extraHeaders,
|
|
800
1655
|
}
|
|
801
1656
|
|
|
802
1657
|
for (const [name, value] of Object.entries(requestHeaders)) {
|
|
@@ -819,113 +1674,268 @@ async function resolveXhrConnectionOptions(
|
|
|
819
1674
|
return typeof options === 'function' ? await options() : options
|
|
820
1675
|
}
|
|
821
1676
|
|
|
1677
|
+
/**
|
|
1678
|
+
* Build an XHR-backed {@link StreamEventSource}. `parseLines` decodes the raw
|
|
1679
|
+
* newline-delimited body into events (SSE or NDJSON); the reconnect engine is
|
|
1680
|
+
* shared with the fetch adapters. A fresh XHR is opened per attempt, so a
|
|
1681
|
+
* `Last-Event-ID` reconnect header (via `extraHeaders`) is applied at open time.
|
|
1682
|
+
*/
|
|
1683
|
+
function xhrEventSource(
|
|
1684
|
+
url: string,
|
|
1685
|
+
options: XhrConnectionOptions,
|
|
1686
|
+
method: string,
|
|
1687
|
+
messages: Array<UIMessage> | Array<ModelMessage>,
|
|
1688
|
+
data: Record<string, any> | undefined,
|
|
1689
|
+
runContext: RunAgentInputContext | undefined,
|
|
1690
|
+
parseLines: (lines: AsyncIterable<string>) => AsyncIterable<StreamEvent>,
|
|
1691
|
+
): StreamEventSource {
|
|
1692
|
+
return async function* (extraHeaders, abortSignal) {
|
|
1693
|
+
const request = createConfiguredXhrRequest(
|
|
1694
|
+
url,
|
|
1695
|
+
options,
|
|
1696
|
+
messages,
|
|
1697
|
+
data,
|
|
1698
|
+
runContext,
|
|
1699
|
+
method,
|
|
1700
|
+
extraHeaders,
|
|
1701
|
+
)
|
|
1702
|
+
const lines = readXhrLines(request.xhr, abortSignal)
|
|
1703
|
+
if (abortSignal?.aborted) {
|
|
1704
|
+
await lines.next()
|
|
1705
|
+
return
|
|
1706
|
+
}
|
|
1707
|
+
// A read-only join is a bodyless GET; a run POSTs the RunAgentInput payload.
|
|
1708
|
+
request.xhr.send(method === 'GET' ? null : request.body)
|
|
1709
|
+
try {
|
|
1710
|
+
yield* parseLines(lines)
|
|
1711
|
+
} finally {
|
|
1712
|
+
// Tear the socket down on an early exit (terminal reached or reconnect
|
|
1713
|
+
// break) so late bytes stop downloading. When the abort signal fired,
|
|
1714
|
+
// `readXhrLines` already aborted — skip here to avoid a double abort().
|
|
1715
|
+
if (!abortSignal?.aborted) request.xhr.abort()
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/** SSE line parser bound to the run's ids for a `[DONE]` fallback. */
|
|
1721
|
+
function xhrSSEParser(runContext: RunAgentInputContext | undefined) {
|
|
1722
|
+
const fallbackIds: { threadId?: string; runId?: string } = {
|
|
1723
|
+
...(runContext?.threadId !== undefined
|
|
1724
|
+
? { threadId: runContext.threadId }
|
|
1725
|
+
: {}),
|
|
1726
|
+
...(runContext?.runId !== undefined ? { runId: runContext.runId } : {}),
|
|
1727
|
+
}
|
|
1728
|
+
return (lines: AsyncIterable<string>) => linesToSSEEvents(lines, fallbackIds)
|
|
1729
|
+
}
|
|
1730
|
+
|
|
822
1731
|
/**
|
|
823
1732
|
* Create an XMLHttpRequest-backed Server-Sent Events connection adapter.
|
|
1733
|
+
*
|
|
1734
|
+
* Resumable: against a durable (`id:`-tagged) server response, a dropped socket
|
|
1735
|
+
* auto-reconnects with `Last-Event-ID` and de-dupes the replayed prefix, and
|
|
1736
|
+
* `joinRun` attaches to an existing run from the start. A non-durable response
|
|
1737
|
+
* is a single plain request, exactly as before.
|
|
824
1738
|
*/
|
|
825
1739
|
export function xhrServerSentEvents(
|
|
826
1740
|
url: string | (() => string),
|
|
827
1741
|
options: XhrConnectionOptionsResolver = {},
|
|
828
|
-
):
|
|
1742
|
+
): ResumableConnectConnectionAdapter {
|
|
829
1743
|
return {
|
|
830
1744
|
async *connect(messages, data, abortSignal, runContext) {
|
|
831
1745
|
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
832
1746
|
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
833
1747
|
const signal = abortSignal || resolvedOptions.signal
|
|
834
|
-
|
|
1748
|
+
// POST URL is byte-identical to a plain request; the run id (when set)
|
|
1749
|
+
// rides in the X-Run-Id header so durability can key the log by it
|
|
1750
|
+
// without changing the request URL existing clients rely on.
|
|
1751
|
+
const requestUrl = resolvedUrl
|
|
1752
|
+
yield* resumableStream(
|
|
1753
|
+
xhrEventSource(
|
|
1754
|
+
requestUrl,
|
|
1755
|
+
resolvedOptions,
|
|
1756
|
+
'POST',
|
|
1757
|
+
messages,
|
|
1758
|
+
data,
|
|
1759
|
+
runContext,
|
|
1760
|
+
xhrSSEParser(runContext),
|
|
1761
|
+
),
|
|
1762
|
+
signal,
|
|
1763
|
+
resolvedOptions.reconnect,
|
|
1764
|
+
)
|
|
1765
|
+
},
|
|
1766
|
+
async *joinRun(runId, abortSignal) {
|
|
1767
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1768
|
+
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
1769
|
+
const signal = abortSignal || resolvedOptions.signal
|
|
1770
|
+
const joinUrl = withSearchParams(resolvedUrl, { offset: '-1', runId })
|
|
1771
|
+
yield* resumableStream(
|
|
1772
|
+
xhrEventSource(
|
|
1773
|
+
joinUrl,
|
|
1774
|
+
resolvedOptions,
|
|
1775
|
+
'GET',
|
|
1776
|
+
[],
|
|
1777
|
+
undefined,
|
|
1778
|
+
undefined,
|
|
1779
|
+
// A `[DONE]` during a join correlates to the joined run id (parity
|
|
1780
|
+
// with fetchServerSentEvents.joinRun).
|
|
1781
|
+
(lines) => linesToSSEEvents(lines, { runId }),
|
|
1782
|
+
),
|
|
1783
|
+
signal,
|
|
1784
|
+
resolvedOptions.reconnect,
|
|
1785
|
+
)
|
|
1786
|
+
},
|
|
1787
|
+
async hydrate(threadId) {
|
|
1788
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1789
|
+
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
1790
|
+
// Hydration is a non-streaming JSON GET, so fetch is fine even for the
|
|
1791
|
+
// XHR-backed streaming adapter.
|
|
1792
|
+
return fetchThreadHydration(
|
|
1793
|
+
fetch,
|
|
835
1794
|
resolvedUrl,
|
|
836
|
-
resolvedOptions,
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1795
|
+
mergeHeaders(resolvedOptions.headers),
|
|
1796
|
+
resolvedOptions.withCredentials ? 'include' : 'same-origin',
|
|
1797
|
+
threadId,
|
|
1798
|
+
)
|
|
1799
|
+
},
|
|
1800
|
+
async hydrateGeneration(threadId) {
|
|
1801
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1802
|
+
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
1803
|
+
// Hydration is a non-streaming JSON GET, so fetch is fine even for the
|
|
1804
|
+
// XHR-backed streaming adapter.
|
|
1805
|
+
return fetchGenerationHydration(
|
|
1806
|
+
fetch,
|
|
1807
|
+
resolvedUrl,
|
|
1808
|
+
mergeHeaders(resolvedOptions.headers),
|
|
1809
|
+
resolvedOptions.withCredentials ? 'include' : 'same-origin',
|
|
1810
|
+
threadId,
|
|
840
1811
|
)
|
|
841
|
-
const lines = readXhrLines(request.xhr, signal)
|
|
842
|
-
if (signal?.aborted) {
|
|
843
|
-
await lines.next()
|
|
844
|
-
return
|
|
845
|
-
}
|
|
846
|
-
request.xhr.send(request.body)
|
|
847
|
-
let lastThreadId: string | undefined
|
|
848
|
-
let lastRunId: string | undefined
|
|
849
|
-
let lastModel: string | undefined
|
|
850
|
-
|
|
851
|
-
for await (const line of lines) {
|
|
852
|
-
if (
|
|
853
|
-
line.startsWith(':') ||
|
|
854
|
-
line.startsWith('event:') ||
|
|
855
|
-
line.startsWith('id:') ||
|
|
856
|
-
line.startsWith('retry:')
|
|
857
|
-
) {
|
|
858
|
-
continue
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
const chunkData = parseSseDataLine(line)
|
|
862
|
-
if (chunkData === '[DONE]') {
|
|
863
|
-
const synthetic: RunFinishedEvent = {
|
|
864
|
-
type: EventType.RUN_FINISHED,
|
|
865
|
-
threadId: lastThreadId ?? runContext?.threadId ?? '',
|
|
866
|
-
runId: lastRunId ?? runContext?.runId ?? '',
|
|
867
|
-
model: lastModel ?? '',
|
|
868
|
-
timestamp: Date.now(),
|
|
869
|
-
finishReason: 'stop',
|
|
870
|
-
}
|
|
871
|
-
request.xhr.abort()
|
|
872
|
-
yield synthetic
|
|
873
|
-
return
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
const chunk = JSON.parse(chunkData) as StreamChunk
|
|
877
|
-
if ('threadId' in chunk && typeof chunk.threadId === 'string') {
|
|
878
|
-
lastThreadId = chunk.threadId
|
|
879
|
-
}
|
|
880
|
-
if ('runId' in chunk && typeof chunk.runId === 'string') {
|
|
881
|
-
lastRunId = chunk.runId
|
|
882
|
-
}
|
|
883
|
-
if ('model' in chunk && typeof chunk.model === 'string') {
|
|
884
|
-
lastModel = chunk.model
|
|
885
|
-
}
|
|
886
|
-
yield chunk
|
|
887
|
-
}
|
|
888
1812
|
},
|
|
889
1813
|
}
|
|
890
1814
|
}
|
|
891
1815
|
|
|
892
1816
|
/**
|
|
893
1817
|
* Create an XMLHttpRequest-backed newline-delimited JSON stream adapter.
|
|
1818
|
+
*
|
|
1819
|
+
* Resumable: against a durable (envelope-tagged) server response, a dropped
|
|
1820
|
+
* socket auto-reconnects with `Last-Event-ID` and de-dupes the replayed prefix,
|
|
1821
|
+
* and `joinRun` attaches to an existing run from the start. A non-durable
|
|
1822
|
+
* (bare-line) response is a single plain request, exactly as before.
|
|
894
1823
|
*/
|
|
895
1824
|
export function xhrHttpStream(
|
|
896
1825
|
url: string | (() => string),
|
|
897
1826
|
options: XhrConnectionOptionsResolver = {},
|
|
898
|
-
):
|
|
1827
|
+
): ResumableConnectConnectionAdapter {
|
|
899
1828
|
return {
|
|
900
1829
|
async *connect(messages, data, abortSignal, runContext) {
|
|
901
1830
|
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
902
1831
|
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
903
1832
|
const signal = abortSignal || resolvedOptions.signal
|
|
904
|
-
|
|
1833
|
+
// POST URL is byte-identical to a plain request; the run id (when set)
|
|
1834
|
+
// rides in the X-Run-Id header so durability can key the log by it
|
|
1835
|
+
// without changing the request URL existing clients rely on.
|
|
1836
|
+
const requestUrl = resolvedUrl
|
|
1837
|
+
yield* resumableStream(
|
|
1838
|
+
xhrEventSource(
|
|
1839
|
+
requestUrl,
|
|
1840
|
+
resolvedOptions,
|
|
1841
|
+
'POST',
|
|
1842
|
+
messages,
|
|
1843
|
+
data,
|
|
1844
|
+
runContext,
|
|
1845
|
+
linesToNdjsonEvents,
|
|
1846
|
+
),
|
|
1847
|
+
signal,
|
|
1848
|
+
resolvedOptions.reconnect,
|
|
1849
|
+
)
|
|
1850
|
+
},
|
|
1851
|
+
async *joinRun(runId, abortSignal) {
|
|
1852
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1853
|
+
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
1854
|
+
const signal = abortSignal || resolvedOptions.signal
|
|
1855
|
+
const joinUrl = withSearchParams(resolvedUrl, { offset: '-1', runId })
|
|
1856
|
+
yield* resumableStream(
|
|
1857
|
+
xhrEventSource(
|
|
1858
|
+
joinUrl,
|
|
1859
|
+
resolvedOptions,
|
|
1860
|
+
'GET',
|
|
1861
|
+
[],
|
|
1862
|
+
undefined,
|
|
1863
|
+
undefined,
|
|
1864
|
+
linesToNdjsonEvents,
|
|
1865
|
+
),
|
|
1866
|
+
signal,
|
|
1867
|
+
resolvedOptions.reconnect,
|
|
1868
|
+
)
|
|
1869
|
+
},
|
|
1870
|
+
async hydrate(threadId) {
|
|
1871
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1872
|
+
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
1873
|
+
// Hydration is a non-streaming JSON GET, so fetch is fine even for the
|
|
1874
|
+
// XHR-backed streaming adapter.
|
|
1875
|
+
return fetchThreadHydration(
|
|
1876
|
+
fetch,
|
|
905
1877
|
resolvedUrl,
|
|
906
|
-
resolvedOptions,
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1878
|
+
mergeHeaders(resolvedOptions.headers),
|
|
1879
|
+
resolvedOptions.withCredentials ? 'include' : 'same-origin',
|
|
1880
|
+
threadId,
|
|
1881
|
+
)
|
|
1882
|
+
},
|
|
1883
|
+
async hydrateGeneration(threadId) {
|
|
1884
|
+
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1885
|
+
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
1886
|
+
// Hydration is a non-streaming JSON GET, so fetch is fine even for the
|
|
1887
|
+
// XHR-backed streaming adapter.
|
|
1888
|
+
return fetchGenerationHydration(
|
|
1889
|
+
fetch,
|
|
1890
|
+
resolvedUrl,
|
|
1891
|
+
mergeHeaders(resolvedOptions.headers),
|
|
1892
|
+
resolvedOptions.withCredentials ? 'include' : 'same-origin',
|
|
1893
|
+
threadId,
|
|
910
1894
|
)
|
|
911
|
-
const lines = readXhrLines(request.xhr, signal)
|
|
912
|
-
if (signal?.aborted) {
|
|
913
|
-
await lines.next()
|
|
914
|
-
return
|
|
915
|
-
}
|
|
916
|
-
request.xhr.send(request.body)
|
|
917
|
-
|
|
918
|
-
for await (const line of lines) {
|
|
919
|
-
yield JSON.parse(line) as StreamChunk
|
|
920
|
-
}
|
|
921
1895
|
},
|
|
922
1896
|
}
|
|
923
1897
|
}
|
|
924
1898
|
|
|
1899
|
+
/**
|
|
1900
|
+
* Optional persistence handlers for the lightweight adapters (`stream()`,
|
|
1901
|
+
* `rpcStream()`). These are one-shot, request-scoped calls with no built-in
|
|
1902
|
+
* GET endpoint or second channel, so hydration and run-rejoin only exist if
|
|
1903
|
+
* the app supplies them — typically thin wrappers over TanStack Start server
|
|
1904
|
+
* functions backed by `@tanstack/ai-persistence` (`getGenerationHydration`)
|
|
1905
|
+
* and a delivery-durability log (`memoryStream` / `replayRunStream`).
|
|
1906
|
+
*
|
|
1907
|
+
* Each handler is spread onto the returned adapter only when defined, so
|
|
1908
|
+
* feature detection (`connection.hydrateGeneration` etc.) keeps working.
|
|
1909
|
+
*/
|
|
1910
|
+
export interface StreamConnectionHandlers {
|
|
1911
|
+
/**
|
|
1912
|
+
* Server-driven chat hydration for `persistence: true`: the stored
|
|
1913
|
+
* transcript for `threadId` plus a cursor to an in-flight run.
|
|
1914
|
+
*/
|
|
1915
|
+
hydrate?: (threadId: string) => Promise<ChatHydrationResult>
|
|
1916
|
+
/**
|
|
1917
|
+
* Server-driven generation hydration for `persistence: true`: the last
|
|
1918
|
+
* generation's resume snapshot for `threadId` plus a cursor to a run still
|
|
1919
|
+
* generating. See {@link ConnectConnectionAdapter.hydrateGeneration}.
|
|
1920
|
+
*/
|
|
1921
|
+
hydrateGeneration?: (threadId: string) => Promise<GenerationHydrationResult>
|
|
1922
|
+
/**
|
|
1923
|
+
* Re-attach to a run still generating and replay it from the start. See
|
|
1924
|
+
* {@link ConnectConnectionAdapter.joinRun}.
|
|
1925
|
+
*/
|
|
1926
|
+
joinRun?: (
|
|
1927
|
+
runId: string,
|
|
1928
|
+
abortSignal?: AbortSignal,
|
|
1929
|
+
) => AsyncIterable<StreamChunk>
|
|
1930
|
+
}
|
|
1931
|
+
|
|
925
1932
|
/**
|
|
926
1933
|
* Create a direct stream connection adapter (for server functions or direct streams)
|
|
927
1934
|
*
|
|
928
1935
|
* @param streamFactory - A function that returns an async iterable of StreamChunks
|
|
1936
|
+
* @param handlers - Optional persistence handlers (`hydrate`,
|
|
1937
|
+
* `hydrateGeneration`, `joinRun`) that let server-driven persistence work
|
|
1938
|
+
* without an HTTP endpoint — each is usually a one-line server-function call
|
|
929
1939
|
* @returns A connection adapter for direct streams
|
|
930
1940
|
*
|
|
931
1941
|
* @example
|
|
@@ -934,6 +1944,15 @@ export function xhrHttpStream(
|
|
|
934
1944
|
* const connection = stream(() => serverFunction({ messages }));
|
|
935
1945
|
*
|
|
936
1946
|
* const client = new ChatClient({ connection });
|
|
1947
|
+
*
|
|
1948
|
+
* // With generation persistence over server functions
|
|
1949
|
+
* const connection = stream(
|
|
1950
|
+
* () => generateImageFn({ data: input }),
|
|
1951
|
+
* {
|
|
1952
|
+
* hydrateGeneration: (threadId) => getImageHydrationFn({ data: threadId }),
|
|
1953
|
+
* joinRun: (runId) => joinImageRunFn({ data: runId }),
|
|
1954
|
+
* },
|
|
1955
|
+
* );
|
|
937
1956
|
* ```
|
|
938
1957
|
*/
|
|
939
1958
|
export function stream(
|
|
@@ -942,6 +1961,7 @@ export function stream(
|
|
|
942
1961
|
data?: Record<string, any>,
|
|
943
1962
|
abortSignal?: AbortSignal,
|
|
944
1963
|
) => AsyncIterable<StreamChunk>,
|
|
1964
|
+
handlers?: StreamConnectionHandlers,
|
|
945
1965
|
): ConnectConnectionAdapter {
|
|
946
1966
|
return {
|
|
947
1967
|
async *connect(messages, data, abortSignal) {
|
|
@@ -949,6 +1969,11 @@ export function stream(
|
|
|
949
1969
|
// Server-side chat() handles conversion to ModelMessages
|
|
950
1970
|
yield* streamFactory(messages, data, abortSignal)
|
|
951
1971
|
},
|
|
1972
|
+
...(handlers?.hydrate ? { hydrate: handlers.hydrate } : {}),
|
|
1973
|
+
...(handlers?.hydrateGeneration
|
|
1974
|
+
? { hydrateGeneration: handlers.hydrateGeneration }
|
|
1975
|
+
: {}),
|
|
1976
|
+
...(handlers?.joinRun ? { joinRun: handlers.joinRun } : {}),
|
|
952
1977
|
}
|
|
953
1978
|
}
|
|
954
1979
|
|
|
@@ -1030,6 +2055,9 @@ async function* abortableIterable<T>(
|
|
|
1030
2055
|
* Create an RPC stream connection adapter (for RPC-based streaming like Cap'n Web RPC)
|
|
1031
2056
|
*
|
|
1032
2057
|
* @param rpcCall - A function that accepts messages and returns an async iterable of StreamChunks
|
|
2058
|
+
* @param handlers - Optional persistence handlers (`hydrate`,
|
|
2059
|
+
* `hydrateGeneration`, `joinRun`) that let server-driven persistence work
|
|
2060
|
+
* without an HTTP endpoint — each is usually a one-line RPC call
|
|
1033
2061
|
* @returns A connection adapter for RPC streams
|
|
1034
2062
|
*
|
|
1035
2063
|
* @example
|
|
@@ -1040,6 +2068,15 @@ async function* abortableIterable<T>(
|
|
|
1040
2068
|
* );
|
|
1041
2069
|
*
|
|
1042
2070
|
* const client = new ChatClient({ connection });
|
|
2071
|
+
*
|
|
2072
|
+
* // With generation persistence over RPC
|
|
2073
|
+
* const connection = rpcStream(
|
|
2074
|
+
* (messages, data) => api.streamMurfResponse(messages, data),
|
|
2075
|
+
* {
|
|
2076
|
+
* hydrateGeneration: (threadId) => api.getGenerationHydration(threadId),
|
|
2077
|
+
* joinRun: (runId) => api.replayRun(runId),
|
|
2078
|
+
* },
|
|
2079
|
+
* );
|
|
1043
2080
|
* ```
|
|
1044
2081
|
*/
|
|
1045
2082
|
export function rpcStream(
|
|
@@ -1048,6 +2085,7 @@ export function rpcStream(
|
|
|
1048
2085
|
data?: Record<string, any>,
|
|
1049
2086
|
abortSignal?: AbortSignal,
|
|
1050
2087
|
) => AsyncIterable<StreamChunk>,
|
|
2088
|
+
handlers?: StreamConnectionHandlers,
|
|
1051
2089
|
): ConnectConnectionAdapter {
|
|
1052
2090
|
return {
|
|
1053
2091
|
async *connect(messages, data, abortSignal) {
|
|
@@ -1055,5 +2093,10 @@ export function rpcStream(
|
|
|
1055
2093
|
// Server-side chat() handles conversion to ModelMessages
|
|
1056
2094
|
yield* rpcCall(messages, data, abortSignal)
|
|
1057
2095
|
},
|
|
2096
|
+
...(handlers?.hydrate ? { hydrate: handlers.hydrate } : {}),
|
|
2097
|
+
...(handlers?.hydrateGeneration
|
|
2098
|
+
? { hydrateGeneration: handlers.hydrateGeneration }
|
|
2099
|
+
: {}),
|
|
2100
|
+
...(handlers?.joinRun ? { joinRun: handlers.joinRun } : {}),
|
|
1058
2101
|
}
|
|
1059
2102
|
}
|