@assistant-ui/ai-sdk 0.0.5 → 0.0.7

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 (92) hide show
  1. package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -8
  2. package/dist/adapters/aiSDKFormatAdapter.js +1 -25
  3. package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -2
  4. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
  5. package/dist/aiSDKExtras.d.ts +2 -3
  6. package/dist/aiSDKExtras.d.ts.map +1 -1
  7. package/dist/converters/convertMessage.d.ts +5 -4
  8. package/dist/converters/convertMessage.d.ts.map +1 -1
  9. package/dist/converters/convertMessage.js +40 -3
  10. package/dist/converters/convertMessage.js.map +1 -1
  11. package/dist/converters/modelContentEnvelope.d.ts +4 -5
  12. package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
  13. package/dist/converters/toCreateMessage.d.ts +1 -2
  14. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  15. package/dist/converters/toolOutputConversion.d.ts +2 -3
  16. package/dist/converters/toolOutputConversion.d.ts.map +1 -1
  17. package/dist/hooks.d.ts +2 -3
  18. package/dist/hooks.d.ts.map +1 -1
  19. package/dist/model-context/injectInteractableContext.d.ts +1 -2
  20. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  21. package/dist/model-context/injectQuoteContext.d.ts +1 -2
  22. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  23. package/dist/runtime/AISDKChat.d.ts +2 -3
  24. package/dist/runtime/AISDKChat.d.ts.map +1 -1
  25. package/dist/runtime/AISDKThreads.d.ts +2 -3
  26. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  27. package/dist/runtime/AISDKThreads.js +87 -68
  28. package/dist/runtime/AISDKThreads.js.map +1 -1
  29. package/dist/runtime/sdkIdentity.d.ts +5 -0
  30. package/dist/runtime/sdkIdentity.d.ts.map +1 -0
  31. package/dist/runtime/sdkIdentity.js +9 -0
  32. package/dist/runtime/sdkIdentity.js.map +1 -0
  33. package/dist/runtime/useAISDKRuntime.d.ts +17 -5
  34. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  35. package/dist/runtime/useAISDKRuntime.js +103 -38
  36. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  37. package/dist/runtime/useChatRuntime.d.ts +2 -3
  38. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  39. package/dist/runtime/useChatRuntime.js +5 -1
  40. package/dist/runtime/useChatRuntime.js.map +1 -1
  41. package/dist/runtime/useChatThread.d.ts +19 -5
  42. package/dist/runtime/useChatThread.d.ts.map +1 -1
  43. package/dist/runtime/useChatThread.js +31 -10
  44. package/dist/runtime/useChatThread.js.map +1 -1
  45. package/dist/runtime/useExternalHistory.d.ts +2 -3
  46. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  47. package/dist/runtime/useExternalHistory.js +31 -20
  48. package/dist/runtime/useExternalHistory.js.map +1 -1
  49. package/dist/runtime/useResourceCleanup.d.ts +1 -2
  50. package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
  51. package/dist/runtime/useStreamingTiming.d.ts +2 -3
  52. package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
  53. package/dist/tools/frontendTools.d.ts +3 -4
  54. package/dist/tools/frontendTools.d.ts.map +1 -1
  55. package/dist/tools/generativeTools.d.ts +5 -6
  56. package/dist/tools/generativeTools.d.ts.map +1 -1
  57. package/dist/tools/mcp-stdio.unsupported.d.ts +1 -2
  58. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
  59. package/dist/transport/AssistantChatTransport.d.ts +3 -4
  60. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  61. package/dist/transport/resumable.d.ts +4 -5
  62. package/dist/transport/resumable.d.ts.map +1 -1
  63. package/dist/usage.d.ts +4 -5
  64. package/dist/usage.d.ts.map +1 -1
  65. package/dist/utils/getVercelAIMessages.d.ts +1 -2
  66. package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
  67. package/dist/utils/sliceMessagesUntil.d.ts +1 -2
  68. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  69. package/package.json +15 -14
  70. package/src/adapters/aiSDKFormatAdapter.ts +4 -41
  71. package/src/converters/convertMessage.test.ts +152 -0
  72. package/src/converters/convertMessage.ts +95 -5
  73. package/src/runtime/AISDKChat.test.ts +4 -5
  74. package/src/runtime/AISDKThreads.cloud.test.ts +12 -3
  75. package/src/runtime/AISDKThreads.test.ts +140 -13
  76. package/src/runtime/AISDKThreads.ts +23 -4
  77. package/src/runtime/sdkIdentity.ts +9 -0
  78. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +287 -0
  79. package/src/runtime/useAISDKRuntime.approval.test.tsx +257 -1
  80. package/src/runtime/useAISDKRuntime.test.ts +46 -4
  81. package/src/runtime/useAISDKRuntime.ts +164 -24
  82. package/src/runtime/useAISDKRuntime.voice.test.tsx +270 -0
  83. package/src/runtime/useChatRuntime.test.ts +72 -5
  84. package/src/runtime/useChatRuntime.ts +2 -1
  85. package/src/runtime/useChatThread.test.ts +74 -0
  86. package/src/runtime/useChatThread.ts +45 -6
  87. package/src/runtime/useExternalHistory.test.ts +75 -0
  88. package/src/runtime/useExternalHistory.ts +28 -11
  89. package/src/tools/generativeTools.test.ts +7 -1
  90. package/src/transport/AssistantChatTransport.test.ts +1 -9
  91. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +0 -1
  92. package/dist/adapters/aiSDKFormatAdapter.js.map +0 -1
