@tanstack/ai-react 0.18.1 → 0.19.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 (49) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/index.d.ts +1 -1
  3. package/dist/esm/index.js +2 -23
  4. package/dist/esm/mcp-app-resource.js +45 -45
  5. package/dist/esm/mcp-app-resource.js.map +1 -1
  6. package/dist/esm/mcp-apps.js +1 -4
  7. package/dist/esm/types.d.ts +29 -3
  8. package/dist/esm/use-audio-recorder.js +41 -46
  9. package/dist/esm/use-audio-recorder.js.map +1 -1
  10. package/dist/esm/use-chat.js +330 -275
  11. package/dist/esm/use-chat.js.map +1 -1
  12. package/dist/esm/use-generate-audio.d.ts +50 -4
  13. package/dist/esm/use-generate-audio.js +47 -23
  14. package/dist/esm/use-generate-audio.js.map +1 -1
  15. package/dist/esm/use-generate-image.d.ts +50 -4
  16. package/dist/esm/use-generate-image.js +49 -23
  17. package/dist/esm/use-generate-image.js.map +1 -1
  18. package/dist/esm/use-generate-speech.d.ts +50 -4
  19. package/dist/esm/use-generate-speech.js +43 -23
  20. package/dist/esm/use-generate-speech.js.map +1 -1
  21. package/dist/esm/use-generate-video.d.ts +50 -4
  22. package/dist/esm/use-generate-video.js +141 -103
  23. package/dist/esm/use-generate-video.js.map +1 -1
  24. package/dist/esm/use-generation.d.ts +59 -6
  25. package/dist/esm/use-generation.js +113 -90
  26. package/dist/esm/use-generation.js.map +1 -1
  27. package/dist/esm/use-mcp-app-bridge.js +46 -23
  28. package/dist/esm/use-mcp-app-bridge.js.map +1 -1
  29. package/dist/esm/use-realtime-chat.js +185 -185
  30. package/dist/esm/use-realtime-chat.js.map +1 -1
  31. package/dist/esm/use-summarize.d.ts +50 -4
  32. package/dist/esm/use-summarize.js +46 -23
  33. package/dist/esm/use-summarize.js.map +1 -1
  34. package/dist/esm/use-transcription.d.ts +50 -4
  35. package/dist/esm/use-transcription.js +51 -20
  36. package/dist/esm/use-transcription.js.map +1 -1
  37. package/package.json +7 -7
  38. package/src/index.ts +10 -0
  39. package/src/types.ts +44 -0
  40. package/src/use-chat.ts +221 -35
  41. package/src/use-generate-audio.ts +64 -17
  42. package/src/use-generate-image.ts +64 -17
  43. package/src/use-generate-speech.ts +64 -17
  44. package/src/use-generate-video.ts +99 -18
  45. package/src/use-generation.ts +112 -18
  46. package/src/use-summarize.ts +64 -17
  47. package/src/use-transcription.ts +60 -18
  48. package/dist/esm/index.js.map +0 -1
  49. package/dist/esm/mcp-apps.js.map +0 -1
package/src/use-chat.ts CHANGED
@@ -5,11 +5,15 @@ import type {
5
5
  AnyClientTool,
6
6
  InferSchemaType,
7
7
  ModelMessage,
8
+ RunAgentResumeItem,
8
9
  SchemaInput,
9
10
  StreamChunk,
10
11
  } from '@tanstack/ai/client'
