@assistant-ui/ai-sdk 0.0.6 → 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 (79) hide show
  1. package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -2
  2. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
  3. package/dist/aiSDKExtras.d.ts +2 -3
  4. package/dist/aiSDKExtras.d.ts.map +1 -1
  5. package/dist/converters/convertMessage.d.ts +5 -4
  6. package/dist/converters/convertMessage.d.ts.map +1 -1
  7. package/dist/converters/convertMessage.js +39 -3
  8. package/dist/converters/convertMessage.js.map +1 -1
  9. package/dist/converters/modelContentEnvelope.d.ts +4 -5
  10. package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
  11. package/dist/converters/toCreateMessage.d.ts +1 -2
  12. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  13. package/dist/converters/toolOutputConversion.d.ts +2 -3
  14. package/dist/converters/toolOutputConversion.d.ts.map +1 -1
  15. package/dist/hooks.d.ts +2 -3
  16. package/dist/hooks.d.ts.map +1 -1
  17. package/dist/model-context/injectInteractableContext.d.ts +1 -2
  18. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  19. package/dist/model-context/injectQuoteContext.d.ts +1 -2
  20. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  21. package/dist/runtime/AISDKChat.d.ts +2 -3
  22. package/dist/runtime/AISDKChat.d.ts.map +1 -1
  23. package/dist/runtime/AISDKThreads.d.ts +2 -3
  24. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  25. package/dist/runtime/AISDKThreads.js +4 -10
  26. package/dist/runtime/AISDKThreads.js.map +1 -1
  27. package/dist/runtime/sdkIdentity.d.ts +1 -2
  28. package/dist/runtime/sdkIdentity.d.ts.map +1 -1
  29. package/dist/runtime/sdkIdentity.js +1 -1
  30. package/dist/runtime/useAISDKRuntime.d.ts +17 -5
  31. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  32. package/dist/runtime/useAISDKRuntime.js +95 -37
  33. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  34. package/dist/runtime/useChatRuntime.d.ts +2 -3
  35. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  36. package/dist/runtime/useChatThread.d.ts +19 -5
  37. package/dist/runtime/useChatThread.d.ts.map +1 -1
  38. package/dist/runtime/useChatThread.js +30 -9
  39. package/dist/runtime/useChatThread.js.map +1 -1
  40. package/dist/runtime/useExternalHistory.d.ts +2 -3
  41. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  42. package/dist/runtime/useExternalHistory.js +28 -20
  43. package/dist/runtime/useExternalHistory.js.map +1 -1
  44. package/dist/runtime/useResourceCleanup.d.ts +1 -2
  45. package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
  46. package/dist/runtime/useStreamingTiming.d.ts +2 -3
  47. package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
  48. package/dist/tools/frontendTools.d.ts +3 -4
  49. package/dist/tools/frontendTools.d.ts.map +1 -1
  50. package/dist/tools/generativeTools.d.ts +5 -6
  51. package/dist/tools/generativeTools.d.ts.map +1 -1
  52. package/dist/tools/mcp-stdio.unsupported.d.ts +1 -2
  53. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
  54. package/dist/transport/AssistantChatTransport.d.ts +3 -4
  55. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  56. package/dist/transport/resumable.d.ts +4 -5
  57. package/dist/transport/resumable.d.ts.map +1 -1
  58. package/dist/usage.d.ts +4 -5
  59. package/dist/usage.d.ts.map +1 -1
  60. package/dist/utils/getVercelAIMessages.d.ts +1 -2
  61. package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
  62. package/dist/utils/sliceMessagesUntil.d.ts +1 -2
  63. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  64. package/package.json +15 -14
  65. package/src/converters/convertMessage.test.ts +138 -0
  66. package/src/converters/convertMessage.ts +94 -5
  67. package/src/runtime/AISDKChat.test.ts +4 -5
  68. package/src/runtime/AISDKThreads.test.ts +9 -5
  69. package/src/runtime/AISDKThreads.ts +3 -12
  70. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +287 -0
  71. package/src/runtime/useAISDKRuntime.approval.test.tsx +257 -1
  72. package/src/runtime/useAISDKRuntime.test.ts +7 -3
  73. package/src/runtime/useAISDKRuntime.ts +147 -23
  74. package/src/runtime/useAISDKRuntime.voice.test.tsx +270 -0
  75. package/src/runtime/useChatRuntime.test.ts +72 -4
  76. package/src/runtime/useChatThread.ts +44 -5
  77. package/src/runtime/useExternalHistory.test.ts +46 -0
  78. package/src/runtime/useExternalHistory.ts +24 -10
  79. package/src/tools/generativeTools.test.ts +7 -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,
