@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
@@ -1,9 +1,19 @@
1
- import { GENERATION_EVENTS } from './generation-types'
1
+ import {
2
+ GENERATION_EVENTS,
3
+ GENERATION_STREAM_TRUNCATED_MESSAGE,
4
+ GENERATION_UNRESTORABLE_RESULT_MESSAGE,
5
+ clientStateFromResumeStatus,
6
+ createGenerationHydrationError,
7
+ createGenerationResultSnapshot,
8
+ parseGenerationResumeSnapshot,
9
+ updateGenerationResumeSnapshot,
10
+ } from './generation-types'
2
11
  import { createNoOpGenerationDevtoolsBridge } from './devtools-noop'
3
12
  import { parseSSEResponse } from './sse-parser'
4
13
  import type { StreamChunk } from '@tanstack/ai/client'
5
14
  import type {
6
15
  ConnectConnectionAdapter,
16
+ GenerationHydrationResult,
7
17
  RunAgentInputContext,
8
18
  } from './connection-adapters'
9
19
  import type {
@@ -16,6 +26,9 @@ import type {
16
26
  GenerationClientOptions,
17
27
  GenerationClientState,
18
28
  GenerationFetcher,
29
+ GenerationRestoredResult,
30
+ GenerationResumeSnapshot,
31
+ GenerationResumeState,
19
32
  } from './generation-types'
20
33
 
21
34
  /**
@@ -33,6 +46,15 @@ interface GenerationCallbacks<TResult, TOutput> {
33
46
  onLoadingChange?: ((isLoading: boolean) => void) | undefined
34
47
  onErrorChange?: ((error: Error | undefined) => void) | undefined
35
48
  onStatusChange?: ((status: GenerationClientState) => void) | undefined
49
+ onResumeSnapshotChange?:
50
+ | ((snapshot: GenerationResumeSnapshot | undefined) => void)
51
+ | undefined
52
+ onResumeStateChange?:
53
+ | ((resumeState: GenerationResumeState | null) => void)
54
+ | undefined
55
+ reconstructResult?:
56
+ | ((restored: GenerationRestoredResult) => TResult | null)
57
+ | undefined
36
58
  }
37
59
 
38
60
  /**
@@ -77,10 +99,23 @@ export class GenerationClient<
77
99
  > {
78
100
  private readonly connection: ConnectConnectionAdapter | undefined
79
101
  private readonly fetcher: GenerationFetcher<TInput, TResult> | undefined
102
+ // Persistence handlers supplied as options (e.g. alongside a `fetcher`), used
103
+ // when the connection doesn't carry its own — the connection's handlers take
104
+ // precedence when both exist.
105
+ private readonly hydrateGenerationHandler:
106
+ | ConnectConnectionAdapter['hydrateGeneration']
107
+ | undefined
108
+ private readonly joinRunHandler:
109
+ | ConnectConnectionAdapter['joinRun']
110
+ | undefined
80
111
  private readonly uniqueId: string
81
112
  private readonly devtoolsMetadata: AIDevtoolsClientMetadata
82
113
  private readonly devtoolsBridge: GenerationDevtoolsBridge<TOutput>
83
114
  private readonly threadId: string
115
+ private readonly persistenceScope: string | undefined
116
+ // Server-driven mode (`persistence: true`): no local snapshot store; on mount
117
+ // the client hydrates the last generation for `threadId` from the server.
118
+ private readonly serverDriven: boolean = false
84
119
  private body: Record<string, any>
85
120
  private result: TOutput | null = null
86
121
  private input: TInput | null = null
@@ -88,9 +123,14 @@ export class GenerationClient<
88
123
  private isLoading = false
89
124
  private error: Error | undefined = undefined
90
125
  private status: GenerationClientState = 'idle'
126
+ private resumeSnapshot: GenerationResumeSnapshot | undefined
127
+ private lastEmittedResumeState: string | undefined
91
128
  private abortController: AbortController | null = null
129
+ private rejoinedRunId: string | undefined
92
130
  private readonly callbacksRef: GenerationCallbacks<TResult, TOutput>
93
131
  private devtoolsMounted = false
132
+ private disposed = false
133
+ private serverHydrationStarted = false
94
134
 
95
135
  constructor(
96
136
  options: GenerationClientOptions<TInput, TResult, TOutput> &
@@ -102,12 +142,35 @@ export class GenerationClient<
102
142
  }
103
143
  ),
104
144
  ) {
105
- this.uniqueId = options.id ?? this.generateUniqueId('generation')
106
- this.threadId = this.uniqueId
145
+ // `threadId` is the single identity. Deprecated `id` is only a fallback
146
+ // when no threadId is given (ephemeral runs / legacy call sites).
147
+ this.uniqueId =
148
+ options.threadId ?? options.id ?? this.generateUniqueId('generation')
149
+ // AG-UI requires a thread id on every run, so fall back to uniqueId. The
150
+ // generated fallback is for the WIRE ONLY: it is not stable across reloads,
151
+ // so persistence must never key on it — see `persistenceScope` below.
152
+ this.threadId = options.threadId ?? this.uniqueId
153
+ // The persistence scope: the explicit `threadId` and nothing else. The
154
+ // types require it whenever `persistence` is set; this field keeps the
155
+ // fallback from silently becoming a storage key for JS callers.
156
+ this.persistenceScope = options.threadId
107
157
  this.connection = options.connection
108
158
  this.fetcher = options.fetcher
159
+ this.hydrateGenerationHandler = options.hydrateGeneration
160
+ this.joinRunHandler = options.joinRun
109
161
  this.body = options.body ?? {}
110
-
162
+ // `persistence` is `false`/omitted (ephemeral) or `true` (server-driven:
163
+ // hydrate the last generation for `threadId` from the server on mount).
164
+ this.serverDriven = options.persistence === true
165
+ // The types require `threadId` alongside `persistence`, so this only fires
166
+ // for JS callers. Warn rather than fall back silently: keying on the
167
+ // generated wire id would write a different slot every reload, restoring
168
+ // nothing while accumulating orphaned records.
169
+ if (options.persistence && !this.persistenceScope) {
170
+ console.warn(
171
+ '[TanStack AI] `persistence` needs a stable `threadId` to key on. Without one nothing will be restored after a reload. Pass a `threadId` derived from your own domain (e.g. `product-123-hero`).',
172
+ )
173
+ }
111
174
  this.callbacksRef = {
112
175
  onResult: options.onResult,
113
176
  onError: options.onError,
@@ -117,12 +180,22 @@ export class GenerationClient<
117
180
  onLoadingChange: options.onLoadingChange,
118
181
  onErrorChange: options.onErrorChange,
119
182
  onStatusChange: options.onStatusChange,
183
+ onResumeSnapshotChange: options.onResumeSnapshotChange,
184
+ onResumeStateChange: options.onResumeStateChange,
185
+ reconstructResult: options.reconstructResult,
120
186
  }
121
187
 
122
188
  this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
123
189
  this.devtoolsBridge = (
124
190
  options.devtoolsBridgeFactory ?? createNoOpGenerationDevtoolsBridge
125
191
  )<TOutput>(this.buildDevtoolsBridgeOptions())
192
+
193
+ // Mount hydration (`maybeHydrateFromServer`) is deliberately NOT run here. The framework
194
+ // hooks build this client inside `useMemo`, so the constructor executes in
195
+ // React's render phase; hydrating here would re-fire the hydrate GET on
196
+ // every discarded/speculative render, flooding the connection pool when
197
+ // several clients mount together. It is kicked off once from
198
+ // `mountDevtools`, which the hooks call from a commit-phase mount effect.
126
199
  }
127
200
 
128
201
  private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions<TOutput> {
@@ -143,6 +216,19 @@ export class GenerationClient<
143
216
  }
144
217
 
145
218
  mountDevtools(): void {
219
+ // Mounting revives a disposed client. Framework hooks call this from
220
+ // their mount effect, so a dispose → remount cycle (e.g. React
221
+ // StrictMode's mount → cleanup → mount replay against the same memoized
222
+ // client) leaves the client usable again.
223
+ this.disposed = false
224
+ this.maybeHydrateFromServer()
225
+ // Re-attach to an in-flight run whose snapshot is already loaded — the
226
+ // remount case. On the FIRST mount the snapshot loads asynchronously and
227
+ // `repaintRestoredSnapshot` starts the rejoin; on a StrictMode remount the
228
+ // snapshot is already present but the prior rejoin was aborted by
229
+ // `dispose()`, so retrigger it here. Guarded by `rejoinInFlight`'s own
230
+ // dedupe/in-flight checks, so this never double-joins.
231
+ this.maybeResumeInFlight()
146
232
  if (this.devtoolsMounted) {
147
233
  return
148
234
  }
@@ -158,8 +244,9 @@ export class GenerationClient<
158
244
  * while already generating will be a no-op.
159
245
  */
160
246
  async generate(input: TInput): Promise<void> {
161
- this.mountDevtools()
247
+ if (this.disposed) return
162
248
  if (this.isLoading) return
249
+ this.mountDevtools()
163
250
 
164
251
  this.input = input
165
252
  this.progress = null
@@ -179,11 +266,16 @@ export class GenerationClient<
179
266
  if (signal.aborted) return
180
267
  if (result instanceof Response) {
181
268
  // Server function returned SSE Response — parse stream
182
- await this.processStream(parseSSEResponse(result, signal), runId)
269
+ await this.processStream(
270
+ parseSSEResponse(result, signal),
271
+ runId,
272
+ signal,
273
+ )
183
274
  } else {
184
275
  this.devtoolsBridge.ensureRunStarted(runId)
185
276
  this.setResult(result)
186
277
  this.setStatus('success')
278
+ this.completePlainFetcherResumeSnapshot(result)
187
279
  }
188
280
  } else if (this.connection) {
189
281
  // Streaming adapter path
@@ -194,7 +286,7 @@ export class GenerationClient<
194
286
  signal,
195
287
  this.createRunContext(runId),
196
288
  )
197
- await this.processStream(stream, runId)
289
+ await this.processStream(stream, runId, signal)
198
290
  } else {
199
291
  throw new Error(
200
292
  'GenerationClient requires either a connection or fetcher option',
@@ -217,6 +309,7 @@ export class GenerationClient<
217
309
  const error = err instanceof Error ? err : new Error(String(err))
218
310
  this.setError(error)
219
311
  this.setStatus('error')
312
+ this.recordResumeSnapshotError(error)
220
313
  this.devtoolsBridge.finishRun(
221
314
  this.devtoolsBridge.getActiveRunId() ?? runId,
222
315
  'run:errored',
@@ -225,24 +318,38 @@ export class GenerationClient<
225
318
  )
226
319
  this.callbacksRef.onError?.(error)
227
320
  } finally {
228
- this.abortController = null
229
- this.setIsLoading(false)
321
+ if (this.abortController === abortController) {
322
+ this.abortController = null
323
+ this.setIsLoading(false)
324
+ }
230
325
  }
231
326
  }
