@tanstack/ai-client 0.22.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 { createNoOpVideoDevtoolsBridge } 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 {
@@ -15,6 +25,8 @@ import type {
15
25
  import type {
16
26
  GenerationClientState,
17
27
  GenerationFetcher,
28
+ GenerationResumeSnapshot,
29
+ GenerationResumeState,
18
30
  VideoGenerateInput,
19
31
  VideoGenerateResult,
20
32
  VideoGenerationClientOptions,
@@ -42,6 +54,12 @@ interface VideoCallbacks<TOutput> {
42
54
  onStatusChange?: ((status: GenerationClientState) => void) | undefined
43
55
  onJobIdChange?: ((jobId: string | null) => void) | undefined
44
56
  onVideoStatusChange?: ((status: VideoStatusInfo | null) => void) | undefined
57
+ onResumeSnapshotChange?:
58
+ | ((snapshot: GenerationResumeSnapshot | undefined) => void)
59
+ | undefined
60
+ onResumeStateChange?:
61
+ | ((resumeState: GenerationResumeState | null) => void)
62
+ | undefined
45
63
  }
46
64
 
47
65
  /**
@@ -81,6 +99,15 @@ interface VideoCallbacks<TOutput> {
81
99
  */
82
100
  export class VideoGenerationClient<TOutput = VideoGenerateResult> {
83
101
  private readonly connection: ConnectConnectionAdapter | 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
84
111
  private readonly fetcher:
85
112
  | GenerationFetcher<VideoGenerateInput, VideoGenerateResult>
86
113
  | undefined
@@ -88,6 +115,9 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
88
115
  private readonly devtoolsMetadata: AIDevtoolsClientMetadata
89
116
  private readonly devtoolsBridge: VideoDevtoolsBridge<TOutput>
90
117
  private readonly threadId: string
118
+ // Server-driven mode (`persistence: true`): no local snapshot store; on mount
119
+ // the client hydrates the last generation for `threadId` from the server.
120
+ private readonly serverDriven: boolean = false
91
121
  private body: Record<string, any>
92
122
 
93
123
  private result: TOutput | null = null
@@ -98,9 +128,13 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
98
128
  private isLoading = false
99
129
  private error: Error | undefined = undefined
100
130
  private status: GenerationClientState = 'idle'
131
+ private resumeSnapshot: GenerationResumeSnapshot | undefined
101
132
  private abortController: AbortController | null = null
133
+ private rejoinedRunId: string | undefined
102
134
  private readonly callbacksRef: VideoCallbacks<TOutput>
103
135
  private devtoolsMounted = false
136
+ private disposed = false
137
+ private serverHydrationStarted = false
104
138
 
105
139
  constructor(
106
140
  options: VideoGenerationClientOptions<TOutput> &
@@ -112,12 +146,21 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
112
146
  }
113
147
  ),
114
148
  ) {
115
- this.uniqueId = options.id ?? this.generateUniqueId('video')
116
- this.threadId = this.uniqueId
149
+ // `threadId` is the single identity. Deprecated `id` is only a fallback
150
+ // when no threadId is given (ephemeral runs / legacy call sites).
151
+ this.uniqueId =
152
+ options.threadId ?? options.id ?? this.generateUniqueId('video')
153
+ // The wire/hydration thread key. Server-driven mode needs a stable key, so
154
+ // prefer an explicit `threadId`, then legacy `id`, then a generated id.
155
+ this.threadId = options.threadId ?? this.uniqueId
117
156
  this.connection = options.connection
118
157
  this.fetcher = options.fetcher
158
+ this.hydrateGenerationHandler = options.hydrateGeneration
159
+ this.joinRunHandler = options.joinRun
119
160
  this.body = options.body ?? {}
120
-
161
+ // `persistence` is `false`/omitted (ephemeral) or `true` (server-driven:
162
+ // hydrate the last generation for `threadId` from the server on mount).
163
+ this.serverDriven = options.persistence === true
121
164
  this.callbacksRef = {
122
165
  onResult: options.onResult,
123
166
  onError: options.onError,
@@ -131,12 +174,21 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
131
174
  onStatusChange: options.onStatusChange,
132
175
  onJobIdChange: options.onJobIdChange,
133
176
  onVideoStatusChange: options.onVideoStatusChange,
177
+ onResumeSnapshotChange: options.onResumeSnapshotChange,
178
+ onResumeStateChange: options.onResumeStateChange,
134
179
  }
135
180
 
136
181
  this.devtoolsMetadata = this.createDevtoolsMetadata(options.devtools)
137
182
  this.devtoolsBridge = (
138
183
  options.devtoolsBridgeFactory ?? createNoOpVideoDevtoolsBridge
139
184
  )<TOutput>(this.buildDevtoolsBridgeOptions())
185
+
186
+ // Mount hydration (`maybeHydrateFromServer`) is deliberately NOT run here. The framework
187
+ // hooks build this client inside `useMemo`, so the constructor executes in
188
+ // React's render phase; hydrating here would re-fire the hydrate GET on
189
+ // every discarded/speculative render, flooding the connection pool when
190
+ // several clients mount together. It is kicked off once from
191
+ // `mountDevtools`, which the hooks call from a commit-phase mount effect.
140
192
  }
141
193
 
142
194
  private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions<TOutput> {
@@ -159,6 +211,15 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
159
211
  }
160
212
 
161
213
  mountDevtools(): void {
214
+ // Mounting revives a disposed client. Framework hooks call this from
215
+ // their mount effect, so a dispose → remount cycle (e.g. React
216
+ // StrictMode's mount → cleanup → mount replay against the same memoized
217
+ // client) leaves the client usable again.
218
+ this.disposed = false
219
+ this.maybeHydrateFromServer()
220
+ // Re-attach to an already-loaded `running` snapshot (remount case); see the
221
+ // note in GenerationClient.mountDevtools. Guarded by rejoinInFlight.
222
+ this.maybeResumeInFlight()
162
223
  if (this.devtoolsMounted) {
163
224
  return
164
225
  }
@@ -173,8 +234,9 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
173
234
  * Only one generation can be in-flight at a time.
174
235
  */
175
236
  async generate(input: VideoGenerateInput): Promise<void> {
176
- this.mountDevtools()
237
+ if (this.disposed) return
177
238
  if (this.isLoading) return
239
+ this.mountDevtools()
178
240
 
179
241
  this.input = input
180
242
  this.progress = null
@@ -200,7 +262,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
200
262
  signal,
201
263
  this.createRunContext(runId),
202
264
  )
203
- await this.processStream(stream, runId)
265
+ await this.processStream(stream, runId, signal)
204
266
  } else {
205
267
  throw new Error(
206
268
  'VideoGenerationClient requires either a connection or fetcher option',
@@ -218,6 +280,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
218
280
  const error = err instanceof Error ? err : new Error(String(err))
219
281
  this.setError(error)
220
282
  this.setStatus('error')
283
+ this.recordResumeSnapshotError(error)
221
284
  this.devtoolsBridge.finishRun(
222
285
  this.devtoolsBridge.getActiveRunId() ?? runId,
223
286
  'run:errored',
@@ -226,8 +289,10 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
226
289
  )
227
290
  this.callbacksRef.onError?.(error)
228
291
  } finally {
229
- this.abortController = null
230
- this.setIsLoading(false)
292
+ if (this.abortController === abortController) {
293
+ this.abortController = null
294
+ this.setIsLoading(false)
295
+ }
231
296
  }
232
297
  }
233
298
 
@@ -247,28 +312,37 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
247
312
 
248
313
  if (result instanceof Response) {
249
314
  // Server function returned SSE Response — parse stream
250
- await this.processStream(parseSSEResponse(result, signal), runId)
315
+ await this.processStream(parseSSEResponse(result, signal), runId, signal)
251
316
  } else {
252
317
  this.devtoolsBridge.ensureRunStarted(runId)
253
318
  this.setResult(result)
254
319
  this.setStatus('success')
320
+ this.completePlainFetcherResumeSnapshot(result)
255
321
  }
256
322
  }
257
323
 
258
324
  /**
259
325
  * Process a stream of AG-UI events from the streaming connection adapter.
260
326
  * The server handles the polling loop and streams status updates.
327
+ *
328
+ * Throws {@link GENERATION_STREAM_TRUNCATED_MESSAGE} when the iteration ends
329
+ * without a terminal chunk — see the note on
330
+ * `GenerationClient.processStream`. Video runs are long enough that a proxy
331
+ * idle timeout mid-poll is the likeliest way to hit it.
261
332
  */
262
333
  private async processStream(
263
334
  source: AsyncIterable<StreamChunk>,
264
335
  fallbackRunId: string,
336
+ signal: AbortSignal,
265
337
  ): Promise<void> {
266
338
  let streamRunId: string | undefined
339
+ let sawTerminalChunk = false
267
340
 
268
341
  for await (const chunk of source) {
269
- if (this.abortController?.signal.aborted) break
342
+ if (signal.aborted) break
270
343
 
271
344
  this.callbacksRef.onChunk?.(chunk)
345
+ this.observeResumeSnapshot(chunk)
272
346
  const chunkRunId =
273
347
  'runId' in chunk && typeof chunk.runId === 'string'
274
348
  ? chunk.runId
@@ -307,6 +381,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
307
381
  }
308
382
  case 'RUN_FINISHED': {
309
383
  streamRunId = chunk.runId
384
+ sawTerminalChunk = true
310
385
  this.devtoolsBridge.ensureRunStarted(chunk.runId)
311
386
  this.setStatus('success')
312
387
  break
@@ -326,6 +401,11 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
326
401
  break
327
402
  }
328
403
  }
404
+
405
+ // An aborted read is a deliberate stop/dispose, not a truncation.
406
+ if (!sawTerminalChunk && !signal.aborted) {
407
+ throw new Error(GENERATION_STREAM_TRUNCATED_MESSAGE)
408
+ }
329
409
  }