@@ -14,7 +14,7 @@ import type {
14
14
  CreateUIMessage,
15
15
  UseChatHelpers,
16
16
  } from "@ai-sdk/react";
17
- import { isToolUIPart, generateId } from "ai";
17
+ import { isToolUIPart, generateId, getToolName } from "ai";
18
18
  import {
19
19
  useExternalStoreRuntime,
20
20
  useRuntimeAdapters,
@@ -37,6 +37,7 @@ import type {
37
37
  AppendMessage,
38
38
  RunConfig,
39
39
  McpAppMetadata,
40
+ RespondToToolApprovalOptions,
40
41
  } from "@assistant-ui/core";
41
42
  import {
42
43
  getExternalStoreMessages,
@@ -47,11 +48,15 @@ import {
47
48
  MessageRepository,
48
49
  } from "@assistant-ui/core/internal";
49
50
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
51
+ import type { AssistantError } from "@assistant-ui/core";
50
52
  import { sliceMessagesUntil } from "../utils/sliceMessagesUntil";
51
53
  import { toCreateMessage } from "../converters/toCreateMessage";
52
54
  import { vercelAttachmentAdapter } from "../adapters/vercelAttachmentAdapter";
53
55
  import { getVercelAIMessages } from "../utils/getVercelAIMessages";
54
- import { AISDKMessageConverter } from "../converters/convertMessage";
56
+ import {
57
+ AISDKMessageConverter,
58
+ type AISDKMessageConverterMetadata,
59
+ } from "../converters/convertMessage";
55
60
  import { wrapModelContentEnvelope } from "../converters/modelContentEnvelope";
56
61
  import {
57
62
  type AISDKStorageFormat,
@@ -80,6 +85,23 @@ const toUIMessage = <UI_MESSAGE extends UIMessage>(
80
85
  role: createMessage.role ?? fallbackRole,
81
86
  }) as UI_MESSAGE;
82
87
 
88
+ const toVoiceTranscriptUIMessage = <UI_MESSAGE extends UIMessage>(
89
+ message: ThreadMessage,
90
+ ): UI_MESSAGE =>
91
+ ({
92
+ id: message.id,
93
+ role: message.role,
94
+ parts: message.content
95
+ .filter((part) => part.type === "text")
96
+ .map((part) => ({ type: "text", text: part.text })),
97
+ metadata: {
98
+ modality: "voice",
99
+ ...(Object.keys(message.metadata.custom).length > 0 && {
100
+ custom: message.metadata.custom,
101
+ }),
102
+ },
103
+ }) as UI_MESSAGE;
104
+
83
105
  export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
84
106
  ExternalStoreSharedOptions & {
85
107
  adapters?:
@@ -114,6 +136,24 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
114
136
  * Provide this to bridge resume-tool-call invocations into a custom handler.
115
137
  */
116
138
  onResumeToolCall?: ExternalStoreAdapter["onResumeToolCall"];
139
+ /**
140
+ * Answers tool approval requests through a host-owned channel instead of the AI SDK's `addToolApprovalResponse`.
141
+ *
142
+ * Called for every approval request in the thread with the complete response, including option and free-form answers. Hand requests the host does not own to `respondViaAISDK`, which is what runs when this option is omitted. The answer applies to the approval when the handler starts and is removed if it throws. It is never written into the `useChat` messages, so `sendAutomaticallyWhen` cannot forward it, and it lasts as long as this runtime: until then a second response to the same request rejects, and a runtime mounted again over the same chat shows the request open until the resumed run records its resolution in the chat.
143
+ *
144
+ * While a handler is set, an approval's `display`, `allowFreeform` and `options` reach the renderer, because the handler can receive answers the AI SDK cannot carry.
145
+ */
146
+ onRespondToToolApproval?:
147
+ | ((
148
+ response: RespondToToolApprovalOptions,
149
+ context: {
150
+ toolCallId: string;
151
+ toolName: string;
152
+ /** Sends this response through the AI SDK's `addToolApprovalResponse`, which carries only `approved` and `reason`. */
153
+ respondViaAISDK: () => Promise<void>;
154
+ },
155
+ ) => Promise<void> | void)
156
+ | undefined;
117
157
  /**
118
158
  * How consecutive assistant messages are rendered.
119
159
  *
@@ -219,6 +259,24 @@ const useGeneratedSuggestions = (
219
259
 
220
260
  const NO_CANCELLED_MESSAGE_IDS: ReadonlySet<string> = new Set();
221
261
 
262
+ const NO_TOOL_APPROVAL_RESPONSES: ReadonlyMap<
263
+ string,
264
+ RespondToToolApprovalOptions
265
+ > = new Map();
266
+
267
+ const toChatError = (error: Error): AssistantError => {
268
+ const code = (error as { code?: unknown }).code;
269
+ return {
270
+ code:
271
+ typeof code === "string"
272
+ ? code
273
+ : error.name !== "Error"
274
+ ? error.name
275
+ : "unknown",
276
+ message: error.message,
277
+ };
278
+ };
279
+
222
280
  export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
223
281
  chatHelpers: ReturnType<typeof useChat<UI_MESSAGE>>,
224
282
  adapter: AISDKRuntimeAdapter<UI_MESSAGE> = {},
@@ -229,6 +287,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
229
287
  cancelPendingToolCallsOnSend = true,
230
288
  onResume,
231
289
  onResumeToolCall,
290
+ onRespondToToolApproval: customOnRespondToToolApproval,
232
291
  joinStrategy,
233
292
  messageRepository,
234
293
  unstable_onBranchChange,
@@ -242,6 +301,10 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
242
301
  chatId: string;
243
302
  ids: ReadonlySet<string>;
244
303
  } | null>(null);
304
+ const [toolApprovalResponses, setToolApprovalResponses] = useState<
305
+ ReadonlyMap<string, RespondToToolApprovalOptions>
306
+ >(NO_TOOL_APPROVAL_RESPONSES);
307
+ const hostApprovalIdsRef = useRef(new Set<string>());
245
308
  const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
246
309
  new Map(),
247
310
  );
@@ -271,6 +334,22 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
271
334
  cancelledMessages?.chatId === chatHelpers.id
272
335
  ? cancelledMessages.ids
273
336
  : NO_CANCELLED_MESSAGE_IDS;
337
+ const supportsRichToolApprovalResponses =
338
+ customOnRespondToToolApproval != null;
339
+
340
+ const toThreadMessages = useCallback(
341
+ (sourceMessages: UI_MESSAGE[]) => {
342
+ const metadata: AISDKMessageConverterMetadata = {
343
+ supportsRichToolApprovalResponses,
344
+ };
345
+ return AISDKMessageConverter.toThreadMessages(
346
+ sourceMessages,
347
+ false,
348
+ metadata,
349
+ );
350
+ },
351
+ [supportsRichToolApprovalResponses],
352
+ );
274
353
 
275
354
  const retractCancellation = useCallback(
276
355
  (chatId: string, messageId: string) => {
@@ -307,16 +386,20 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
307
386
  isRunning,
308
387
  messages: chatHelpers.messages,
309
388
  joinStrategy,
310
- metadata: useMemo(
389
+ metadata: useMemo<AISDKMessageConverterMetadata>(
311
390
  () => ({
312
391
  toolStatuses,
313
392
  messageTiming,
314
393
  toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
315
394
  toolLastInputCache: toolLastInputCacheRef.current,
316
395
  mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
396
+ supportsRichToolApprovalResponses,
317
397
  ...(optimisticMessageId && { optimisticMessageId }),
318
- ...(chatHelpers.error && { error: chatHelpers.error.message }),
398
+ ...(chatHelpers.error && {
399
+ error: toChatError(chatHelpers.error),
400
+ }),
319
401
  ...(cancelledMessageIds.size > 0 && { cancelledMessageIds }),
402
+ ...(toolApprovalResponses.size > 0 && { toolApprovalResponses }),
320
403
  }),
321
404
  [
322
405
  toolStatuses,
@@ -324,6 +407,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
324
407
  optimisticMessageId,
325
408
  chatHelpers.error,
326
409
  cancelledMessageIds,
410
+ toolApprovalResponses,
411
+ supportsRichToolApprovalResponses,
327
412
  ],
328
413
  ),
329
414
  });
@@ -331,13 +416,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
331
416
  const exportedMessageRepository = useMemo(() => {
332
417
  if (!messageRepository) return undefined;
333
418
  const converted = toExportedMessageRepository(
334
- AISDKMessageConverter.toThreadMessages as (
335
- messages: UI_MESSAGE[],
336
- ) => ThreadMessage[],
419
+ toThreadMessages,
337
420
  messageRepository,
338
421
  );
339
422
  return converted.messages.length > 0 ? converted : undefined;
340
- }, [messageRepository]);
423
+ }, [messageRepository, toThreadMessages]);
341
424
 
342
425
  const generatedSuggestions = useGeneratedSuggestions(
343
426
  suggestionAdapter,
@@ -354,9 +437,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
354
437
  const { isLoading, deleteMessage: deleteHistoryMessage } = useExternalHistory(
355
438
  runtimeRef,
356
439
  adapters?.history ?? contextAdapters?.history,
357
- AISDKMessageConverter.toThreadMessages as (
358
- messages: UI_MESSAGE[],
359
- ) => ThreadMessage[],
440
+ toThreadMessages,
360
441
  aiSDKV6FormatAdapter as MessageFormatAdapter<
361
442
  UI_MESSAGE,
362
443
  AISDKStorageFormat
@@ -418,6 +499,69 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
418
499
  });
419
500
  };
420
501
 
502
+ const respondViaAISDK = ({
503
+ approvalId,
504
+ approved,
505
+ reason,
506
+ }: RespondToToolApprovalOptions) =>
507
+ Promise.resolve(
508
+ chatHelpers.addToolApprovalResponse({
509
+ id: approvalId,
510
+ approved,
511
+ ...(reason != null && { reason }),
512
+ options: { metadata: lastRunConfigRef.current },
513
+ }),
514
+ );
515
+
516
+ const respondViaHost = async (
517
+ onRespond: NonNullable<AISDKRuntimeAdapter["onRespondToToolApproval"]>,
518
+ response: RespondToToolApprovalOptions,
519
+ ) => {
520
+ const { approvalId } = response;
521
+ const requested = chatHelpers.messages
522
+ .flatMap((message) => message.parts)
523
+ .filter(isToolUIPart)
524
+ .find(
525
+ (part) =>
526
+ part.state === "approval-requested" &&
527
+ part.approval.id === approvalId,
528
+ );
529
+ if (!requested || hostApprovalIdsRef.current.has(approvalId))
530
+ throw new Error(
531
+ `Tool approval ${approvalId} is not waiting for a response.`,
532
+ );
533
+
534
+ // A host answer stays out of the useChat messages, where sendAutomaticallyWhen would forward it to the chat route.
535
+ const applyResponse = (applied: boolean) => {
536
+ if (applied) hostApprovalIdsRef.current.add(approvalId);
537
+ else hostApprovalIdsRef.current.delete(approvalId);
538
+ setToolApprovalResponses((prev) => {
539
+ const responses = new Map(prev);
540
+ if (applied) responses.set(approvalId, response);
541
+ else responses.delete(approvalId);
542
+ return responses;
543
+ });
544
+ };
545
+
546
+ applyResponse(true);
547
+ try {
548
+ await onRespond(response, {
549
+ toolCallId: requested.toolCallId,
550
+ toolName: getToolName(requested),
551
+ respondViaAISDK: async () => {
552
+ try {
553
+ await respondViaAISDK(response);
554
+ } finally {
555
+ applyResponse(false);
556
+ }
557
+ },
558
+ });
559
+ } catch (error) {
560
+ if (hostApprovalIdsRef.current.has(approvalId)) applyResponse(false);
561
+ throw error;
562
+ }
563
+ };
564
+
421
565
  const hasSeededRepositoryRef = useRef(false);
422
566
  const shouldFeedRepository =
423
567
  exportedMessageRepository != null &&
@@ -445,6 +589,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
445
589
  .filter(Boolean)
446
590
  .flat(),
447
591
  ),
592
+ onVoiceTranscript: (message: ThreadMessage) =>
593
+ chatHelpers.setMessages((current) => [
594
+ ...current,
595
+ toVoiceTranscriptUIMessage<UI_MESSAGE>(message),
596
+ ]),
448
597
  onExportExternalState: (): MessageFormatRepository<UI_MESSAGE> => {
449
598
  const exported = runtimeRef.current.thread.export();
450
599
 
@@ -485,10 +634,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
485
634
  },
486
635
  onLoadExternalState: (repo: MessageFormatRepository<UI_MESSAGE>) => {
487
636
  // Convert MessageFormatRepository to ExportedMessageRepository
488
- const exportedRepo = toExportedMessageRepository(
489
- AISDKMessageConverter.toThreadMessages,
490
- repo,
491
- );
637
+ const exportedRepo = toExportedMessageRepository(toThreadMessages, repo);
492
638
 
493
639
  // Import into the thread's MessageRepository
494
640
  runtimeRef.current.thread.import(exportedRepo);
@@ -618,15 +764,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
618
764
  );
619
765
  }
620
766
  },
621
- onRespondToToolApproval: ({ approvalId, approved, reason }) =>
622
- Promise.resolve(
623
- chatHelpers.addToolApprovalResponse({
624
- id: approvalId,
625
- approved,
626
- ...(reason != null && { reason }),
627
- options: { metadata: lastRunConfigRef.current },
628
- }),
629
- ),
767
+ onRespondToToolApproval: customOnRespondToToolApproval
768
+ ? (response) => respondViaHost(customOnRespondToToolApproval, response)
769
+ : respondViaAISDK,
630
770
  ...pickExternalStoreSharedOptions(adapter),
631
771
  ...(adapter.unstable_messageRepositoryInstance && {
632
772
  unstable_messageRepositoryInstance:
@@ -0,0 +1,270 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ getExternalStoreMessages,
6
+ type RealtimeVoiceAdapter,
7
+ type ThreadHistoryAdapter,
8
+ type ThreadMessage,
9
+ } from "@assistant-ui/core";
10
+ import type { UIMessage } from "ai";
11
+ import { describe, expect, it, vi } from "vitest";
12
+
13
+ import { useAISDKRuntime } from "./useAISDKRuntime";
14
+
15
+ const createChatHelpers = (initialMessages: UIMessage[] = []) => {
16
+ let currentMessages: UIMessage[] = initialMessages;
17
+ const chatHelpers: any = {
18
+ id: "chat-1",
19
+ status: "ready",
20
+ error: null,
21
+ messages: currentMessages,
22
+ setMessages: vi.fn(
23
+ (next: UIMessage[] | ((current: UIMessage[]) => UIMessage[])) => {
24
+ currentMessages =
25
+ typeof next === "function" ? next(currentMessages) : [...next];
26
+ chatHelpers.messages = currentMessages;
27
+ return currentMessages;
28
+ },
29
+ ),
30
+ sendMessage: vi.fn().mockResolvedValue(undefined),
31
+ regenerate: vi.fn().mockResolvedValue(undefined),
32
+ addToolResult: vi.fn(),
33
+ addToolOutput: vi.fn(),
34
+ stop: vi.fn(),
35
+ };
36
+ return chatHelpers;
37
+ };
38
+
39
+ const createVoiceAdapter = () => {
40
+ let transcriptCallback:
41
+ | ((transcript: RealtimeVoiceAdapter.TranscriptItem) => void)
42
+ | undefined;
43
+ const session: RealtimeVoiceAdapter.Session = {
44
+ status: { type: "running" },
45
+ isMuted: false,
46
+ disconnect: vi.fn(),
47
+ mute: vi.fn(),
48
+ unmute: vi.fn(),
49
+ onStatusChange: () => () => {},
50
+ onTranscript: (callback) => {
51
+ transcriptCallback = callback;
52
+ return () => {
53
+ transcriptCallback = undefined;
54
+ };
55
+ },
56
+ onModeChange: () => () => {},
57
+ onVolumeChange: () => () => {},
58
+ };
59
+ return {
60
+ adapter: { connect: () => session } satisfies RealtimeVoiceAdapter,
61
+ emitTranscript: (transcript: RealtimeVoiceAdapter.TranscriptItem) =>
62
+ transcriptCallback?.(transcript),
63
+ };
64
+ };
65
+
66
+ const createHistoryAdapter = () => {
67
+ const append = vi.fn(async () => {});
68
+ const adapter: ThreadHistoryAdapter = {
69
+ load: vi.fn(),
70
+ append: vi.fn(),
71
+ withFormat: vi.fn().mockReturnValue({
72
+ load: vi.fn().mockResolvedValue({ headId: null, messages: [] }),
73
+ append,
74
+ }),
75
+ };
76
+ return { adapter, append };
77
+ };
78
+
79
+ const textOf = (message: ThreadMessage) =>
80
+ message.content
81
+ .map((part) => (part.type === "text" ? part.text : ""))
82
+ .join("");
83
+
84
+ const renderVoiceRuntime = async (
85
+ chat: ReturnType<typeof createChatHelpers>,
86
+ adapters: { voice: RealtimeVoiceAdapter; history?: ThreadHistoryAdapter },
87
+ ) => {
88
+ const rendered = renderHook(() => useAISDKRuntime(chat, { adapters }));
89
+ await waitFor(() => {
90
+ expect(rendered.result.current.thread.getState().capabilities.voice).toBe(
91
+ true,
92
+ );
93
+ });
94
+ return rendered;
95
+ };
96
+
97
+ describe("useAISDKRuntime voice transcripts", () => {
98
+ it("persists finalized transcripts through the useChat messages", async () => {
99
+ const chat = createChatHelpers();
100
+ const voice = createVoiceAdapter();
101
+ const { result, rerender } = await renderVoiceRuntime(chat, {
102
+ voice: voice.adapter,
103
+ });
104
+
105
+ act(() => {
106
+ result.current.thread.connectVoice();
107
+ voice.emitTranscript({
108
+ role: "assistant",
109
+ text: "Spoken reply",
110
+ isFinal: true,
111
+ });
112
+ });
113
+
114
+ const transcript = chat.messages[0];
115
+ expect(transcript).toEqual({
116
+ id: expect.any(String),
117
+ role: "assistant",
118
+ parts: [{ type: "text", text: "Spoken reply" }],
119
+ metadata: { modality: "voice" },
120
+ });
121
+
122
+ rerender();
123
+
124
+ await waitFor(() => {
125
+ const messages = result.current.thread
126
+ .getState()
127
+ .messages.filter((message) => message.id === transcript.id);
128
+ expect(messages).toHaveLength(1);
129
+ expect(messages[0]?.metadata.modality).toBe("voice");
130
+ expect(getExternalStoreMessages(messages[0]!)).toEqual([transcript]);
131
+ });
132
+ });
133
+
134
+ it("keeps a transcript spoken after a typed reply as its own message", async () => {
135
+ const chat = createChatHelpers([
136
+ {
137
+ id: "typed-user",
138
+ role: "user",
139
+ parts: [{ type: "text", text: "Hi" }],
140
+ },
141
+ {
142
+ id: "typed-assistant",
143
+ role: "assistant",
144
+ parts: [{ type: "text", text: "Typed reply" }],
145
+ },
146
+ ]);
147
+ const voice = createVoiceAdapter();
148
+ const { result, rerender } = await renderVoiceRuntime(chat, {
149
+ voice: voice.adapter,
150
+ });
151
+
152
+ act(() => {
153
+ result.current.thread.connectVoice();
154
+ voice.emitTranscript({
155
+ role: "assistant",
156
+ text: "Spoken reply",
157
+ isFinal: true,
158
+ });
159
+ });
160
+ const transcript = chat.messages[2];
161
+ rerender();
162
+
163
+ await waitFor(() => {
164
+ const spoken = result.current.thread.getState().messages.at(-1)!;
165
+ expect(getExternalStoreMessages(spoken)).toEqual([transcript]);
166
+ });
167
+ const messages = result.current.thread.getState().messages;
168
+ expect(messages.map((message) => message.id)).toEqual([
169
+ "typed-user",
170
+ "typed-assistant",
171
+ transcript.id,
172
+ ]);
173
+ expect(textOf(messages[1]!)).toBe("Typed reply");
174
+ expect(messages[1]?.metadata.modality).toBeUndefined();
175
+ expect(textOf(messages[2]!)).toBe("Spoken reply");
176
+ expect(messages[2]?.metadata.modality).toBe("voice");
177
+ });
178
+
179
+ it("keeps consecutive assistant transcripts as separate messages", async () => {
180
+ const chat = createChatHelpers();
181
+ const voice = createVoiceAdapter();
182
+ const { result, rerender } = await renderVoiceRuntime(chat, {
183
+ voice: voice.adapter,
184
+ });
185
+
186
+ act(() => {
187
+ result.current.thread.connectVoice();
188
+ voice.emitTranscript({ role: "assistant", text: "First", isFinal: true });
189
+ voice.emitTranscript({
190
+ role: "assistant",
191
+ text: "Second",
192
+ isFinal: true,
193
+ });
194
+ });
195
+ const [first, second] = chat.messages;
196
+ rerender();
197
+
198
+ await waitFor(() => {
199
+ const messages = result.current.thread.getState().messages;
200
+ expect(
201
+ messages.map((message) => getExternalStoreMessages(message)),
202
+ ).toEqual([[first], [second]]);
203
+ });
204
+ expect(result.current.thread.getState().messages.map(textOf)).toEqual([
205
+ "First",
206
+ "Second",
207
+ ]);
208
+ });
209
+
210
+ it("keeps the transcript in the thread after the session disconnects", async () => {
211
+ const chat = createChatHelpers();
212
+ const voice = createVoiceAdapter();
213
+ const { result, rerender } = await renderVoiceRuntime(chat, {
214
+ voice: voice.adapter,
215
+ });
216
+
217
+ act(() => {
218
+ result.current.thread.connectVoice();
219
+ voice.emitTranscript({
220
+ role: "assistant",
221
+ text: "Spoken reply",
222
+ isFinal: true,
223
+ });
224
+ });
225
+ const transcript = chat.messages[0];
226
+ rerender();
227
+ await waitFor(() => {
228
+ const messages = result.current.thread.getState().messages;
229
+ expect(getExternalStoreMessages(messages[0]!)).toEqual([transcript]);
230
+ });
231
+
232
+ act(() => {
233
+ result.current.thread.disconnectVoice();
234
+ });
235
+ rerender();
236
+
237
+ const messages = result.current.thread.getState().messages;
238
+ expect(messages).toHaveLength(1);
239
+ expect(messages[0]?.id).toBe(transcript.id);
240
+ expect(messages[0]?.metadata.modality).toBe("voice");
241
+ expect(result.current.thread.getState().voice).toBeUndefined();
242
+ });
243
+
244
+ it("appends a finalized transcript to the history adapter without a text run", async () => {
245
+ const chat = createChatHelpers();
246
+ const voice = createVoiceAdapter();
247
+ const history = createHistoryAdapter();
248
+ const { result, rerender } = await renderVoiceRuntime(chat, {
249
+ voice: voice.adapter,
250
+ history: history.adapter,
251
+ });
252
+
253
+ act(() => {
254
+ result.current.thread.connectVoice();
255
+ voice.emitTranscript({
256
+ role: "assistant",
257
+ text: "Spoken reply",
258
+ isFinal: true,
259
+ });
260
+ });
261
+ const transcript = chat.messages[0];
262
+ rerender();
263
+
264
+ await waitFor(() => expect(history.append).toHaveBeenCalledTimes(1));
265
+ expect(history.append).toHaveBeenCalledWith({
266
+ parentId: null,
267
+ message: transcript,
268
+ });
269
+ });
270
+ });
@@ -53,6 +53,11 @@ const mocks = vi.hoisted(() => {
53
53
 
54
54
  vi.mock("@ai-sdk/react", () => ({
55
55
  useChat: mocks.useChat,
56
+ Chat: class MockChat {
57
+ constructor(config: unknown) {
58
+ Object.assign(this, config);
59
+ }
60
+ },
56
61
  }));
57
62
 
58
63
  vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
@@ -88,7 +93,6 @@ const sendMessagesOptions = {
88
93
 
89
94
  describe("useChatRuntime", () => {
90
95
  beforeEach(() => {
91
- vi.clearAllMocks();
92
96
  mocks.state.isLoadingHistory = false;
93
97
  mocks.state.threadId = "thread-id";
94
98
  mocks.state.mainThreadId = "thread-id";
@@ -96,6 +100,51 @@ describe("useChatRuntime", () => {
96
100
  window.sessionStorage.clear();
97
101
  });
98
102
 
103
+ it("forwards a callback through a ref, so a later render's callback fires instead of the mounted one", () => {
104
+ mocks.useChat.mockReturnValue({
105
+ resumeStream: vi.fn(),
106
+ status: "ready",
107
+ });
108
+
109
+ const onToolCallA = vi.fn();
110
+ const onToolCallB = vi.fn();
111
+
112
+ const { rerender } = renderHook(
113
+ ({ onToolCall }: { onToolCall: typeof onToolCallA }) =>
114
+ useChatRuntime({ onToolCall }),
115
+ { initialProps: { onToolCall: onToolCallA } },
116
+ );
117
+
118
+ const chat = mocks.useChat.mock.calls[0]?.[0]?.chat as {
119
+ onToolCall?: (arg: unknown) => void;
120
+ sendAutomaticallyWhen?: (arg: unknown) => boolean;
121
+ };
122
+
123
+ chat.onToolCall?.("first");
124
+ expect(onToolCallA).toHaveBeenCalledExactlyOnceWith("first");
125
+
126
+ rerender({ onToolCall: onToolCallB });
127
+ chat.onToolCall?.("second");
128
+
129
+ expect(onToolCallB).toHaveBeenCalledExactlyOnceWith("second");
130
+ expect(onToolCallA).toHaveBeenCalledOnce();
131
+ });
132
+
133
+ it("coerces an unset sendAutomaticallyWhen to false, matching useChat's own default", () => {
134
+ mocks.useChat.mockReturnValue({
135
+ resumeStream: vi.fn(),
136
+ status: "ready",
137
+ });
138
+
139
+ renderHook(() => useChatRuntime());
140
+
141
+ const chat = mocks.useChat.mock.calls[0]?.[0]?.chat as {
142
+ sendAutomaticallyWhen?: (arg: unknown) => boolean;
143
+ };
144
+
145
+ expect(chat.sendAutomaticallyWhen?.({})).toBe(false);
146
+ });
147
+
99
148
  it("forwards a defined chat update throttle to useChat", () => {
100
149
  mocks.useChat.mockReturnValue({
101
150
  resumeStream: vi.fn(),
@@ -112,6 +161,24 @@ describe("useChatRuntime", () => {
112
161
  expect(mocks.useChat.mock.calls[1]?.[0]).not.toHaveProperty("throttle");
113
162
  });
114
163
 
164
+ it("forwards a custom approval handler to the runtime only", () => {
165
+ const onRespondToToolApproval = vi.fn();
166
+ mocks.useChat.mockReturnValue({
167
+ resumeStream: vi.fn(),
168
+ status: "ready",
169
+ });
170
+
171
+ renderHook(() => useChatRuntime({ onRespondToToolApproval }));
172
+
173
+ expect(mocks.useAISDKRuntime).toHaveBeenCalledWith(
174
+ expect.anything(),
175
+ expect.objectContaining({ onRespondToToolApproval }),
176
+ );
177
+ expect(mocks.useChat.mock.calls[0]?.[0]).not.toHaveProperty(
178
+ "onRespondToToolApproval",
179
+ );
180
+ });
181
+
115
182
  it("waits for external history to load before resuming a stream", async () => {
116
183
  mocks.state.isLoadingHistory = true;
117
184
  const resumeStream = vi.fn().mockResolvedValue(undefined);
@@ -284,8 +351,8 @@ describe("useChatRuntime", () => {
284
351
  resumeStream: vi.fn().mockResolvedValue(undefined),
285
352
  status: "streaming",
286
353
  };
287
- mocks.useChat.mockImplementation(({ id }: { id: string }) =>
288
- id === "thread-a" ? threadA : threadB,
354
+ mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
355
+ chat.id === "thread-a" ? threadA : threadB,
289
356
  );
290
357
 
291
358
  mocks.state.threadId = "thread-a";
@@ -322,8 +389,8 @@ describe("useChatRuntime", () => {
322
389
  resumeStream: vi.fn().mockResolvedValue(undefined),
323
390
  status: "ready",
324
391
  };
325
- mocks.useChat.mockImplementation(({ id }: { id: string }) =>
326
- id === "__LOCALID_background" ? backgroundThread : mainThread,
392
+ mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
393
+ chat.id === "__LOCALID_background" ? backgroundThread : mainThread,
327
394
  );
328
395
  const transport = {
329
396
  getResumableAdapter: () => ({