232
327
 
233
328
  /**
234
329
  * Process a stream of AG-UI events from the streaming connection adapter.
330
+ *
331
+ * Throws {@link GENERATION_STREAM_TRUNCATED_MESSAGE} when the iteration ends
332
+ * without a terminal chunk. A `for await` over a stream that simply stops —
333
+ * proxy idle timeout, server restart, a durable log missing its terminal
334
+ * append — returns normally and would otherwise leave the caller's `status`
335
+ * on `generating` forever, with the persisted snapshot still `running` so
336
+ * every reload rejoins the same dead run. Throwing routes it through the
337
+ * caller's error path instead, which settles the status and rewrites the
338
+ * snapshot so nothing chases it again.
235
339
  */
236
340
  private async processStream(
237
341
  source: AsyncIterable<StreamChunk>,
238
342
  fallbackRunId: string,
343
+ signal: AbortSignal,
239
344
  ): Promise<void> {
240
345
  let streamRunId: string | undefined
346
+ let sawTerminalChunk = false
241
347
 
242
348
  for await (const chunk of source) {
243
- if (this.abortController?.signal.aborted) break
349
+ if (signal.aborted) break
244
350
 
245
351
  this.callbacksRef.onChunk?.(chunk)
352
+ this.observeResumeSnapshot(chunk)
246
353
  const chunkRunId =
247
354
  'runId' in chunk && typeof chunk.runId === 'string'
248
355
  ? chunk.runId
@@ -270,6 +377,7 @@ export class GenerationClient<
270
377
  }
271
378
  case 'RUN_FINISHED': {
272
379
  streamRunId = chunk.runId
380
+ sawTerminalChunk = true
273
381
  this.devtoolsBridge.ensureRunStarted(chunk.runId)
274
382
  this.setStatus('success')
275
383
  break
@@ -289,6 +397,11 @@ export class GenerationClient<
289
397
  break
290
398
  }
291
399
  }