330
410
 
331
411
  /**
@@ -344,10 +424,24 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
344
424
  this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled')
345
425
  }
346
426
  }
427
+ // A stopped run is no longer resumable. Without this the in-memory
428
+ // snapshot stays `running`, and a remount's `maybeResumeInFlight` would
429
+ // rejoin a run the user just cancelled.
430
+ if (this.resumeSnapshot && this.resumeSnapshot.status === 'running') {
431
+ this.resumeSnapshot = {
432
+ ...this.resumeSnapshot,
433
+ resumeState: null,
434
+ status: 'idle',
435
+ }
436
+ this.notifyResumeSnapshotChanged()
437
+ }
347
438
  }
348
439
 
349
440
  /**
350
- * Clear all state and return to idle.
441
+ * Clear all state and return to idle. Also drops the client's in-memory
442
+ * resume snapshot, so a remount restores nothing. The server-side record is
443
+ * untouched — this client no longer writes one — so a full page reload under
444
+ * `persistence: true` re-hydrates the last generation again.
351
445
  */
352
446
  reset(): void {
353
447
  this.stop()
@@ -359,6 +453,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
359
453
  this.setVideoStatus(null)
360
454
  this.setError(undefined)
361
455
  this.setStatus('idle')
456
+ this.clearResumeSnapshot()
362
457
  this.devtoolsBridge.emitState()
363
458
  }
