@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.
Files changed (72) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/audio-recorder.js +190 -213
  3. package/dist/esm/audio-recorder.js.map +1 -1
  4. package/dist/esm/chat-client.d.ts +172 -3
  5. package/dist/esm/chat-client.js +1656 -1386
  6. package/dist/esm/chat-client.js.map +1 -1
  7. package/dist/esm/cleared-stream-tracker.d.ts +23 -0
  8. package/dist/esm/cleared-stream-tracker.js +97 -0
  9. package/dist/esm/cleared-stream-tracker.js.map +1 -0
  10. package/dist/esm/client-persistor.d.ts +25 -12
  11. package/dist/esm/client-persistor.js +260 -235
  12. package/dist/esm/client-persistor.js.map +1 -1
  13. package/dist/esm/connection-adapters.d.ts +231 -10
  14. package/dist/esm/connection-adapters.js +989 -574
  15. package/dist/esm/connection-adapters.js.map +1 -1
  16. package/dist/esm/devtools-noop.d.ts +1 -0
  17. package/dist/esm/devtools-noop.js +79 -139
  18. package/dist/esm/devtools-noop.js.map +1 -1
  19. package/dist/esm/devtools.d.ts +31 -1
  20. package/dist/esm/devtools.js +977 -1127
  21. package/dist/esm/devtools.js.map +1 -1
  22. package/dist/esm/events.js +224 -226
  23. package/dist/esm/events.js.map +1 -1
  24. package/dist/esm/generation-client.d.ts +145 -2
  25. package/dist/esm/generation-client.js +659 -321
  26. package/dist/esm/generation-client.js.map +1 -1
  27. package/dist/esm/generation-reconstruct.d.ts +21 -0
  28. package/dist/esm/generation-reconstruct.js +85 -0
  29. package/dist/esm/generation-reconstruct.js.map +1 -0
  30. package/dist/esm/generation-types.d.ts +289 -3
  31. package/dist/esm/generation-types.js +356 -13
  32. package/dist/esm/generation-types.js.map +1 -1
  33. package/dist/esm/index.d.ts +9 -4
  34. package/dist/esm/index.js +7 -39
  35. package/dist/esm/interrupt-manager.d.ts +77 -0
  36. package/dist/esm/interrupt-manager.js +787 -0
  37. package/dist/esm/interrupt-manager.js.map +1 -0
  38. package/dist/esm/mcp-app-bridge.js +56 -64
  39. package/dist/esm/mcp-app-bridge.js.map +1 -1
  40. package/dist/esm/realtime-client.js +366 -440
  41. package/dist/esm/realtime-client.js.map +1 -1
  42. package/dist/esm/response-stream.js +19 -26
  43. package/dist/esm/response-stream.js.map +1 -1
  44. package/dist/esm/sse-parser.js +44 -47
  45. package/dist/esm/sse-parser.js.map +1 -1
  46. package/dist/esm/sse-utils.js +8 -9
  47. package/dist/esm/sse-utils.js.map +1 -1
  48. package/dist/esm/storage-adapters.d.ts +62 -0
  49. package/dist/esm/storage-adapters.js +174 -0
  50. package/dist/esm/storage-adapters.js.map +1 -0
  51. package/dist/esm/types.d.ts +212 -10
  52. package/dist/esm/types.js +38 -7
  53. package/dist/esm/types.js.map +1 -1
  54. package/dist/esm/video-generation-client.d.ts +113 -2
  55. package/dist/esm/video-generation-client.js +665 -379
  56. package/dist/esm/video-generation-client.js.map +1 -1
  57. package/package.json +7 -7
  58. package/src/chat-client.ts +1079 -61
  59. package/src/cleared-stream-tracker.ts +151 -0
  60. package/src/client-persistor.ts +102 -33
  61. package/src/connection-adapters.ts +1185 -142
  62. package/src/devtools-noop.ts +4 -3
  63. package/src/devtools.ts +121 -3
  64. package/src/generation-client.ts +563 -13
  65. package/src/generation-reconstruct.ts +121 -0
  66. package/src/generation-types.ts +727 -3
  67. package/src/index.ts +56 -1
  68. package/src/interrupt-manager.ts +1440 -0
  69. package/src/storage-adapters.ts +242 -0
  70. package/src/types.ts +301 -9
  71. package/src/video-generation-client.ts +479 -13
  72. 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
- return 'runId' in chunk && typeof chunk.runId === 'string'
32
- ? chunk.runId
33
- : chunkRunIds.get(chunk)
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
- const { done, value } = await reader.read()
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
- if (line.trim()) {
114
- yield line
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
- * Yield StreamChunks parsed from an SSE Response body.
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:` / `id:` / `retry:` SSE control fields. A `[DONE]` sentinel is
138
- * treated as a terminal event: a synthesized RUN_FINISHED is yielded using
139
- * the most recent upstream `threadId` / `runId`, ensuring the consumer sees
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* responseToSSEChunks(
145
- response: Response,
146
- abortSignal?: AbortSignal,
147
- ): AsyncGenerator<StreamChunk> {
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
- for await (const line of readStreamLines(reader, abortSignal)) {
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
- const message =
376
- err instanceof Error ? err.message : 'Unknown error in connect()'
377
- const synthetic: RunErrorEvent = {
378
- type: EventType.RUN_ERROR,
379
- threadId: requireSyntheticId(
380
- upstreamThreadId ?? runContext?.threadId,
381
- 'threadId',
382
- ),
383
- runId: requireSyntheticId(
384
- upstreamRunId ?? runContext?.runId,
385
- 'runId',
386
- ),
387
- timestamp: Date.now(),
388
- message,
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-4o',
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
- ): ConnectConnectionAdapter {
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
- const response = await fetchClient(resolvedUrl, {
528
- method: 'POST',
529
- headers: requestHeaders,
530
- body: JSON.stringify(requestBody),
531
- credentials: resolvedOptions.credentials || 'same-origin',
532
- ...(signal ? { signal } : {}),
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
- yield* responseToSSEChunks(response, abortSignal)
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-4o',
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
- ): ConnectConnectionAdapter {
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
- const response = await fetchClient(resolvedUrl, {
612
- method: 'POST',
613
- headers: requestHeaders,
614
- body: JSON.stringify(requestBody),
615
- credentials: resolvedOptions.credentials || 'same-origin',
616
- ...(signal ? { signal } : {}),
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
- if (!response.ok) {
620
- throw new Error(
621
- `HTTP error! status: ${response.status} ${response.statusText}`,
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
- // Parse raw HTTP stream (newline-delimited JSON)
626
- const reader = getResponseStreamReader(response)
627
-
628
- for await (const line of readStreamLines(reader, abortSignal)) {
629
- yield JSON.parse(line) as StreamChunk
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
- if (xhr.status < 200 || xhr.status >= 300) {
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
- error = new Error('XHR request failed')
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('POST', url)
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
- ): ConnectConnectionAdapter {
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
- const request = createConfiguredXhrRequest(
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
- messages,
838
- data,
839
- runContext,
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
- ): ConnectConnectionAdapter {
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
- const request = createConfiguredXhrRequest(
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
- messages,
908
- data,
909
- runContext,
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
  }