400
+
401
+ // An aborted read is a deliberate stop/dispose, not a truncation.
402
+ if (!sawTerminalChunk && !signal.aborted) {
403
+ throw new Error(GENERATION_STREAM_TRUNCATED_MESSAGE)
404
+ }
292
405
  }
293
406
 
294
407
  /**
@@ -307,10 +420,24 @@ export class GenerationClient<
307
420
  this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled')
308
421
  }
309
422
  }
423
+ // A stopped run is no longer resumable. Without this the in-memory
424
+ // snapshot stays `running`, and a remount's `maybeResumeInFlight` would
425
+ // rejoin a run the user just cancelled.
426
+ if (this.resumeSnapshot && this.resumeSnapshot.status === 'running') {
427
+ this.resumeSnapshot = {
428
+ ...this.resumeSnapshot,
429
+ resumeState: null,
430
+ status: 'idle',
431
+ }
432
+ this.notifyResumeSnapshotChanged()
433
+ }
310
434
  }
311
435
 
312
436
  /**
313
- * Clear the result, error, and return to idle state.
437
+ * Clear the result, error, and return to idle state. Also drops the client's
438
+ * in-memory resume snapshot, so a remount restores nothing. The server-side
439
+ * record is untouched — this client no longer writes one — so a full page
440
+ * reload under `persistence: true` re-hydrates the last generation again.
314
441
  */