364
459
 
@@ -403,9 +498,18 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
403
498
  }
404
499
 
405
500
  dispose(): void {
406
- this.stop()
501
+ this.disposed = true
502
+ // Teardown, NOT a user cancel (see GenerationClient.dispose): abort in-flight
503
+ // delivery but keep the `running` snapshot resumable so a remount rejoins.
504
+ if (this.abortController) {
505
+ this.abortController.abort()
506
+ this.abortController = null
507
+ }
508
+ this.setIsLoading(false)
407
509
  this.devtoolsBridge.dispose()
408
510
  this.devtoolsMounted = false
511
+ this.serverHydrationStarted = false
512
+ this.rejoinedRunId = undefined
409
513
  }
410
514
 
411
515
  // ===========================
@@ -436,6 +540,33 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
436
540
  return this.status
437
541
  }
438
542
 
543
+ getResumeSnapshot(): GenerationResumeSnapshot | undefined {
544
+ return this.resumeSnapshot
545
+ ? {
546
+ ...this.resumeSnapshot,
547
+ ...(this.resumeSnapshot.pendingArtifacts
548
+ ? { pendingArtifacts: [...this.resumeSnapshot.pendingArtifacts] }
549
+ : {}),
550
+ ...(this.resumeSnapshot.result
551
+ ? {
552
+ result: {
553
+ ...this.resumeSnapshot.result,
554
+ ...(this.resumeSnapshot.result.artifacts
555
+ ? { artifacts: [...this.resumeSnapshot.result.artifacts] }
556
+ : {}),
557
+ },
558
+ }
559
+ : {}),
560
+ ...(this.resumeSnapshot.error
561
+ ? { error: { ...this.resumeSnapshot.error } }
562
+ : {}),
563
+ ...(this.resumeSnapshot.lastEvent
564
+ ? { lastEvent: { ...this.resumeSnapshot.lastEvent } }
565
+ : {}),
566
+ }
567
+ : undefined
568
+ }
569
+
439
570
  // ===========================
