@tanstack/ai-client 0.22.0 → 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,5 +1,9 @@
1
- import type { MediaPrompt, StreamChunk } from '@tanstack/ai/client'
2
- import type { TranscriptionResponseFormat } from '@tanstack/ai'
1
+ import type {
2
+ MediaPrompt,
3
+ PersistedArtifactRef,
4
+ StreamChunk,
5
+ } from '@tanstack/ai/client'
6
+ import type { TokenUsage, TranscriptionResponseFormat } from '@tanstack/ai'
3
7
  import type { ConnectConnectionAdapter } from './connection-adapters'
4
8
  import type { AIDevtoolsClientMetadata } from './devtools'
5
9
  import type {
@@ -58,6 +62,204 @@ export type InferGenerationOutput<TResult, TFn> = TFn extends (
58
62
  */
59
63
  export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error'
60
64
 
65
+ /**
66
+ * Status of a persisted/restored generation run.
67
+ *
68
+ * `running` / `complete` / `error` are the three the server-side mapper emits
69
+ * over the wire. `idle` is client-local only: `stop()` rewrites a `running`
70
+ * snapshot to it so a cancelled run is no longer resumable.
71
+ *
72
+ * @internal
73
+ */
74
+ export type GenerationResumeStatus = 'idle' | 'running' | 'complete' | 'error'
75
+
76
+ /**
77
+ * Thrown when a generation stream ends without a terminal `RUN_FINISHED` /
78
+ * `RUN_ERROR` chunk — a proxy/load-balancer idle timeout, a server restart
79
+ * mid-run, or a durable log whose terminal append never landed. The run's
80
+ * outcome is unknowable from the client, so it settles as an error rather than
81
+ * leaving the client stuck on `generating` forever.
82
+ */
83
+ export const GENERATION_STREAM_TRUNCATED_MESSAGE =
84
+ 'The generation stream ended before the run finished (no RUN_FINISHED or RUN_ERROR was received) — the connection was interrupted. Generate again to retry.'
85
+
86
+ /**
87
+ * Reported when a restored snapshot says the run completed but the activity's
88
+ * `reconstructResult` mapper cannot rebuild a result from it — typically an
89
+ * output artifact persisted without a serve `url`. Surfacing it beats a
90
+ * `success` status with a `null` result, which no consumer can render.
91
+ */
92
+ export const GENERATION_UNRESTORABLE_RESULT_MESSAGE =
93
+ 'The stored generation completed but its result could not be rebuilt from the persisted record (its output artifact carries no serve URL, or the fields this activity needs were not persisted). Generate again to produce a fresh result.'
94
+
95
+ /**
96
+ * Wrap a mount-hydration failure with context. Genuine failures (transport
97
+ * error, a 403 from the authorize gate, an unparseable body, a record the
98
+ * client's validator rejects) must reach the app; only a genuine miss — the
99
+ * server reporting no record for the thread — stays silent.
100
+ */
101
+ export function createGenerationHydrationError(
102
+ detail: string,
103
+ cause?: unknown,
104
+ ): Error {
105
+ const suffix = cause instanceof Error ? `: ${cause.message}` : ''
106
+ const error = new Error(
107
+ `[TanStack AI] Restoring the last generation for this thread failed — ${detail}${suffix}`,
108
+ )
109
+ if (cause !== undefined) {
110
+ error.cause = cause
111
+ }
112
+ return error
113
+ }
114
+
115
+ /**
116
+ * Map a persisted resume status to the client's live state machine on restore:
117
+ * complete → success, error → error, running → generating, idle → idle. A
118
+ * restored `running` only reaches this mapping when a `joinRun` handler can
119
+ * tail the run to completion; without one the client rewrites the snapshot to
120
+ * `error` (interrupted) before repainting, so it never sticks on `generating`.
121
+ */
122
+ export function clientStateFromResumeStatus(
123
+ status: GenerationResumeStatus,
124
+ ): GenerationClientState {
125
+ switch (status) {
126
+ case 'complete':
127
+ return 'success'
128
+ case 'error':
129
+ return 'error'
130
+ case 'running':
131
+ return 'generating'
132
+ case 'idle':
133
+ return 'idle'
134
+ }
135
+ }
136
+
137
+ /** @internal */
138
+ export interface GenerationResumeState {
139
+ threadId: string
140
+ runId: string
141
+ /**
142
+ * Artifact refs observed while the run is still in flight. Non-null only while
143
+ * a run is streaming (`resumeState` itself is null once it ends); the final
144
+ * refs move onto `result.artifacts` when the run completes.
145
+ */
146
+ pendingArtifacts?: Array<PersistedArtifactRef>
147
+ }
148
+
149
+ /** @internal */
150
+ export interface GenerationResultSnapshot {
151
+ id?: string
152
+ model?: string
153
+ status?: string
154
+ /**
155
+ * The provider's async job handle (e.g. a Veo/fal video job id used for
156
+ * status polling) — NOT the generation's own `runId`, which lives on
157
+ * {@link GenerationResumeState.runId}.
158
+ */
159
+ providerJobId?: string
160
+ expiresAt?: string
161
+ /**
162
+ * The text output of a text activity (a transcription's `text` or a summary's
163
+ * `summary`). Persisted so a text generation restores its result on reload
164
+ * (text is small and not bytes). Absent for media activities, whose output
165
+ * restores from `artifacts`.
166
+ */
167
+ text?: string
168
+ /** Token usage, persisted so a text result that requires it can be rebuilt. */
169
+ usage?: TokenUsage
170
+ artifacts?: Array<PersistedArtifactRef>
171
+ }
172
+
173
+ /** @internal */
174
+ export interface GenerationErrorSnapshot {
175
+ message: string
176
+ code?: string
177
+ }
178
+
179
+ /** @internal */
180
+ export interface GenerationEventSnapshot {
181
+ type: StreamChunk['type']
182
+ name?: string
183
+ timestamp?: number
184
+ }
185
+
186
+ /** @internal */
187
+ export interface GenerationResumeSnapshot {
188
+ /**
189
+ * Version of the snapshot shape. Written on every snapshot the client builds
190
+ * so future shape changes can migrate (or reject) an older record hydrated
191
+ * from the server. Absent means `1`.
192
+ */
193
+ schemaVersion?: 1
194
+ resumeState: GenerationResumeState | null
195
+ status: GenerationResumeStatus
196
+ activity?: PersistedArtifactRef['source']['activity']
197
+ pendingArtifacts?: Array<PersistedArtifactRef>
198
+ result?: GenerationResultSnapshot
199
+ error?: GenerationErrorSnapshot
200
+ lastEvent?: GenerationEventSnapshot
201
+ }
202
+
203
+ /**
204
+ * The `persistence` / `threadId` / `id` identity shared by every generation hook.
205
+ *
206
+ * Turning persistence on **requires** a `threadId`, the stable scope runs are
207
+ * filed under. Without one the client would hydrate by a generated id that
208
+ * changes every reload, so nothing would ever restore; making it a type error
209
+ * means the compiler asks for the scope instead of the runtime inventing one.
210
+ *
211
+ * `threadId` is the single identity for the hook, the AG-UI wire thread, and
212
+ * persistence. Legacy `id` is deprecated and typed `never` whenever
213
+ * `threadId` is supplied — pass one scope, not two.
214
+ *
215
+ * Ephemeral generations (no `persistence`, or `persistence: false`) may still
216
+ * pass a deprecated `id` when they have no `threadId`, as a wire/devtools
217
+ * fallback. Prefer giving them a `threadId` instead.
218
+ *
219
+ * USAGE: intersect this onto a hook's parameter and subtract the three keys
220
+ * from the options interface, leaving that interface a plain (non-union)
221
+ * object so `Pick` / `Omit` composition elsewhere keeps working:
222
+ *
223
+ * ```ts
224
+ * options: Omit<UseGenerateImageOptions, 'onResult' | 'persistence' | 'threadId' | 'id'> & {
225
+ * onResult?: (result: ImageGenerationResult) => TTransformed
226
+ * } & GenerationPersistenceOptions
227
+ * ```
228
+ *
229
+ * Do NOT bake the union into the options interface itself: a later plain `Omit`
230
+ * over a union collapses it to a single object type and the requirement
231
+ * silently disappears. `use-generation-persistence-types.test.ts` pins this.
232
+ */
233
+ export type GenerationPersistenceOptions =
234
+ | {
235
+ persistence: true
236
+ /** Required by `persistence` — the stable scope runs are filed under. */
237
+ threadId: string
238
+ /**
239
+ * @deprecated Prefer `threadId`. Not allowed when `threadId` is set —
240
+ * `threadId` is the single identity for the hook, the wire, and persistence.
241
+ */
242
+ id?: never
243
+ }
244
+ | {
245
+ persistence?: false | undefined
246
+ /** Stable scope for the generation slot (also the wire / devtools identity). */
247
+ threadId: string
248
+ /**
249
+ * @deprecated Prefer `threadId`. Not allowed when `threadId` is set.
250
+ */
251
+ id?: never
252
+ }
253
+ | {
254
+ persistence?: false | undefined
255
+ threadId?: undefined
256
+ /**
257
+ * @deprecated Prefer `threadId` as the single identity. Only allowed when
258
+ * `threadId` is omitted — legacy wire/devtools fallback for ephemeral runs.
259
+ */
260
+ id?: string
261
+ }
262
+
61
263
  // ===========================
62
264
  // Event Constants
63
265
  // ===========================
@@ -70,6 +272,8 @@ export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error'
70
272
  export const GENERATION_EVENTS = {
71
273
  /** The generation result payload */
72
274
  RESULT: 'generation:result',
275
+ /** Persisted artifact refs for generated media */
276
+ ARTIFACTS: 'generation:artifacts',
73
277
  /** Progress update (0-100) with optional message */
74
278
  PROGRESS: 'generation:progress',
75
279
  /** Video job created with jobId */
@@ -126,15 +330,87 @@ export type GenerationTransport<TInput, TResult> =
126
330
  */
127
331
  // eslint-disable-next-line @typescript-eslint/naming-convention -- _TInput is unused in the interface body but part of the public positional generic API (callers supply it for inference)
128
332
  export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
129
- /** Unique identifier for this generation client instance */
333
+ /**
334
+ * @deprecated Prefer {@link GenerationClientOptions.threadId}. Legacy instance
335
+ * id used only as a wire/devtools fallback when `threadId` is omitted. When
336
+ * both are passed, `threadId` wins and `id` is ignored. Framework hooks type
337
+ * `id` as `never` whenever `threadId` is set — see
338
+ * {@link GenerationPersistenceOptions}.
339
+ */
130
340
  id?: string
131
341
 
342
+ /**
343
+ * The **scope** this generation belongs to: a stable, app-chosen name for the
344
+ * slot successive runs fill, not a link to a chat conversation. This is the
345
+ * single identity for the client — wire thread id, devtools hook id, and
346
+ * persistence key.
347
+ *
348
+ * A generation hook starts empty and produces many runs over its life — each
349
+ * run gets its own `runId`, but they all belong to one scope. Persistence
350
+ * keys on this: server-driven hydrates the last run for it on mount. It is
351
+ * also sent as the AG-UI thread id on the wire, since the protocol requires
352
+ * one.
353
+ *
354
+ * Derive it from your own domain — it must be meaningful before any media
355
+ * exists and identical after a reload:
356
+ *
357
+ * ```ts
358
+ * threadId: `video-${videoId}-start-frame`
359
+ * ```
360
+ *
361
+ * **Required whenever `persistence` is set.** An app that cannot name the
362
+ * scope has nothing to restore *to*, and a generated fallback would key each
363
+ * reload differently — silently restoring nothing. Optional only for
364
+ * ephemeral runs, where it falls back to deprecated `id` (or a generated id)
365
+ * purely to satisfy the wire and nothing is written.
366
+ */
367
+ threadId?: string
368
+
132
369
  /** Additional body parameters to send with connect-based adapter requests */
133
370
  body?: Record<string, any>
134
371
 
135
372
  /** Metadata used to register this generation hook with TanStack AI Devtools */
136
373
  devtools?: Partial<AIDevtoolsClientMetadata>
137
374
 
375
+ /**
376
+ * How this generation persists across reloads.
377
+ *
378
+ * - Omit or `false`: ephemeral, in-memory only.
379
+ * - `true`: server-driven. On mount the client hydrates the last generation
380
+ * for its `threadId` from the server (needs a `hydrateGeneration` handler,
381
+ * from the connection or the option below) and repaints that snapshot. It
382
+ * never auto-starts a run.
383
+ *
384
+ * The record lives on the server, written by `withGenerationPersistence`. The
385
+ * browser caches nothing, so a generation's history is never duplicated into
386
+ * client storage.
387
+ */
388
+ persistence?: boolean
389
+
390
+ /**
391
+ * Server-driven hydration handler, for transports that don't carry one on
392
+ * the connection: supply it alongside `fetcher` (or a `stream()` /
393
+ * `rpcStream()` connection built without handlers) so `persistence: true`
394
+ * can restore the last generation for `threadId` on mount. Typically a
395
+ * one-line TanStack Start server-function call backed by
396
+ * `getGenerationHydration` from `@tanstack/ai-persistence`.
397
+ *
398
+ * A connection's own `hydrateGeneration` takes precedence when both exist.
399
+ */
400
+ hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration']
401
+
402
+ /**
403
+ * Re-attach handler for a run that is still generating, for transports that
404
+ * don't carry one on the connection. The client tails this on mount when a
405
+ * restored/hydrated snapshot reports a run in flight, replaying it to
406
+ * completion in place. Without it, a restored `running` snapshot surfaces
407
+ * as an (interrupted) error — an interrupted generation cannot be resumed,
408
+ * only re-run.
409
+ *
410
+ * A connection's own `joinRun` takes precedence when both exist.
411
+ */
412
+ joinRun?: ConnectConnectionAdapter['joinRun']
413
+
138
414
  /**
139
415
  * Factory that constructs the devtools bridge. Default is a no-op
140
416
  * factory; the real implementation lives in `@tanstack/ai-client/devtools`.
@@ -166,6 +442,198 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
166
442
  onErrorChange?: (error: Error | undefined) => void
167
443
  /** @internal Called when generation status changes */
168
444
  onStatusChange?: (status: GenerationClientState) => void
445
+ /** @internal Called when lightweight resume snapshot changes. Receives `undefined` when the snapshot is cleared by `reset()`. */
446
+ onResumeSnapshotChange?: (
447
+ snapshot: GenerationResumeSnapshot | undefined,
448
+ ) => void
449
+ /** @internal Called when the in-flight run identity changes. `null` once no run is in flight. Mirrors the chat client's resume-state callback. */
450
+ onResumeStateChange?: (resumeState: GenerationResumeState | null) => void
451
+
452
+ /**
453
+ * @internal Rebuild a typed result from a restored snapshot, injected by each
454
+ * specialized client/hook (which knows the concrete result shape). Called on
455
+ * mount restore (client store or server hydrate) so `result` repaints as if the
456
+ * run had just finished, with media resolved to the durable serve URL. Returns
457
+ * `null` when the snapshot cannot rebuild a result (then `result` stays null;
458
+ * `status` / `error` / `resumeState` still repaint).
459
+ */
460
+ reconstructResult?: (restored: GenerationRestoredResult) => TResult | null
461
+ }
462
+
463
+ /**
464
+ * The restorable shape handed to a client's `reconstructResult` mapper: the
465
+ * result metadata that survived persistence plus the durable artifact refs (each
466
+ * carrying its serve {@link PersistedArtifactRef.url}). The specialized client
467
+ * turns this into its own typed result (image `images`, video `url`, text
468
+ * `text`, ...).
469
+ */
470
+ export interface GenerationRestoredResult {
471
+ id?: string
472
+ model?: string
473
+ status?: string
474
+ /** The provider's async job handle — see {@link GenerationResultSnapshot.providerJobId}. */
475
+ providerJobId?: string
476
+ expiresAt?: string
477
+ text?: string
478
+ usage?: TokenUsage
479
+ activity?: PersistedArtifactRef['source']['activity']
480
+ artifacts: Array<PersistedArtifactRef>
481
+ }
482
+
483
+ /**
484
+ * Reduces one observed stream chunk into the lightweight resume snapshot.
485
+ *
486
+ * A `RUN_STARTED` chunk begins a fresh run, so stale `result` / `error` /
487
+ * `pendingArtifacts` from a previous run are dropped rather than carried into
488
+ * the new run's snapshot.
489
+ *
490
+ * @internal
491
+ */
492
+ export function updateGenerationResumeSnapshot(
493
+ previous: GenerationResumeSnapshot | null | undefined,
494
+ chunk: StreamChunk,
495
+ ): GenerationResumeSnapshot {
496
+ const threadId = stringField(chunk, 'threadId')
497
+ const runId = stringField(chunk, 'runId')
498
+ const carried = chunk.type === 'RUN_STARTED' ? undefined : previous
499
+ const previousArtifacts = carried?.pendingArtifacts ?? []
500
+ const next: GenerationResumeSnapshot = {
501
+ schemaVersion: 1,
502
+ resumeState: carried?.resumeState ?? null,
503
+ status: carried?.status ?? 'idle',
504
+ ...(carried?.activity ? { activity: carried.activity } : {}),
505
+ ...(previousArtifacts.length > 0
506
+ ? { pendingArtifacts: [...previousArtifacts] }
507
+ : {}),
508
+ ...(carried?.result ? { result: { ...carried.result } } : {}),
509
+ ...(carried?.error ? { error: { ...carried.error } } : {}),
510
+ lastEvent: createGenerationEventSnapshot(chunk),
511
+ }
512
+
513
+ if (threadId && runId) {
514
+ next.resumeState = { threadId, runId }
515
+ next.status = 'running'
516
+ } else if (chunk.type === 'RUN_STARTED') {
517
+ next.status = 'running'
518
+ }
519
+
520
+ if (chunk.type === 'CUSTOM') {
521
+ if (chunk.name === GENERATION_EVENTS.ARTIFACTS) {
522
+ const artifacts = collectArtifactRefs(chunk.value)
523
+ if (artifacts.length > 0) {
524
+ next.pendingArtifacts = artifacts
525
+ next.activity = artifacts[0]?.source.activity
526
+ }
527
+ } else if (chunk.name === GENERATION_EVENTS.RESULT) {
528
+ const result = createGenerationResultSnapshot(chunk.value)
529
+ if (result) {
530
+ next.result = result
531
+ if (result.artifacts && result.artifacts.length > 0) {
532
+ next.pendingArtifacts = result.artifacts
533
+ next.activity = result.artifacts[0]?.source.activity
534
+ }
535
+ }
536
+ } else if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) {
537
+ // Capture the provider job id as soon as the job exists — for a long
538
+ // video run this is the one piece of identity worth having after a
539
+ // reload, and the terminal `generation:result` may never arrive.
540
+ const providerJobId = isObject(chunk.value)
541
+ ? stringField(chunk.value, 'jobId')
542
+ : undefined
543
+ if (providerJobId) {
544
+ next.result = { ...next.result, providerJobId }
545
+ }
546
+ }
547
+ } else if (chunk.type === 'RUN_FINISHED') {
548
+ next.resumeState = null
549
+ next.status = 'complete'
550
+ } else if (chunk.type === 'RUN_ERROR') {
551
+ next.resumeState = null
552
+ next.status = 'error'
553
+ next.error = createGenerationErrorSnapshot(chunk)
554
+ }
555
+
556
+ return next
557
+ }
558
+
559
+ /**
560
+ * Validates an untrusted value (a hydration body resolved by the server) into a
561
+ * {@link GenerationResumeSnapshot}, or returns `undefined` when the value is
562
+ * not a usable snapshot.
563
+ *
564
+ * A hydrated record is outside the type system: it may be stale, truncated, or
565
+ * written by a different version. Every field is re-validated with the same
566
+ * narrowing the live chunk reducer uses. `lastEvent` is not restored, since it
567
+ * describes a transient stream position with no meaning after a reload.
568
+ *
569
+ * @internal
570
+ */
571
+ export function parseGenerationResumeSnapshot(
572
+ value: unknown,
573
+ ): GenerationResumeSnapshot | undefined {
574
+ if (!isObject(value)) return undefined
575
+
576
+ const schemaVersion = Reflect.get(value, 'schemaVersion')
577
+ if (schemaVersion !== undefined && schemaVersion !== 1) return undefined
578
+
579
+ const status = generationResumeStatusField(value, 'status')
580
+ if (!status) return undefined
581
+
582
+ const rawResumeState = Reflect.get(value, 'resumeState')
583
+ let resumeState: GenerationResumeState | null = null
584
+ if (rawResumeState !== null && rawResumeState !== undefined) {
585
+ if (!isObject(rawResumeState)) return undefined
586
+ const threadId = stringField(rawResumeState, 'threadId')
587
+ const runId = stringField(rawResumeState, 'runId')
588
+ if (!threadId || !runId) return undefined
589
+ resumeState = { threadId, runId }
590
+ }
591
+
592
+ const snapshot: GenerationResumeSnapshot = {
593
+ schemaVersion: 1,
594
+ resumeState,
595
+ status,
596
+ }
597
+
598
+ const activity = persistedArtifactActivityField(value, 'activity')
599
+ if (activity) snapshot.activity = activity
600
+
601
+ const pendingArtifacts = collectArtifactRefs(
602
+ Reflect.get(value, 'pendingArtifacts'),
603
+ )
604
+ if (pendingArtifacts.length > 0) snapshot.pendingArtifacts = pendingArtifacts
605
+
606
+ const result = createGenerationResultSnapshot(Reflect.get(value, 'result'))
607
+ if (result) snapshot.result = result
608
+
609
+ const rawError = Reflect.get(value, 'error')
610
+ if (isObject(rawError)) {
611
+ const message = stringField(rawError, 'message')
612
+ if (message) {
613
+ const code = stringField(rawError, 'code')
614
+ snapshot.error = { message, ...(code ? { code } : {}) }
615
+ }
616
+ }
617
+
618
+ return snapshot
619
+ }
620
+
621
+ function generationResumeStatusField(
622
+ value: object,
623
+ key: string,
624
+ ): GenerationResumeStatus | undefined {
625
+ const field = stringField(value, key)
626
+ if (field === undefined) return undefined
627
+
628
+ switch (field) {
629
+ case 'idle':
630
+ case 'running':
631
+ case 'complete':
632
+ case 'error':
633
+ return field
634
+ default:
635
+ return undefined
636
+ }
169
637
  }
170
638
 
171
639
  // ===========================
@@ -200,6 +668,8 @@ export interface VideoGenerateResult {
200
668
  url: string
201
669
  /** When the URL expires, if applicable */
202
670
  expiresAt?: Date
671
+ /** Persisted artifact references for generated assets, when available */
672
+ artifacts?: Array<PersistedArtifactRef>
203
673
  }
204
674
 
205
675
  /**
@@ -328,3 +798,257 @@ export interface VideoGenerateInput {
328
798
  /** Model-specific options */
329
799
  modelOptions?: Record<string, any>
330
800
  }