@@ -52,7 +53,10 @@ import { sliceMessagesUntil } from "../utils/sliceMessagesUntil";
52
53
  import { toCreateMessage } from "../converters/toCreateMessage";
53
54
  import { vercelAttachmentAdapter } from "../adapters/vercelAttachmentAdapter";
54
55
  import { getVercelAIMessages } from "../utils/getVercelAIMessages";
55
- import { AISDKMessageConverter } from "../converters/convertMessage";
56
+ import {
57
+ AISDKMessageConverter,
58
+ type AISDKMessageConverterMetadata,
59
+ } from "../converters/convertMessage";
56
60
  import { wrapModelContentEnvelope } from "../converters/modelContentEnvelope";
57
61
  import {
58
62
  type AISDKStorageFormat,
@@ -81,6 +85,23 @@ const toUIMessage = <UI_MESSAGE extends UIMessage>(
81
85
  role: createMessage.role ?? fallbackRole,
82
86
  }) as UI_MESSAGE;
83
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
+
84
105
  export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
85
106
  ExternalStoreSharedOptions & {
86
107
  adapters?:
@@ -115,6 +136,24 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
115
136
  * Provide this to bridge resume-tool-call invocations into a custom handler.
116
137
  */
117
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;
118
157
  /**
119
158
  * How consecutive assistant messages are rendered.
120
159
  *
@@ -220,6 +259,11 @@ const useGeneratedSuggestions = (
220
259
 
221
260
  const NO_CANCELLED_MESSAGE_IDS: ReadonlySet<string> = new Set();
222
261
 
262
+ const NO_TOOL_APPROVAL_RESPONSES: ReadonlyMap<
263
+ string,
264
+ RespondToToolApprovalOptions
265
+ > = new Map();
266
+
223
267
  const toChatError = (error: Error): AssistantError => {
224
268
  const code = (error as { code?: unknown }).code;
225
269
  return {
@@ -243,6 +287,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
243
287
  cancelPendingToolCallsOnSend = true,
244
288
  onResume,
245
289
  onResumeToolCall,
290
+ onRespondToToolApproval: customOnRespondToToolApproval,
246
291
  joinStrategy,
247
292
  messageRepository,
248
293
  unstable_onBranchChange,
@@ -256,6 +301,10 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
256
301
  chatId: string;
257
302
  ids: ReadonlySet<string>;
258
303
  } | null>(null);
304
+ const [toolApprovalResponses, setToolApprovalResponses] = useState<
305
+ ReadonlyMap<string, RespondToToolApprovalOptions>
306
+ >(NO_TOOL_APPROVAL_RESPONSES);
307
+ const hostApprovalIdsRef = useRef(new Set<string>());
259
308
  const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
260
309
  new Map(),
261
310
  );
@@ -285,6 +334,22 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
285
334
  cancelledMessages?.chatId === chatHelpers.id
286
335
  ? cancelledMessages.ids
287
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
+ );
288
353
 
289
354
  const retractCancellation = useCallback(
290
355
  (chatId: string, messageId: string) => {
@@ -321,18 +386,20 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
321
386
  isRunning,
322
387
  messages: chatHelpers.messages,
323
388
  joinStrategy,
324
- metadata: useMemo(
389
+ metadata: useMemo<AISDKMessageConverterMetadata>(
325
390
  () => ({
326
391
  toolStatuses,
327
392
  messageTiming,
328
393
  toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
329
394
  toolLastInputCache: toolLastInputCacheRef.current,
330
395
  mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
396
+ supportsRichToolApprovalResponses,
331
397
  ...(optimisticMessageId && { optimisticMessageId }),
332
398
  ...(chatHelpers.error && {
333
399
  error: toChatError(chatHelpers.error),
334
400
  }),
335
401
  ...(cancelledMessageIds.size > 0 && { cancelledMessageIds }),
402
+ ...(toolApprovalResponses.size > 0 && { toolApprovalResponses }),
336
403
  }),
337
404
  [
338
405
  toolStatuses,
@@ -340,6 +407,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
340
407
  optimisticMessageId,
341
408
  chatHelpers.error,
342
409
  cancelledMessageIds,
410
+ toolApprovalResponses,
411
+ supportsRichToolApprovalResponses,
343
412
  ],
344
413
  ),
345
414
  });
@@ -347,13 +416,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
347
416
  const exportedMessageRepository = useMemo(() => {
348
417
  if (!messageRepository) return undefined;
349
418
  const converted = toExportedMessageRepository(
350
- AISDKMessageConverter.toThreadMessages as (
351
- messages: UI_MESSAGE[],
352
- ) => ThreadMessage[],
419
+ toThreadMessages,
353
420
  messageRepository,
354
421
  );
355
422
  return converted.messages.length > 0 ? converted : undefined;
356
- }, [messageRepository]);
423
+ }, [messageRepository, toThreadMessages]);
357
424
 
358
425
  const generatedSuggestions = useGeneratedSuggestions(
359
426
  suggestionAdapter,
@@ -370,9 +437,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
370
437
  const { isLoading, deleteMessage: deleteHistoryMessage } = useExternalHistory(
371
438
  runtimeRef,
372
439
  adapters?.history ?? contextAdapters?.history,
373
- AISDKMessageConverter.toThreadMessages as (
374
- messages: UI_MESSAGE[],
375
- ) => ThreadMessage[],
440
+ toThreadMessages,
376
441
  aiSDKV6FormatAdapter as MessageFormatAdapter<
377
442
  UI_MESSAGE,
378
443
  AISDKStorageFormat
@@ -434,6 +499,69 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
434
499
  });
435
500
  };
436
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
+
437
565
  const hasSeededRepositoryRef = useRef(false);
438
566
  const shouldFeedRepository =
439
567
  exportedMessageRepository != null &&
@@ -461,6 +589,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
461
589
  .filter(Boolean)
462
590
  .flat(),
463
591
  ),
592
+ onVoiceTranscript: (message: ThreadMessage) =>
593
+ chatHelpers.setMessages((current) => [
594
+ ...current,
595
+ toVoiceTranscriptUIMessage<UI_MESSAGE>(message),
596
+ ]),
464
597
  onExportExternalState: (): MessageFormatRepository<UI_MESSAGE> => {
465
598
  const exported = runtimeRef.current.thread.export();
466
599
 
@@ -501,10 +634,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
501
634
  },
502
635
  onLoadExternalState: (repo: MessageFormatRepository<UI_MESSAGE>) => {
503
636
  // Convert MessageFormatRepository to ExportedMessageRepository
504
- const exportedRepo = toExportedMessageRepository(
505
- AISDKMessageConverter.toThreadMessages,
506
- repo,
507
- );
637
+ const exportedRepo = toExportedMessageRepository(toThreadMessages, repo);
508
638
 
509
639
  // Import into the thread's MessageRepository
510
640
  runtimeRef.current.thread.import(exportedRepo);
@@ -634,15 +764,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
634
764
  );
635
765
  }
636
766
  },
637
- onRespondToToolApproval: ({ approvalId, approved, reason }) =>
638
- Promise.resolve(
639
- chatHelpers.addToolApprovalResponse({
640
- id: approvalId,
641
- approved,
642
- ...(reason != null && { reason }),
643
- options: { metadata: lastRunConfigRef.current },
644
- }),
645
- ),
767
+ onRespondToToolApproval: customOnRespondToToolApproval
768
+ ? (response) => respondViaHost(customOnRespondToToolApproval, response)
769
+ : respondViaAISDK,
646
770
  ...pickExternalStoreSharedOptions(adapter),
647
771
  ...(adapter.unstable_messageRepositoryInstance && {
648
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) => ({
@@ -95,6 +100,51 @@ describe("useChatRuntime", () => {
95
100
  window.sessionStorage.clear();
96
101
  });
97
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
+
98
148
  it("forwards a defined chat update throttle to useChat", () => {
99
149
  mocks.useChat.mockReturnValue({
100
150
  resumeStream: vi.fn(),
@@ -111,6 +161,24 @@ describe("useChatRuntime", () => {
111
161
  expect(mocks.useChat.mock.calls[1]?.[0]).not.toHaveProperty("throttle");
112
162
  });
113
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
+
114
182
  it("waits for external history to load before resuming a stream", async () => {
115
183
  mocks.state.isLoadingHistory = true;
116
184
  const resumeStream = vi.fn().mockResolvedValue(undefined);
@@ -283,8 +351,8 @@ describe("useChatRuntime", () => {
283
351
  resumeStream: vi.fn().mockResolvedValue(undefined),
284
352
  status: "streaming",
285
353
  };
286
- mocks.useChat.mockImplementation(({ id }: { id: string }) =>
287
- id === "thread-a" ? threadA : threadB,
354
+ mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
355
+ chat.id === "thread-a" ? threadA : threadB,
288
356
  );
289
357
 
290
358
  mocks.state.threadId = "thread-a";
@@ -321,8 +389,8 @@ describe("useChatRuntime", () => {
321
389
  resumeStream: vi.fn().mockResolvedValue(undefined),
322
390
  status: "ready",
323
391
  };
324
- mocks.useChat.mockImplementation(({ id }: { id: string }) =>
325
- id === "__LOCALID_background" ? backgroundThread : mainThread,
392
+ mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
393
+ chat.id === "__LOCALID_background" ? backgroundThread : mainThread,
326
394
  );
327
395
  const transport = {
328
396
  getResumableAdapter: () => ({