11
12
  import type {
12
13
  ChatClientState,
14
+ ChatInterrupt,
15
+ ChatInterruptState,
16
+ ChatResumeState,
13
17
  ConnectionStatus,
14
18
  InferredClientContext,
15
19
  QueuedMessage,
@@ -25,6 +29,9 @@ import type {
25
29
  UseChatReturn,
26
30
  } from './types'
27
31
 
32
+ const EMPTY_INTERRUPTS = Object.freeze([])
33
+ const EMPTY_INTERRUPT_ERRORS = Object.freeze([])
34
+
28
35
  export function useChat<
29
36
  const TTools extends ReadonlyArray<AnyClientTool> = any,
30
37
  TSchema extends SchemaInput | undefined = undefined,
@@ -32,8 +39,12 @@ export function useChat<
32
39
  >(
33
40
  options: UseChatOptions<TTools, TSchema, TContext>,
34
41
  ): UseChatReturn<TTools, TSchema> {
42
+ // The hook's identity is its `threadId` — also the persistence key, so a
43
+ // reload with the same `threadId` restores the same conversation. `hookId` is
44
+ // only a stable fallback for React's client-recreation keying when no
45
+ // `threadId` is given (an ephemeral chat), never a persistence key.
35
46
  const hookId = useId()
36
- const clientId = options.id || hookId
47
+ const clientId = options.threadId ?? hookId
37
48
 
38
49
  const [messages, setMessages] = useState<Array<UIMessage<TTools>>>(
39
50
  options.initialMessages || [],
@@ -46,6 +57,15 @@ export function useChat<
46
57
  useState<ConnectionStatus>('disconnected')
47
58
  const [sessionGenerating, setSessionGenerating] = useState(false)
48
59
  const [queue, setQueue] = useState<Array<QueuedMessage>>([])
60
+ const [runId, setRunId] = useState<string | null>(null)
61
+ const [interruptState, setInterruptState] = useState<
62
+ ChatInterruptState<TTools>
63
+ >(() => ({
64
+ interrupts: EMPTY_INTERRUPTS,
65
+ pendingInterrupts: EMPTY_INTERRUPTS,
66
+ interruptErrors: EMPTY_INTERRUPT_ERRORS,
67
+ resuming: false,
68
+ }))
49
69
 
50
70
  type Partial = DeepPartial<InferSchemaType<NonNullable<TSchema>>>
51
71
  type Final = InferSchemaType<NonNullable<TSchema>>
@@ -55,10 +75,15 @@ export function useChat<
55
75
  options.initialMessages || [],
56
76
  )
57
77
  const isFirstMountRef = useRef(true)
78
+ const subscribedRef = useRef(false)
58
79
  const activeClientRef = useRef<ChatClient | null>(null)
59
80
  const cleanupInvalidationRef = useRef<ReturnType<typeof setTimeout> | null>(
60
81
  null,
61
82
  )
83
+ const cleanupDisposalRef = useRef<{
84
+ client: ChatClient
85
+ timeout: ReturnType<typeof setTimeout>
86
+ } | null>(null)
62
87
 
63
88
  // Update ref synchronously during render so it's always current when useMemo runs.
64
89
  messagesRef.current = messages
@@ -67,6 +92,12 @@ export function useChat<
67
92
  const optionsRef = useRef<UseChatOptions<TTools, TSchema, TContext>>(options)
68
93
  optionsRef.current = options
69
94
 
95
+ const syncResumeState = useCallback((target: ChatClient | null) => {
96
+ if (!target) return
97
+ setRunId(target.getCurrentRunId())
98
+ setInterruptState(target.getInterruptState())
99
+ }, [])
100
+
70
101
  // Create ChatClient instance with callbacks to sync state
71
102
  const client = useMemo(() => {
72
103
  const messagesToUse = options.initialMessages || []
@@ -81,10 +112,20 @@ export function useChat<
81
112
  ? { connection: initialOptions.connection }
82
113
  : { fetcher: initialOptions.fetcher }
83
114
 
115
+ const instanceHolder: {
116
+ current: ChatClient<TTools, TContext> | undefined
117
+ } = { current: undefined }
118
+ const getActiveInstance = () => {
119
+ const currentInstance = instanceHolder.current
120
+ if (!currentInstance || activeClientRef.current !== currentInstance) {
121
+ return undefined
122
+ }
123
+ return currentInstance
124
+ }
125
+ const pendingInitializationErrors: Array<Error> = []
84
126
  const instance = new ChatClient<TTools, TContext>({
85
127
  devtoolsBridgeFactory: createChatDevtoolsBridge,
86
128
  ...transport,
87
- id: clientId,
88
129
  initialMessages: messagesToUse,
89
130
  ...(initialOptions.body !== undefined && { body: initialOptions.body }),
90
131
  ...(initialOptions.threadId !== undefined && {
@@ -96,6 +137,9 @@ export function useChat<
96
137
  ...(initialOptions.persistence !== undefined && {
97
138
  persistence: initialOptions.persistence,
98
139
  }),
140
+ ...(initialOptions.initialResumeSnapshot !== undefined && {
141
+ initialResumeSnapshot: initialOptions.initialResumeSnapshot,
142
+ }),
99
143
  ...(initialOptions.context !== undefined && {
100
144
  context: initialOptions.context,
101
145
  }),
@@ -106,57 +150,64 @@ export function useChat<
106
150
  outputKind: initialOptions.outputSchema ? 'structured' : 'chat',
107
151
  },
108
152
  onResponse: (response) => {
109
- if (activeClientRef.current !== instance) return
153
+ if (!getActiveInstance()) return
110
154
  void optionsRef.current.onResponse?.(response)
111
155
  },
112
156
  onChunk: (chunk: StreamChunk) => {
113
- if (activeClientRef.current !== instance) return
157
+ if (!getActiveInstance()) return
114
158
  optionsRef.current.onChunk?.(chunk)
115
159
  },
116
160
  onFinish: (message: UIMessage<TTools>) => {
117
- if (activeClientRef.current !== instance) return
161
+ if (!getActiveInstance()) return
118
162
  optionsRef.current.onFinish?.(message)
119
163
  },
120
164
  onError: (error: Error) => {
121
- if (activeClientRef.current !== instance) return
165
+ const currentInstance = instanceHolder.current
166
+ if (!currentInstance) {
167
+ pendingInitializationErrors.push(error)
168
+ return
169
+ }
170
+ if (activeClientRef.current !== currentInstance) return
122
171
  optionsRef.current.onError?.(error)
123
172
  },
124
173
  ...(initialOptions.tools !== undefined && {
125
174
  tools: initialOptions.tools,
126
175
  }),
127
176
  onCustomEvent: (eventType, data, context) => {
128
- if (activeClientRef.current !== instance) return
177
+ if (!getActiveInstance()) return
129
178
  optionsRef.current.onCustomEvent?.(eventType, data, context)
130
179
  },
131
180
  ...(options.streamProcessor !== undefined && {
132
181
  streamProcessor: options.streamProcessor,
133
182
  }),
134
183
  onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
135
- if (activeClientRef.current !== instance) return
184
+ if (!getActiveInstance()) return
136
185
  setMessages(newMessages)
137
186
  },
138
187
  onLoadingChange: (newIsLoading: boolean) => {
139
- if (activeClientRef.current !== instance) return
188
+ const currentInstance = getActiveInstance()
189
+ if (!currentInstance) return
140
190
  setIsLoading(newIsLoading)
191
+ syncResumeState(currentInstance)
141
192
  },
142
193
  onErrorChange: (newError: Error | undefined) => {
143
- if (activeClientRef.current !== instance) return
194
+ if (!getActiveInstance()) return
144
195
  setError(newError)
145
196
  },
146
197
  onStatusChange: (status: ChatClientState) => {
147
- if (activeClientRef.current !== instance) return
198
+ if (!getActiveInstance()) return
148
199
  setStatus(status)
149
200
  },
150
201
  onSubscriptionChange: (nextIsSubscribed: boolean) => {
151
- if (activeClientRef.current !== instance) return
202
+ if (!getActiveInstance()) return
152
203
  setIsSubscribed(nextIsSubscribed)
153
204
  },
154
205
  onConnectionStatusChange: (nextStatus: ConnectionStatus) => {
155
- if (activeClientRef.current !== instance) return
206
+ if (!getActiveInstance()) return
156
207
  setConnectionStatus(nextStatus)
157
208
  },
158
209
  onSessionGeneratingChange: (isGenerating: boolean) => {
159
- if (activeClientRef.current !== instance) return
210
+ if (!getActiveInstance()) return
160
211
  setSessionGenerating(isGenerating)
161
212
  },
162
213
  ...(optionsRef.current.queue !== undefined && {
@@ -166,10 +217,32 @@ export function useChat<
166
217
  if (activeClientRef.current !== instance) return
167
218
  setQueue(nextQueue)
168
219
  },
220
+ onRunIdChange: (nextRunId) => {
221
+ if (!getActiveInstance()) return
222
+ setRunId(nextRunId)
223
+ },
224
+ onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => {
225
+ if (!getActiveInstance()) return
226
+ setInterruptState((current) => ({
227
+ ...current,
228
+ interrupts: nextPendingInterrupts,
229
+ pendingInterrupts: nextPendingInterrupts,
230
+ }))
231
+ },
232
+ onInterruptStateChange: (nextInterruptState) => {
233
+ if (!getActiveInstance()) return
234
+ setInterruptState(nextInterruptState)
235
+ optionsRef.current.onInterruptStateChange?.(nextInterruptState)
236
+ },
169
237
  })
238
+ instanceHolder.current = instance
170
239
  activeClientRef.current = instance
240
+ for (const error of pendingInitializationErrors) {
241
+ if (activeClientRef.current !== instance) break
242
+ optionsRef.current.onError?.(error)
243
+ }
171
244
  return instance
172
- }, [clientId])
245
+ }, [clientId, syncResumeState])
173
246
 
174
247
  useEffect(() => {
175
248
  const clientMessages = client.getMessages()
@@ -211,18 +284,55 @@ export function useChat<
211
284
  useEffect(() => {
212
285
  if (options.live) {
213
286
  client.subscribe()
214
- } else {
287
+ subscribedRef.current = true
288
+ } else if (subscribedRef.current) {
289
+ // Only tear down a subscription we actually started. Calling
290
+ // `unsubscribe()` on initial mount (when `live` was never enabled) would
291
+ // abort an in-flight delivery resume — `resumeInFlightRun` is kicked off
292
+ // in the client constructor, and `unsubscribe()` cancels the shared
293
+ // in-flight stream — so a mid-stream reload would drop its rejoin before
294
+ // it delivers a single chunk. This is exactly why a reload froze instead
295
+ // of continuing.
215
296
  client.unsubscribe()
297
+ subscribedRef.current = false
216
298
  }
217
299
  }, [client, options.live])
218
300
 
301
+ // ONLY THE VIEW ON SCREEN HOLDS A STREAM.
302
+ //
303
+ // A page can own many chats — 40 sandboxes, 40 conversations — and a browser
304
+ // allows only ~6 connections per origin. One long-lived stream per chat reaches
305
+ // that ceiling after a handful of views, and every request after it QUEUES:
306
+ // measured, an in-page fetch took over two minutes while the same request from
307
+ // outside the browser took 17ms. So the connection follows the view.
308
+ //
309
+ // Immediate, not deferred: the deferred teardown below can be skipped when the
310
+ // same client remounts, which is right for disposal but useless for a
311
+ // connection. `attach` is idempotent and `detach` keeps the transcript and the
312
+ // resume pointer, so a Strict Mode remount is just detach-then-attach and the
313
+ // run is picked straight back up from the durable log.
314
+ useEffect(() => {
315
+ client.attach()
316
+ return () => {
317
+ client.detach()
318
+ }
319
+ }, [client])
320
+
219
321
  useEffect(() => {
322
+ if (cleanupDisposalRef.current?.client === client) {
323
+ clearTimeout(cleanupDisposalRef.current.timeout)
324
+ cleanupDisposalRef.current = null
325
+ }
220
326
  if (cleanupInvalidationRef.current) {
221
327
  clearTimeout(cleanupInvalidationRef.current)
222
328
  cleanupInvalidationRef.current = null
223
329
  }
224
330
  activeClientRef.current = client
225
331
  client.mountDevtools()
332
+ // Delivery-durability resume is transparent: the resumable SSE connection
333
+ // adapter re-attaches via the browser's native Last-Event-ID on reconnect.
334
+ // We only seed interrupt (state) resume from the client here.
335
+ syncResumeState(client)
226
336
 
227
337
  return () => {
228
338
  cleanupInvalidationRef.current = setTimeout(() => {
@@ -231,46 +341,71 @@ export function useChat<
231
341
  }
232
342
  cleanupInvalidationRef.current = null
233
343
  }, 0)
234
- // Subscribe/unsubscribe on `options.live` is owned by the dedicated
235
- // effect above. This cleanup only fires on unmount or client swap,
236
- // so read `live` through the ref to avoid disposing the client every
237
- // time `live` toggles.
238
- if (optionsRef.current.live) {
239
- client.unsubscribe()
240
- } else {
241
- client.stop()
344
+ // Soft cleanup only: do NOT stop/unsubscribe here. React Strict Mode
345
+ // remounts fire this cleanup then re-attach the same client one tick
346
+ // later; calling `stop()` would abort a constructor rejoin
347
+ // (`resumeInFlightRun`) and can wipe the durable resume pointer before
348
+ // the first chunk. Real teardown lives in the deferred dispose path
349
+ // below, which only runs when the client is not remounted.
350
+ // Subscribe/unsubscribe on `options.live` is still owned by the
351
+ // dedicated effect above for live toggles.
352
+ const disposal = {
353
+ client,
354
+ timeout: setTimeout(() => {
355
+ if (optionsRef.current.live) {
356
+ client.unsubscribe()
357
+ } else {
358
+ client.stop()
359
+ }
360
+ client.dispose()
361
+ if (cleanupDisposalRef.current === disposal) {
362
+ cleanupDisposalRef.current = null
363
+ }
364
+ }, 0),
242
365
  }
243
- client.dispose()
366
+ cleanupDisposalRef.current = disposal
244
367
  }
245
- }, [client])
368
+ }, [client, syncResumeState])
246
369
 
247
370
  const sendMessage = useCallback(
248
371
  async (
249
372
  content: string | MultimodalContent,
250
373
  sendOptions?: SendMessageOptions,
251
374
  ) => {
252
- await client.sendMessage(content, undefined, sendOptions)
375
+ try {
376
+ await client.sendMessage(content, undefined, sendOptions)
377
+ } finally {
378
+ syncResumeState(client)
379
+ }
253
380
  },
254
- [client],
381
+ [client, syncResumeState],
255
382
  )
256
383
 
257
384
  const cancelQueued = useCallback(
258
385
  (id: string) => {
259
386
  client.cancelQueued(id)
260
387
  },
261
- [client],
388
+ [client, syncResumeState],
262
389
  )
263
390
 
264
391
  const append = useCallback(
265
392
  async (message: ModelMessage | UIMessage) => {
266
- await client.append(message)
393
+ try {
394
+ await client.append(message)
395
+ } finally {
396
+ syncResumeState(client)
397
+ }
267
398
  },
268
- [client],
399
+ [client, syncResumeState],
269
400
  )
270
401
 
271
402
  const reload = useCallback(async () => {
272
- await client.reload()
273
- }, [client])
403
+ try {
404
+ await client.reload()
405
+ } finally {
406
+ syncResumeState(client)
407
+ }
408
+ }, [client, syncResumeState])
274
409
 