801
+
802
+ function createGenerationEventSnapshot(
803
+ chunk: StreamChunk,
804
+ ): GenerationEventSnapshot {
805
+ const name = stringField(chunk, 'name')
806
+ const timestamp = numberField(chunk, 'timestamp')
807
+ return {
808
+ type: chunk.type,
809
+ ...(name ? { name } : {}),
810
+ ...(timestamp !== undefined ? { timestamp } : {}),
811
+ }
812
+ }
813
+
814
+ /** @internal Narrows an untrusted result payload into the persisted result snapshot shape. */
815
+ export function createGenerationResultSnapshot(
816
+ value: unknown,
817
+ ): GenerationResultSnapshot | undefined {
818
+ if (!isObject(value)) return undefined
819
+
820
+ const artifacts = collectArtifactRefs(Reflect.get(value, 'artifacts'))
821
+ const snapshot: GenerationResultSnapshot = {}
822
+ const id = stringField(value, 'id')
823
+ const model = stringField(value, 'model')
824
+ const status = stringField(value, 'status')
825
+ // A live provider result carries its job handle as `jobId` (e.g.
826
+ // `VideoGenerateResult.jobId`); a persisted snapshot carries it as
827
+ // `providerJobId`. Accept both — this narrows raw results AND stored
828
+ // snapshots.
829
+ const providerJobId =
830
+ stringField(value, 'providerJobId') ?? stringField(value, 'jobId')
831
+ // A transcription's output is `text`; a summary's is `summary`. Capture either
832
+ // under `text` so a text result restores on reload.
833
+ const text = stringField(value, 'text') ?? stringField(value, 'summary')
834
+ const usage = Reflect.get(value, 'usage')
835
+ if (id) snapshot.id = id
836
+ if (model) snapshot.model = model
837
+ if (status) snapshot.status = status
838
+ if (providerJobId) snapshot.providerJobId = providerJobId
839
+ if (text) snapshot.text = text
840
+ // Passthrough opaque token-usage metadata (untrusted; not deeply validated).
841
+ if (isObject(usage)) snapshot.usage = usage as TokenUsage
842
+ const expiresAt = Reflect.get(value, 'expiresAt')
843
+ if (typeof expiresAt === 'string') {
844
+ snapshot.expiresAt = expiresAt
845
+ } else if (expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())) {
846
+ // `toISOString()` throws on an invalid Date. This runs per chunk on live
847
+ // provider values, so drop an unusable date like every other bad field
848
+ // here rather than throwing out of the stream loop.
849
+ snapshot.expiresAt = expiresAt.toISOString()
850
+ }
851
+ if (artifacts.length > 0) {
852
+ snapshot.artifacts = artifacts
853
+ }
854
+
855
+ return Object.keys(snapshot).length > 0 ? snapshot : undefined
856
+ }
857
+
858
+ function createGenerationErrorSnapshot(
859
+ chunk: StreamChunk,
860
+ ): GenerationErrorSnapshot {
861
+ const message =
862
+ stringField(chunk, 'message') ??
863
+ nestedStringField(chunk, 'error', 'message') ??
864
+ 'An error occurred'
865
+ const code = stringField(chunk, 'code')
866
+ return {
867
+ message,
868
+ ...(code ? { code } : {}),
869
+ }
870
+ }
871
+
872
+ function collectArtifactRefs(value: unknown): Array<PersistedArtifactRef> {
873
+ if (!Array.isArray(value)) return []
874
+ const refs: Array<PersistedArtifactRef> = []
875
+ for (const item of value) {
876
+ const ref = createPersistedArtifactRefSnapshot(item)
877
+ if (ref) {
878
+ refs.push(ref)
879
+ }
880
+ }
881
+ return refs
882
+ }
883
+
884
+ function createPersistedArtifactRefSnapshot(
885
+ value: unknown,
886
+ ): PersistedArtifactRef | undefined {
887
+ if (!isObject(value)) return undefined
888
+ const source = Reflect.get(value, 'source')
889
+ if (!isObject(source)) return undefined
890
+
891
+ const role = persistedArtifactRoleField(value, 'role')
892
+ const artifactId = stringField(value, 'artifactId')
893
+ const threadId = stringField(value, 'threadId')
894
+ const runId = stringField(value, 'runId')
895
+ const name = stringField(value, 'name')
896
+ const mimeType = stringField(value, 'mimeType')
897
+ const size = numberField(value, 'size')
898
+ const createdAt = stringField(value, 'createdAt')
899
+ const activity = persistedArtifactActivityField(source, 'activity')
900
+ const path = stringField(source, 'path')
901
+ const provider = stringField(source, 'provider')
902
+ const model = stringField(source, 'model')
903
+ if (
904
+ !role ||
905
+ !artifactId ||
906
+ !threadId ||
907
+ !runId ||
908
+ !name ||
909
+ !mimeType ||
910
+ size === undefined ||
911
+ !createdAt ||
912
+ !activity ||
913
+ !path ||
914
+ !provider ||
915
+ !model
916
+ ) {
917
+ return undefined
918
+ }
919
+
920
+ const sourceUrl = durableUrlField(value, 'sourceUrl')
921
+ const url = serveUrlField(value, 'url')
922
+ const mediaType = persistedArtifactMediaTypeField(source, 'mediaType')
923
+ const jobId = stringField(source, 'jobId')
924
+ const expiresAt = stringField(source, 'expiresAt')
925
+
926
+ return {
927
+ role,
928
+ artifactId,
929
+ threadId,
930
+ runId,
931
+ name,
932
+ mimeType,
933
+ size,
934
+ createdAt,
935
+ ...(sourceUrl ? { sourceUrl } : {}),
936
+ ...(url ? { url } : {}),
937
+ source: {
938
+ activity,
939
+ path,
940
+ provider,
941
+ model,
942
+ ...(mediaType ? { mediaType } : {}),
943
+ ...(jobId ? { jobId } : {}),
944
+ ...(expiresAt ? { expiresAt } : {}),
945
+ },
946
+ }
947
+ }
948
+
949
+ function durableUrlField(value: object, key: string): string | undefined {
950
+ const field = stringField(value, key)
951
+ if (!field || field.length > 2048) return undefined
952
+ try {
953
+ const url = new URL(field)
954
+ return url.protocol === 'http:' || url.protocol === 'https:'
955
+ ? field
956
+ : undefined
957
+ } catch {
958
+ return undefined
959
+ }
960
+ }
961
+
962
+ /**
963
+ * Validates an app-origin serve URL, which unlike a provider URL is usually a
964
+ * same-origin path (`/api/.../artifact?id=...`). Accepts an absolute http(s) URL
965
+ * or a path-absolute same-origin URL (single leading `/`); rejects
966
+ * protocol-relative (`//host`), `javascript:` / `data:`, and anything else, since
967
+ * this value is rendered as media `src`.
968
+ */
969
+ function serveUrlField(value: object, key: string): string | undefined {
970
+ const field = stringField(value, key)
971
+ if (!field || field.length > 2048) return undefined
972
+ // A single leading `/` is a safe same-origin path. Reject protocol-relative
973
+ // `//host` AND a backslash bypass (`/\host` — the URL parser treats `\` as `/`
974
+ // for http(s), so it would resolve to a foreign origin as an `<img src>`).
975
+ if (field.startsWith('/') && !field.startsWith('//') && !field.includes('\\'))
976
+ return field
977
+ try {
978
+ const url = new URL(field)
979
+ return url.protocol === 'http:' || url.protocol === 'https:'
980
+ ? field
981
+ : undefined
982
+ } catch {
983
+ return undefined
984
+ }
985
+ }
986
+
987
+ function persistedArtifactRoleField(
988
+ value: object,
989
+ key: string,
990
+ ): PersistedArtifactRef['role'] | undefined {
991
+ const field = stringField(value, key)
992
+ return field === 'input' || field === 'output' ? field : undefined
993
+ }
994
+
995
+ function persistedArtifactActivityField(
996
+ value: object,
997
+ key: string,
998
+ ): PersistedArtifactRef['source']['activity'] | undefined {
999
+ const field = stringField(value, key)
1000
+ if (field === undefined) return undefined
1001
+
1002
+ switch (field) {
1003
+ case 'image':
1004
+ case 'audio':
1005
+ case 'tts':
1006
+ case 'video':
1007
+ case 'transcription':
1008
+ return field
1009
+ default:
1010
+ return undefined
1011
+ }
1012
+ }
1013
+
1014
+ function persistedArtifactMediaTypeField(
1015
+ value: object,
1016
+ key: string,
1017
+ ): PersistedArtifactRef['source']['mediaType'] | undefined {
1018
+ const field = stringField(value, key)
1019
+ if (field === undefined) return undefined
1020
+
1021
+ switch (field) {
1022
+ case 'image':
1023
+ case 'audio':
1024
+ case 'video':
1025
+ case 'document':
1026
+ case 'json':
1027
+ return field
1028
+ default:
1029
+ return undefined
1030
+ }
1031
+ }
1032
+
1033
+ function nestedStringField(
1034
+ value: object,
1035
+ key: string,
1036
+ nestedKey: string,
1037
+ ): string | undefined {
1038
+ const nested = Reflect.get(value, key)
1039
+ return isObject(nested) ? stringField(nested, nestedKey) : undefined
1040
+ }
1041
+
1042
+ function stringField(value: object, key: string): string | undefined {
1043
+ const field = Reflect.get(value, key)
1044
+ return typeof field === 'string' ? field : undefined
1045
+ }
1046
+
1047
+ function numberField(value: object, key: string): number | undefined {
1048
+ const field = Reflect.get(value, key)
1049
+ return typeof field === 'number' ? field : undefined
1050
+ }
1051
+
1052
+ function isObject(value: unknown): value is object {
1053
+ return typeof value === 'object' && value !== null
1054
+ }