315
442
  reset(): void {
316
443
  this.stop()
@@ -320,6 +447,7 @@ export class GenerationClient<
320
447
  this.devtoolsBridge.resetRuns()
321
448
  this.setError(undefined)
322
449
  this.setStatus('idle')
450
+ this.clearResumeSnapshot()
323
451
  this.devtoolsBridge.emitState()
324
452
  }
325
453
 
@@ -352,9 +480,27 @@ export class GenerationClient<
352
480
  }
353
481
 
354
482
  dispose(): void {
355
- this.stop()
483
+ this.disposed = true
484
+ // Teardown, NOT a user cancel: abort in-flight DELIVERY (this reader) but
485
+ // do NOT call `stop()` — `stop()` marks the run non-resumable and wipes the
486
+ // `running` snapshot, which is correct for a Stop button but wrong for an
487
+ // unmount / React StrictMode dispose. Clearing it here would destroy the
488
+ // in-memory resume state, so a remount of this same client instance could
489
+ // never rejoin. (A real page revisit re-hydrates from the server instead.)
490
+ // The run itself survives server-side (durable delivery), so the snapshot
491
+ // must stay `running` for the remount to resume it.
492
+ if (this.abortController) {
493
+ this.abortController.abort()
494
+ this.abortController = null
495
+ }
496
+ this.setIsLoading(false)
356
497
  this.devtoolsBridge.dispose()
357
498
  this.devtoolsMounted = false
499
+ // Re-arm mount hydration + rejoin so a remount resumes from the (preserved)
500
+ // snapshot. `mountDevtools` re-runs the hydration entry point and
501
+ // `maybeResumeInFlight`, both individually guarded.
502
+ this.serverHydrationStarted = false
503
+ this.rejoinedRunId = undefined
358
504
  }
359
505
 
360
506
  // ===========================
@@ -377,6 +523,33 @@ export class GenerationClient<
377
523
  return this.status
378
524
  }
379
525
 
526
+ getResumeSnapshot(): GenerationResumeSnapshot | undefined {
527
+ return this.resumeSnapshot
528
+ ? {
529
+ ...this.resumeSnapshot,
530
+ ...(this.resumeSnapshot.pendingArtifacts
531
+ ? { pendingArtifacts: [...this.resumeSnapshot.pendingArtifacts] }
532
+ : {}),
533
+ ...(this.resumeSnapshot.result
534
+ ? {
535
+ result: {
536
+ ...this.resumeSnapshot.result,
537
+ ...(this.resumeSnapshot.result.artifacts
538
+ ? { artifacts: [...this.resumeSnapshot.result.artifacts] }
539
+ : {}),
540
+ },
541
+ }
542
+ : {}),
543
+ ...(this.resumeSnapshot.error
544
+ ? { error: { ...this.resumeSnapshot.error } }
545
+ : {}),
546
+ ...(this.resumeSnapshot.lastEvent
547
+ ? { lastEvent: { ...this.resumeSnapshot.lastEvent } }
548
+ : {}),
549
+ }
550
+ : undefined
551
+ }
552
+
380
553
  // ===========================
381
554
  // Private state setters
382
555
  // ===========================
@@ -408,7 +581,7 @@ export class GenerationClient<
408
581
  // No onResult callback, or callback returned void → use raw value as