440
571
  // Private state setters
441
572
  // ===========================
@@ -475,7 +606,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
475
606
  // TOutput. When the caller did not supply an onResult transform,
476
607
  // `TOutput` defaults to `VideoGenerateResult`, so the runtime cast is
477
608
  // sound.
478
- // eslint-disable-next-line no-restricted-syntax -- TOutput defaults to VideoGenerateResult when no onResult transform is supplied
609
+ // oxlint-disable-next-line eslint-js/no-restricted-syntax -- TOutput defaults to VideoGenerateResult when no onResult transform is supplied
479
610
  this.result = rawResult as unknown as TOutput
480
611
  this.callbacksRef.onResultChange?.(this.result)
481
612
  this.devtoolsBridge.recordResultChange()
@@ -556,4 +687,339 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
556
687
  runId,
557
688
  }
558
689
  }
690
+
691
+ private observeResumeSnapshot(chunk: StreamChunk): void {
692
+ this.resumeSnapshot = updateGenerationResumeSnapshot(
693
+ this.resumeSnapshot,
694
+ chunk,
695
+ )
696
+ this.notifyResumeSnapshotChanged()
697
+ }
698
+
699
+ /** Notify the internal snapshot listener AND emit the public resume state. */
700
+ private notifyResumeSnapshotChanged(): void {
701
+ this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot)
702
+ this.emitResumeState()
703
+ }
704
+
705
+ /** Derive the public `resumeState` from the internal snapshot. */
706
+ private emitResumeState(): void {
707
+ const snapshot = this.resumeSnapshot
708
+ const state = snapshot?.resumeState
709
+ const resumeState: GenerationResumeState | null = state
710
+ ? {
711
+ ...state,
712
+ ...(snapshot?.pendingArtifacts && snapshot.pendingArtifacts.length > 0
713
+ ? { pendingArtifacts: [...snapshot.pendingArtifacts] }
714
+ : {}),
715
+ }
716
+ : null
717
+ this.callbacksRef.onResumeStateChange?.(resumeState)
718
+ }
719
+
720
+ /**
721
+ * Repaint the normal fields from a restored snapshot so a reload presents the
722
+ * video in `result` / `status` / `error` / `jobId`, never a snapshot object.
723
+ * `isLoading` stays false (no auto-tail). Not re-persisted (it came from
724
+ * storage / the server).
725
+ *
726
+ * A `complete` snapshot with no durable video artifact cannot be rebuilt, so
727
+ * it repaints as an error rather than a `success` with a `null` result — see
728
+ * the note on `GenerationClient.repaintFromSnapshot`.
729
+ */
730
+ private repaintFromSnapshot(snapshot: GenerationResumeSnapshot): void {
731
+ this.resumeSnapshot = snapshot
732
+ this.notifyResumeSnapshotChanged()
733
+ this.setStatus(clientStateFromResumeStatus(snapshot.status))
734
+ this.setError(
735
+ snapshot.error
736
+ ? Object.assign(
737
+ new Error(snapshot.error.message),
738
+ snapshot.error.code ? { code: snapshot.error.code } : {},
739
+ )
740
+ : undefined,
741
+ )
742
+ if (snapshot.result?.providerJobId)
743
+ this.setJobId(snapshot.result.providerJobId)
744
+ const restored = this.reconstructVideoResult(snapshot)
745
+ if (restored !== null) {
746
+ this.setResult(restored)
747
+ } else if (snapshot.status === 'complete') {
748
+ this.reportUnrestorableResult()
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Report a `complete` snapshot with no durable video artifact to rebuild
754
+ * from. Runs after the status/error repaint above, so it wins over the
755
+ * snapshot's own `complete` status.
756
+ */
757
+ private reportUnrestorableResult(): void {
758
+ const error = new Error(GENERATION_UNRESTORABLE_RESULT_MESSAGE)
759
+ this.setStatus('error')
760
+ this.setError(error)
761
+ this.callbacksRef.onError?.(error)
762
+ }
763
+
764
+ /**
765
+ * Repaint a restored snapshot (client store or server hydrate) and, when it
766
+ * reports a run still in flight, tail that run to completion via `joinRun`
767
+ * (from the connection, or the `joinRun` option when the transport can't
768
+ * carry one).
769
+ *
770
+ * A `running` snapshot that no `joinRun` handler can tail is repainted as an
771
+ * interrupted error instead of a `generating` status that would never
772
+ * settle: an interrupted generation cannot be resumed, only re-run.
773
+ */
774
+ private repaintRestoredSnapshot(
775
+ snapshot: GenerationResumeSnapshot,
776
+ activeRunId?: string,
777
+ ): void {
778
+ if (snapshot.status !== 'running') {
779
+ this.repaintFromSnapshot(snapshot)
780
+ return
781
+ }
782
+ const joinRun = this.connection?.joinRun ?? this.joinRunHandler
783
+ const runId = activeRunId ?? snapshot.resumeState?.runId
784
+ if (runId && joinRun) {
785
+ this.repaintFromSnapshot(snapshot)
786
+ this.rejoinInFlight(runId)
787
+ return
788
+ }
789
+ this.repaintFromSnapshot({
790
+ ...snapshot,
791
+ resumeState: null,
792
+ status: 'error',
793
+ error: {
794
+ message:
795
+ 'The previous generation was interrupted before it finished and cannot be resumed — generate again to retry.',
796
+ },
797
+ })
798
+ }
799
+
800
+ /**
801
+ * Rebuild a `VideoGenerateResult` from a restored snapshot: the video's bytes
802
+ * are served from the durable artifact URL, so the restored result renders
803
+ * from your own origin. Returns `null` when there is no durable video artifact.
804
+ */
805
+ private reconstructVideoResult(
806
+ snapshot: GenerationResumeSnapshot,
807
+ ): VideoGenerateResult | null {
808
+ const result = snapshot.result
809
+ const artifacts = result?.artifacts ?? []
810
+ const output = artifacts.find(
811
+ (a) =>
812
+ a.role === 'output' && a.source.mediaType === 'video' && a.url != null,
813
+ )
814
+ if (!output?.url) return null
815
+ return {
816
+ jobId: result?.providerJobId ?? '',
817
+ status: 'completed',
818
+ url: output.url,
819
+ ...(result?.expiresAt ? { expiresAt: new Date(result.expiresAt) } : {}),
820
+ artifacts,
821
+ }
822
+ }
823
+
824
+ /**
825
+ * The plain (non-Response) fetcher path never observes stream chunks, so
826
+ * the terminal snapshot is built here from the fetcher's own result. A
827
+ * stale `error` from a previous run is intentionally dropped — this run
828
+ * succeeded.
829
+ */
830
+ private completePlainFetcherResumeSnapshot(rawResult: unknown): void {
831
+ const previous = this.resumeSnapshot
832
+ const result = createGenerationResultSnapshot(rawResult)
833
+ this.resumeSnapshot = {
834
+ schemaVersion: 1,
835
+ resumeState: null,
836
+ status: 'complete',
837
+ ...(previous?.activity ? { activity: previous.activity } : {}),
838
+ ...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0
839
+ ? { pendingArtifacts: [...previous.pendingArtifacts] }
840
+ : {}),
841
+ ...(result
842
+ ? { result }
843
+ : previous?.result
844
+ ? { result: { ...previous.result } }
845
+ : {}),
846
+ }
847
+ this.notifyResumeSnapshotChanged()
848
+ }
849
+
850
+ /**
851
+ * Records a transport-level failure (network drop, throwing callback) in
852
+ * the snapshot. Without this, only a server-emitted RUN_ERROR chunk would
853
+ * mark the snapshot `error`, leaving a persisted record that claims the
854
+ * run is still in flight.
855
+ */
856
+ private recordResumeSnapshotError(error: Error): void {
857
+ // Surface the failure on the observable fields FIRST, unconditionally (see
858
+ // the note in GenerationClient.recordResumeSnapshotError): a RUN_ERROR
859
+ // already flipped the snapshot to `error`, so the early-return would else
860
+ // skip this and leave `status` stuck on `generating`. The guard avoids a
861
+ // duplicate `error` emission on the live `generate()` path.
862
+ if (this.status !== 'error') this.setStatus('error')
863
+ this.setError(error)
864
+ if (this.resumeSnapshot?.status === 'error') return
865
+ if (!this.resumeSnapshot && !this.serverDriven) return
866
+ const previous = this.resumeSnapshot
867
+ this.resumeSnapshot = {
868
+ schemaVersion: 1,
869
+ resumeState: null,
870
+ status: 'error',
871
+ ...(previous?.activity ? { activity: previous.activity } : {}),
872
+ ...(previous?.pendingArtifacts && previous.pendingArtifacts.length > 0
873
+ ? { pendingArtifacts: [...previous.pendingArtifacts] }
874
+ : {}),
875
+ ...(previous?.result ? { result: { ...previous.result } } : {}),
876
+ error: { message: error.message },
877
+ }
878
+ this.notifyResumeSnapshotChanged()
879
+ }
880
+
881
+ /**
882
+ * Drop the client's in-memory snapshot and re-emit. Purely local — this
883
+ * client writes no storage, so nothing persisted is removed.
884
+ */
885
+ private clearResumeSnapshot(): void {
886
+ this.resumeSnapshot = undefined
887
+ this.notifyResumeSnapshotChanged()
888
+ }
889
+
890
+ /**
891
+ * Server-driven mount hydration entry point (`persistence: true`). Runs at
892
+ * most once, from the commit-phase mount path (`mountDevtools`) — never the
893
+ * constructor / render phase — so remounts and speculative renders can't
894
+ * re-fire the hydrate GET.
895
+ */
896
+ private maybeHydrateFromServer(): void {
897
+ if (!this.serverDriven || this.serverHydrationStarted) return
898
+ this.serverHydrationStarted = true
899
+ if (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) {
900
+ this.hydrateFromServer()
901
+ } else {
902
+ // `persistence: true` without any hydrate source can never restore
903
+ // anything — warn rather than silently no-op.
904
+ console.warn(
905
+ '[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.',
906
+ )
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Server-driven mount hydration (`persistence: true`). The client holds no
912
+ * local snapshot; on mount it asks the server — keyed by the stable threadId —
913
+ * for the last generation's resume snapshot, validates it, and repaints it. It
914
+ * never auto-starts a run, and never blocks: a `generate()` that starts first
915
+ * owns the client and hydration backs off, mirroring the chat client.
916
+ *
917
+ * A genuine **miss** (no record for the thread) is silent; a genuine
918
+ * **failure** (transport error, authorize rejection, malformed body, a record
919
+ * the validator rejects) surfaces through `status` / `error` / `onError` — see
920
+ * the note on `GenerationClient.hydrateFromServer`.
921
+ */
922
+ private hydrateFromServer(): void {
923
+ const hydrate =
924
+ this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler
925
+ if (!hydrate) return
926
+ // A send that already started owns the client; don't stomp it.
927
+ if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return
928
+ void (async () => {
929
+ let res: GenerationHydrationResult
930
+ try {
931
+ res = await hydrate(this.threadId)
932
+ } catch (cause) {
933
+ this.failHydration(
934
+ createGenerationHydrationError(
935
+ 'the request to the server did not succeed',
936
+ cause,
937
+ ),
938
+ )
939
+ return
940
+ }
941
+ // No record for this thread — a fresh thread, not a failure.
942
+ if (!res.resumeSnapshot) return
943
+ const snapshot = parseGenerationResumeSnapshot(res.resumeSnapshot)
944
+ if (!snapshot) {
945
+ this.failHydration(
946
+ createGenerationHydrationError(
947
+ 'the server returned a record this client cannot read (unknown schema version, or a missing/invalid `status` or `resumeState`)',
948
+ ),
949
+ )
950
+ return
951
+ }
952
+ // Re-check: a send may have started while the fetch was in flight.
953
+ if (this.resumeSnapshot || this.isLoading || this.status !== 'idle')
954
+ return
955
+ // A run still generating on the server: re-attach and finish it in place.
956
+ this.repaintRestoredSnapshot(snapshot, res.activeRun?.runId)
957
+ })()
958
+ }
959
+
960
+ /**
961
+ * Surface a hydration failure on the observable fields. Skipped when a
962
+ * `generate()` took ownership while the hydrate GET was in flight.
963
+ */
964
+ private failHydration(error: Error): void {
965
+ if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return
966
+ this.setStatus('error')
967
+ this.setError(error)
968
+ this.callbacksRef.onError?.(error)
969
+ }
970
+
971
+ /**
972
+ * Re-attach to an already-loaded `running` snapshot (remount case); see the
973
+ * note in GenerationClient.maybeResumeInFlight. Guarded by `rejoinInFlight`.
974
+ */
975
+ private maybeResumeInFlight(): void {
976
+ if (this.resumeSnapshot?.status !== 'running') return
977
+ const runId = this.resumeSnapshot.resumeState?.runId
978
+ if (runId) this.rejoinInFlight(runId)
979
+ }
980
+
981
+ /**
982
+ * Re-attach to a video run still generating and stream it to completion,
983
+ * mirroring the chat client's mount-time rejoin. Reuses `processStream`, so
984
+ * the job status and result repaint from the replayed chunks. A live
985
+ * `generate()` owns the client and is never stomped; a run is rejoined once.
986
+ */
987
+ private rejoinInFlight(runId: string): void {
988
+ const joinRun = this.connection?.joinRun ?? this.joinRunHandler
989
+ if (!joinRun) return
990
+ if (this.rejoinedRunId === runId) return
991
+ if (this.isLoading || this.abortController) return
992
+ this.rejoinedRunId = runId
993
+ const controller = new AbortController()
994
+ this.abortController = controller
995
+ this.setIsLoading(true)
996
+ this.setStatus('generating')
997
+ void (async () => {
998
+ try {
999
+ await this.processStream(
1000
+ joinRun(runId, controller.signal),
1001
+ runId,
1002
+ controller.signal,
1003
+ )
1004
+ } catch (error) {
1005
+ if (!controller.signal.aborted) {
1006
+ const failure =
1007
+ error instanceof Error ? error : new Error(String(error))
1008
+ // Settles `status`/`error` AND rewrites the snapshot to a terminal
1009
+ // `error` with a null `resumeState`, so the next mount does not
1010
+ // rejoin this run again.
1011
+ this.recordResumeSnapshotError(failure)
1012
+ this.callbacksRef.onError?.(failure)
1013
+ }
1014
+ } finally {
1015
+ // Only reset if this rejoin still owns the client: a `stop()` +
1016
+ // fresh `generate()` may have replaced the controller while the tail
1017
+ // was settling, and that live run owns `isLoading` now.
1018
+ if (this.abortController === controller) {
1019
+ this.abortController = null
1020
+ this.setIsLoading(false)
1021
+ }
1022
+ }
1023
+ })()
1024
+ }
559
1025
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;"}