275
410
  const stop = useCallback(() => {
276
411
  client.stop()
@@ -278,7 +413,8 @@ export function useChat<
278
413
 
279
414
  const clear = useCallback(() => {
280
415
  client.clear()
281
- }, [client])
416
+ syncResumeState(client)
417
+ }, [client, syncResumeState])
282
418
 
283
419
  const setMessagesManually = useCallback(
284
420
  (newMessages: Array<UIMessage<TTools>>) => {
@@ -303,10 +439,50 @@ export function useChat<
303
439
  const addToolApprovalResponse = useCallback(
304
440
  async (response: { id: string; approved: boolean }) => {
305
441
  await client.addToolApprovalResponse(response)
442
+ syncResumeState(client)
443
+ },
444
+ [client, syncResumeState],
445
+ )
446
+
447
+ const resumeInterrupts = useCallback(
448
+ async (resumeItems: Array<RunAgentResumeItem>, state?: ChatResumeState) => {
449
+ const result = await client.resumeInterrupts(
450
+ resumeItems,
451
+ state ?? undefined,
452
+ )
453
+ syncResumeState(client)
454
+ return result
455
+ },
456
+ [client, syncResumeState],
457
+ )
458
+
459
+ const resolveInterrupts = useCallback(
460
+ (
461
+ resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => undefined),
462
+ ) => {
463
+ if (typeof resolution === 'boolean') {
464
+ client.resolveInterrupts(resolution)
465
+ } else {
466
+ client.resolveInterrupts(resolution)
467
+ }
306
468
  },
307
469
  [client],
308
470
  )
309
471
 
472
+ const cancelInterrupts = useCallback(() => {
473
+ client.cancelInterrupts()
474
+ }, [client])
475
+
476
+ const retryInterrupts = useCallback(() => {
477
+ client.retryInterrupts()
478
+ }, [client])
479
+
480
+ const resumeInterruptsUnsafe = useCallback(
481
+ (resumeItems: Array<RunAgentResumeItem>, state?: ChatResumeState) =>
482
+ client.resumeInterruptsUnsafe(resumeItems, state),
483
+ [client],
484
+ )
485
+
310
486
  // The "active" structured-output part is the one on the assistant message
311
487
  // that follows the latest user message. No such message exists between
312
488
  // sendMessage() and the first chunk, so partial/final naturally read as
@@ -355,7 +531,7 @@ export function useChat<
355
531
  // The runtime shape unconditionally exposes partial/final; the public
356
532
  // return type hides them when no outputSchema was supplied. TS can't
357
533
  // structurally narrow across that conditional, so the `as` is the seam.
358
- // eslint-disable-next-line no-restricted-syntax -- hook return shape diverges from generic UseChatReturn<TTools, TSchema> due to conditional type on TSchema; TS can't structurally narrow
534
+ // oxlint-disable-next-line eslint-js/no-restricted-syntax -- hook return shape diverges from generic UseChatReturn<TTools, TSchema> due to conditional type on TSchema; TS can't structurally narrow
359
535
  return {
360
536
  messages: renderedMessages,
361
537
  sendMessage,
@@ -374,6 +550,16 @@ export function useChat<
374
550
  addToolApprovalResponse,
375
551
  queue,
376
552
  cancelQueued,
553
+ runId,
554
+ interrupts: interruptState.interrupts,
555
+ pendingInterrupts: interruptState.pendingInterrupts,
556
+ interruptErrors: interruptState.interruptErrors,
557
+ resuming: interruptState.resuming,
558
+ resolveInterrupts,
559
+ cancelInterrupts,
560
+ retryInterrupts,
561
+ resumeInterruptsUnsafe,
562
+ resumeInterrupts,
377
563
  partial,
378
564
  final,
379
565
  } as unknown as UseChatReturn<TTools, TSchema>
@@ -1,4 +1,5 @@
1
1
  import { useGeneration } from './use-generation'
2
+ import { reconstructAudioResult } from '@tanstack/ai-client'
2
3
  import type { AudioGenerationResult, StreamChunk } from '@tanstack/ai'
3
4
  import type {
4
5
  AIDevtoolsDisplayOptions,
@@ -6,6 +7,7 @@ import type {
6
7
  ConnectConnectionAdapter,
7
8
  GenerationClientState,
8
9
  GenerationFetcher,
10
+ GenerationPersistenceOptions,
9
11
  InferGenerationOutputFromReturn,
10
12
  } from '@tanstack/ai-client'
11
13
 
@@ -19,12 +21,51 @@ export interface UseGenerateAudioOptions<TOutput = AudioGenerationResult> {
19
21
  connection?: ConnectConnectionAdapter
20
22
  /** Direct async function for audio generation */
21
23
  fetcher?: GenerationFetcher<AudioGenerateInput, AudioGenerationResult>
22
- /** Unique identifier for this generation instance */
24
+ /**
25
+ * @deprecated Prefer `threadId`. Only allowed when `threadId` is omitted (see `GenerationPersistenceOptions`).
26
+ */
23
27
  id?: string
24
28
  /** Additional body parameters to send with connect-based adapter requests */
25
29
  body?: Record<string, any>
26
30
  /** Display options for TanStack AI Devtools. */
27
31
  devtools?: AIDevtoolsDisplayOptions
32
+ /**
33
+ * How this generation persists across reloads.
34
+ * - Omit / `false`: ephemeral, in-memory only.
35
+ * - `true`: server-driven — on mount the client hydrates the last generation
36
+ * for its `threadId` from the server (needs a connection with a
37
+ * `hydrateGeneration` handler) and repaints it; it never auto-starts a run.
38
+ */
39
+ persistence?: boolean
40
+ /**
41
+ * The **scope** this generation belongs to: a stable, app-chosen name for the
42
+ * slot successive runs fill — not a link to a chat conversation.
43
+ *
44
+ * The hook starts empty and produces many runs over its life; each gets its
45
+ * own `runId`, but all belong to one scope. Persistence keys on this, so
46
+ * derive it from your own domain and keep it identical across reloads (e.g.
47
+ * `` `video-${videoId}-start-frame` ``). It is also sent as the AG-UI thread
48
+ * id on the wire, which the protocol requires.
49
+ *
50
+ * **Required whenever `persistence` is set** — an app that cannot name the
51
+ * scope has nothing to restore to. Optional for ephemeral generations, where
52
+ * it falls back to `id` purely to satisfy the wire.
53
+ */
54
+ threadId?: string
55
+ /**
56
+ * Server-driven hydration handler for `persistence: true` when the
57
+ * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` /
58
+ * `rpcStream()` adapter built without handlers) — typically a one-line
59
+ * server-function call. The connection's own handler takes precedence.
60
+ */
61
+ hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration']
62
+ /**
63
+ * Re-attach handler that replays a run still generating to completion on
64
+ * mount, when the connection doesn't carry one. Without it, a restored
65
+ * `running` snapshot surfaces as an (interrupted) error. The connection's
66
+ * own handler takes precedence.
67
+ */
68
+ joinRun?: ConnectConnectionAdapter['joinRun']
28
69
  /**
29
70
  * Callback when audio is generated. Can optionally return a transformed value.
30
71
  *
@@ -61,6 +102,13 @@ export interface UseGenerateAudioReturn<TOutput = AudioGenerationResult> {
61
102
  stop: () => void
62
103
  /** Clear result, error, and return to idle */
63
104
  reset: () => void
105
+ /**
106
+ * The id of the generation job currently running, or `null` when nothing is in
107
+ * flight. Each call to `generate` is one job with its own id. Pass it to your
108
+ * own endpoint to cancel or poll the provider job — `stop()` only aborts the
109
+ * local stream, it does not stop work already running on the provider.
110
+ */
111
+ runId: string | null
64
112
  }
65
113
 
66
114
  /**
@@ -94,9 +142,12 @@ export interface UseGenerateAudioReturn<TOutput = AudioGenerationResult> {
94
142
  * ```
95
143
  */
96
144
  export function useGenerateAudio<TTransformed = void>(
97
- options: Omit<UseGenerateAudioOptions, 'onResult'> & {
145
+ options: Omit<
146
+ UseGenerateAudioOptions,
147
+ 'onResult' | 'persistence' | 'threadId' | 'id'
148
+ > & {
98
149
  onResult?: (result: AudioGenerationResult) => TTransformed
99
- },
150
+ } & GenerationPersistenceOptions,
100
151
  ): UseGenerateAudioReturn<
101
152
  InferGenerationOutputFromReturn<AudioGenerationResult, TTransformed>
102
153
  > {
@@ -106,19 +157,15 @@ export function useGenerateAudio<TTransformed = void>(
106
157
  hookName: 'useGenerateAudio',
107
158
  outputKind: 'audio' as const,
108
159
  }
109
- const { generate, result, isLoading, error, status, stop, reset } =
110
- useGeneration<AudioGenerateInput, AudioGenerationResult, TTransformed>({
111
- ...options,
112
- devtools,
113
- })
160
+ const generation = useGeneration<
161
+ AudioGenerateInput,
162
+ AudioGenerationResult,
163
+ TTransformed
164
+ >({
165
+ ...options,
166
+ devtools,
167
+ reconstructResult: reconstructAudioResult,
168
+ })
114
169
 
115
- return {
116
- generate: generate as (input: AudioGenerateInput) => Promise<void>,
117
- result,
118
- isLoading,
119
- error,
120
- status,
121
- stop,
122
- reset,
123
- }
170
+ return generation
124
171
  }