409
582
  // TOutput. When the caller did not supply an onResult transform,
410
583
  // `TOutput` defaults to `TResult`, so the runtime cast is sound.
411
- // eslint-disable-next-line no-restricted-syntax -- TOutput defaults to TResult when no onResult transform is supplied
584
+ // oxlint-disable-next-line eslint-js/no-restricted-syntax -- TOutput defaults to TResult when no onResult transform is supplied
412
585
  this.result = rawResult as unknown as TOutput
413
586
  this.callbacksRef.onResultChange?.(this.result)
414
587
  this.devtoolsBridge.recordResultChange()
@@ -466,6 +639,383 @@ export class GenerationClient<
466
639
  runId,
467
640
  }
468
641
  }
642
+
643
+ private observeResumeSnapshot(chunk: StreamChunk): void {
644
+ this.resumeSnapshot = updateGenerationResumeSnapshot(
645
+ this.resumeSnapshot,
646
+ chunk,
647
+ )
648
+ this.notifyResumeSnapshotChanged()
649
+ }
650
+
651
+ /**
652
+ * Notify the (internal) snapshot listener AND emit the public resume state.
653
+ * The snapshot stays internal (persistence + devtools); the hook consumes
654
+ * `resumeState`, mirroring the chat client.
655
+ */
656
+ private notifyResumeSnapshotChanged(): void {
657
+ this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot)
658
+ this.emitResumeState()
659
+ }
660
+
661
+ /**
662
+ * Derive the public `resumeState` from the internal snapshot: the in-flight
663
+ * run identity, with any in-flight artifact refs folded under it. `null` once
664
+ * no run is in flight.
665
+ *
666
+ * The snapshot is rebuilt for every chunk, so emitting unconditionally would
667
+ * hand each framework hook a fresh object per chunk and re-render the
668
+ * component on every stream event. `resumeState` only changes at run
669
+ * boundaries and when artifacts land, so skip the notification unless it
670
+ * materially changed — same gate the persistence writes use.
671
+ */
672
+ private emitResumeState(): void {
673
+ const snapshot = this.resumeSnapshot
674
+ const state = snapshot?.resumeState
675
+ const resumeState: GenerationResumeState | null = state
676
+ ? {
677
+ ...state,
678
+ ...(snapshot?.pendingArtifacts && snapshot.pendingArtifacts.length > 0
679
+ ? { pendingArtifacts: [...snapshot.pendingArtifacts] }
680
+ : {}),
681
+ }
682
+ : null
683
+ const signature = JSON.stringify(resumeState)
684
+ if (signature === this.lastEmittedResumeState) {
685
+ return
686
+ }
687
+ this.lastEmittedResumeState = signature
688
+ this.callbacksRef.onResumeStateChange?.(resumeState)
689
+ }
690
+
691
+ /**
692
+ * Repaint the normal fields from a restored snapshot (client store or server
693
+ * hydrate), so a reload presents the run in `result` / `status` / `error`
694
+ * exactly as a just-finished run would, never a bolt-on snapshot object.
695
+ * `isLoading` stays false: the client never auto-tails a restored run. The
696
+ * snapshot is not re-persisted here (it came from storage / the server).
697
+ *
698
+ * When the activity's mapper DECLINES a `complete` snapshot the repaint
699
+ * settles as an error instead: `success` with a `null` result is a state no
700
+ * consumer can render, and it hides the real cause (an output artifact
701
+ * persisted without a serve URL). A decline on any other status is expected —
702
+ * a `running` snapshot has no result yet, the rejoin will deliver it.
703
+ */
704
+ private repaintFromSnapshot(snapshot: GenerationResumeSnapshot): void {
705
+ this.resumeSnapshot = snapshot
706
+ this.notifyResumeSnapshotChanged()
707
+ this.setStatus(clientStateFromResumeStatus(snapshot.status))
708
+ this.setError(
709
+ snapshot.error
710
+ ? Object.assign(
711
+ new Error(snapshot.error.message),
712
+ snapshot.error.code ? { code: snapshot.error.code } : {},
713
+ )
714
+ : undefined,
715
+ )
716
+ const restored = this.reconstructRestoredResult(snapshot)
717
+ if (restored !== null) {
718
+ this.setResult(restored)
719
+ } else if (
720
+ this.callbacksRef.reconstructResult &&
721
+ snapshot.status === 'complete'
722
+ ) {
723
+ this.reportUnrestorableResult()
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Report a `complete` snapshot the activity's mapper could not rebuild.
729
+ * Runs after the status/error repaint above, so it wins over the snapshot's
730
+ * own `complete` status.
731
+ */
732
+ private reportUnrestorableResult(): void {
733
+ const error = new Error(GENERATION_UNRESTORABLE_RESULT_MESSAGE)
734
+ this.setStatus('error')
735
+ this.setError(error)
736
+ this.callbacksRef.onError?.(error)
737
+ }
738
+
739
+ /**
740
+ * Repaint a restored snapshot (client store or server hydrate) and, when it
741
+ * reports a run still in flight, tail that run to completion via `joinRun`
742
+ * (from the connection, or the `joinRun` option when the transport can't
743
+ * carry one).
744
+ *
745
+ * A `running` snapshot that no `joinRun` handler can tail is repainted as an
746
+ * interrupted error instead of a `generating` status that would never
747
+ * settle: an interrupted generation cannot be resumed, only re-run.
748
+ */
749
+ private repaintRestoredSnapshot(
750
+ snapshot: GenerationResumeSnapshot,
751
+ activeRunId?: string,
752
+ ): void {
753
+ if (snapshot.status !== 'running') {
754
+ this.repaintFromSnapshot(snapshot)
755
+ return
756
+ }
757
+ const joinRun = this.connection?.joinRun ?? this.joinRunHandler
758
+ const runId = activeRunId ?? snapshot.resumeState?.runId
759
+ if (runId && joinRun) {
760
+ this.repaintFromSnapshot(snapshot)
761
+ this.rejoinInFlight(runId)
762
+ return
763
+ }
764
+ this.repaintFromSnapshot({
765
+ ...snapshot,
766
+ resumeState: null,
767
+ status: 'error',
768
+ error: {
769
+ message:
770
+ 'The previous generation was interrupted before it finished and cannot be resumed — generate again to retry.',
771
+ },
772
+ })
773
+ }
774
+
775
+ /**
776
+ * Build the restorable result shape from the snapshot and hand it to the
777
+ * per-activity `reconstructResult` mapper (injected by the specialized
778
+ * client/hook, which knows the concrete result type).
779
+ *
780
+ * Returns `null` both when no mapper is set (nothing to rebuild — `result`
781
+ * simply stays null) and when the mapper declines. The caller distinguishes
782
+ * the two: see {@link repaintFromSnapshot}.
783
+ */
784
+ private reconstructRestoredResult(
785
+ snapshot: GenerationResumeSnapshot,
786
+ ): TResult | null {
787
+ const build = this.callbacksRef.reconstructResult
788
+ if (!build) return null
789
+ const result = snapshot.result
790
+ const restored: GenerationRestoredResult = {
791
+ ...(result?.id !== undefined ? { id: result.id } : {}),
792
+ ...(result?.model !== undefined ? { model: result.model } : {}),
793
+ ...(result?.status !== undefined ? { status: result.status } : {}),
794
+ ...(result?.providerJobId !== undefined
795
+ ? { providerJobId: result.providerJobId }
796
+ : {}),
797
+ ...(result?.expiresAt !== undefined
798
+ ? { expiresAt: result.expiresAt }
799
+ : {}),
800
+ ...(result?.text !== undefined ? { text: result.text } : {}),
801
+ ...(result?.usage !== undefined ? { usage: result.usage } : {}),
802
+ ...(snapshot.activity !== undefined
803
+ ? { activity: snapshot.activity }
804
+ : {}),
805
+ artifacts: result?.artifacts ?? [],
806
+ }
807
+ return build(restored)
808
+ }
809
+
810
+ /**
811
+ * The plain (non-Response) fetcher path never observes stream chunks, so
812
+ * the terminal snapshot is built here from the fetcher's own result. A
813
+ * stale `error` from a previous run is intentionally dropped — this run
814
+ * succeeded.
815
+ */
816
+ private completePlainFetcherResumeSnapshot(rawResult: unknown): void {
817
+ const previous = this.resumeSnapshot
818
+ const result = createGenerationResultSnapshot(rawResult)
819
+ this.resumeSnapshot = {
820
+ schemaVersion: 1,
821
+ resumeState: null,
822
+ status: 'complete',
823
+ ...(previous?.activity ? { activity: previous.activity } : {}),
824
+ ...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0
825
+ ? { pendingArtifacts: [...previous.pendingArtifacts] }
826
+ : {}),
827
+ ...(result
828
+ ? { result }
829
+ : previous?.result
830
+ ? { result: { ...previous.result } }
831
+ : {}),
832
+ }
833
+ this.notifyResumeSnapshotChanged()
834
+ }
835
+
836
+ /**
837
+ * Records a transport-level failure (network drop, throwing callback) in
838
+ * the snapshot. Without this, only a server-emitted RUN_ERROR chunk would
839
+ * mark the snapshot `error`, leaving a persisted record that claims the
840
+ * run is still in flight.
841
+ */
842
+ private recordResumeSnapshotError(error: Error): void {
843
+ // Surface the failure on the OBSERVABLE fields FIRST: a rejoin (or live
844
+ // stream) that emits RUN_ERROR has already flipped the snapshot to `error`
845
+ // via `observeResumeSnapshot`, so the early-return below would otherwise
846
+ // skip this and leave `status` stuck on `generating` — the run would look
847
+ // like it is still going forever. The guard avoids a duplicate `error`
848
+ // emission on the live `generate()` path, which sets the status itself.
849
+ if (this.status !== 'error') this.setStatus('error')
850
+ this.setError(error)
851
+ if (this.resumeSnapshot?.status === 'error') return
852
+ if (!this.resumeSnapshot && !this.serverDriven) return
853
+ const previous = this.resumeSnapshot
854
+ this.resumeSnapshot = {
855
+ schemaVersion: 1,
856
+ resumeState: null,
857
+ status: 'error',
858
+ ...(previous?.activity ? { activity: previous.activity } : {}),
859
+ ...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0
860
+ ? { pendingArtifacts: [...previous.pendingArtifacts] }
861
+ : {}),
862
+ ...(previous?.result ? { result: { ...previous.result } } : {}),
863
+ error: { message: error.message },
864
+ }
865
+ this.notifyResumeSnapshotChanged()
866
+ }
867
+
868
+ /**
869
+ * Drop the client's in-memory snapshot and re-emit. Purely local — this
870
+ * client writes no storage, so nothing persisted is removed.
871
+ */
872
+ private clearResumeSnapshot(): void {
873
+ this.resumeSnapshot = undefined
874
+ this.lastEmittedResumeState = undefined
875
+ this.notifyResumeSnapshotChanged()
876
+ }
877
+
878
+ /**
879
+ * Server-driven mount hydration entry point (`persistence: true`). Runs at
880
+ * most once, from the commit-phase mount path (`mountDevtools`) — never the
881
+ * constructor / render phase — so remounts and speculative renders can't
882
+ * re-fire the hydrate GET.
883
+ */
884
+ private maybeHydrateFromServer(): void {
885
+ if (!this.serverDriven || this.serverHydrationStarted) return
886
+ this.serverHydrationStarted = true
887
+ if (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) {
888
+ this.hydrateFromServer()
889
+ } else {
890
+ // `persistence: true` without any hydrate source can never restore
891
+ // anything — warn rather than silently no-op.
892
+ console.warn(
893
+ '[TanStack AI] `persistence: true` (server-driven) needs a `hydrateGeneration` handler — either a connection that implements one (e.g. `fetchServerSentEvents` / `fetchHttpStream`, or `stream()` / `rpcStream()` with persistence handlers) or the `hydrateGeneration` option. Without one, nothing is persisted or restored.',
894
+ )
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Server-driven mount hydration (`persistence: true`). The client holds no
900
+ * local snapshot; on mount it asks the server — keyed by the stable threadId —
901
+ * for the last generation's resume snapshot, validates it, and repaints it. It
902
+ * never auto-starts a run, and never blocks: a `generate()` that starts first
903
+ * owns the client and hydration backs off, mirroring the chat client.
904
+ *
905
+ * A genuine **miss** (the server reports no record for the thread) is silent —
906
+ * a fresh thread is not an error. A genuine **failure** (transport error, a
907
+ * 403 from the authorize gate, a malformed body, a record the client's own
908
+ * validator rejects) is surfaced through `status` / `error` / `onError`, so a
909
+ * broken server is distinguishable from an empty one and the app can retry.
910
+ */
911
+ private hydrateFromServer(): void {
912
+ const hydrate =
913
+ this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler
914
+ if (!hydrate) return
915
+ // A send that already started owns the client; don't stomp it.
916
+ if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return
917
+ void (async () => {
918
+ let res: GenerationHydrationResult
919
+ try {
920
+ res = await hydrate(this.threadId)
921
+ } catch (cause) {
922
+ this.failHydration(
923
+ createGenerationHydrationError(
924
+ 'the request to the server did not succeed',
925
+ cause,
926
+ ),
927
+ )
928
+ return
929
+ }
930
+ // No record for this thread — a fresh thread, not a failure.
931
+ if (!res.resumeSnapshot) return
932
+ const snapshot = parseGenerationResumeSnapshot(res.resumeSnapshot)
933
+ if (!snapshot) {
934
+ this.failHydration(
935
+ createGenerationHydrationError(
936
+ 'the server returned a record this client cannot read (unknown schema version, or a missing/invalid `status` or `resumeState`)',
937
+ ),
938
+ )
939
+ return
940
+ }
941
+ // Re-check: a send may have started while the fetch was in flight.
942
+ if (this.resumeSnapshot || this.isLoading || this.status !== 'idle')
943
+ return
944
+ // A run still generating on the server: re-attach and finish it in place.
945
+ this.repaintRestoredSnapshot(snapshot, res.activeRun?.runId)
946
+ })()
947
+ }
948
+
949
+ /**
950
+ * Surface a hydration failure on the observable fields. Skipped when a
951
+ * `generate()` took ownership while the hydrate GET was in flight — the live
952
+ * run's state must win over a stale mount-time failure.
953
+ */
954
+ private failHydration(error: Error): void {
955
+ if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return
956
+ this.setStatus('error')
957
+ this.setError(error)
958
+ this.callbacksRef.onError?.(error)
959
+ }
960
+
961
+ /**
962
+ * Re-attach to an already-loaded `running` snapshot (the remount case). Safe
963
+ * to call repeatedly: `rejoinInFlight` dedupes on `rejoinedRunId` and bails
964
+ * when a run is already in flight, so on the first mount (where the rejoin was
965
+ * already started from `repaintRestoredSnapshot`) this is a no-op.
966
+ */
967
+ private maybeResumeInFlight(): void {
968
+ if (this.resumeSnapshot?.status !== 'running') return
969
+ const runId = this.resumeSnapshot.resumeState?.runId
970
+ if (runId) this.rejoinInFlight(runId)
971
+ }
972
+
973
+ /**
974
+ * Re-attach to a run that is still generating and stream it to completion,
975
+ * mirroring the chat client's mount-time rejoin. Reuses `processStream`, so
976
+ * `result` / `progress` / `status` repaint from the replayed chunks exactly as
977
+ * a live run does. Best-effort: a live `generate()` owns the client and is
978
+ * never stomped, and the same run is only rejoined once.
979
+ */
980
+ private rejoinInFlight(runId: string): void {
981
+ const joinRun = this.connection?.joinRun ?? this.joinRunHandler
982
+ if (!joinRun) return
983
+ if (this.rejoinedRunId === runId) return
984
+ // A fresh send (or an in-progress rejoin) owns the client.
985
+ if (this.isLoading || this.abortController) return
986
+ this.rejoinedRunId = runId
987
+ const controller = new AbortController()
988
+ this.abortController = controller
989
+ this.setIsLoading(true)
990
+ this.setStatus('generating')
991
+ void (async () => {
992
+ try {
993
+ await this.processStream(
994
+ joinRun(runId, controller.signal),
995
+ runId,
996
+ controller.signal,
997
+ )
998
+ } catch (error) {
999
+ if (!controller.signal.aborted) {
1000
+ const failure =
1001
+ error instanceof Error ? error : new Error(String(error))
1002
+ // Settles `status`/`error` AND rewrites the snapshot to a terminal
1003
+ // `error` with a null `resumeState`, so the next mount does not
1004
+ // rejoin this run again.
1005
+ this.recordResumeSnapshotError(failure)
1006
+ this.callbacksRef.onError?.(failure)
1007
+ }
1008
+ } finally {
1009
+ // Only reset if this rejoin still owns the client: a `stop()` +
1010
+ // fresh `generate()` may have replaced the controller while the tail
1011
+ // was settling, and that live run owns `isLoading` now.
1012
+ if (this.abortController === controller) {
1013
+ this.abortController = null
1014
+ this.setIsLoading(false)
1015
+ }
1016
+ }
1017
+ })()
1018
+ }
469
1019
  }
470
1020
 
471
1021
  function completeProgressValue(