@assistant-ui/ai-sdk 0.0.7 → 0.0.8

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 (105) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -2
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  4. package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -3
  5. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
  6. package/dist/adapters/vercelAttachmentAdapter.js +3 -3
  7. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -1
  8. package/dist/aiSDKExtras.d.ts +4 -6
  9. package/dist/aiSDKExtras.d.ts.map +1 -1
  10. package/dist/converters/convertMessage.d.ts +32 -24
  11. package/dist/converters/convertMessage.d.ts.map +1 -1
  12. package/dist/converters/convertMessage.js +43 -9
  13. package/dist/converters/convertMessage.js.map +1 -1
  14. package/dist/converters/modelContentEnvelope.d.ts +6 -7
  15. package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
  16. package/dist/converters/toCreateMessage.d.ts +2 -4
  17. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  18. package/dist/converters/toolOutputConversion.d.ts +20 -22
  19. package/dist/converters/toolOutputConversion.d.ts.map +1 -1
  20. package/dist/hooks.d.ts +2 -4
  21. package/dist/hooks.d.ts.map +1 -1
  22. package/dist/index.d.ts +17 -14
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.native.d.ts +15 -12
  25. package/dist/index.native.d.ts.map +1 -0
  26. package/dist/model-context/injectInteractableContext.d.ts +2 -4
  27. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  28. package/dist/model-context/injectQuoteContext.d.ts +1 -3
  29. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  30. package/dist/runtime/AISDKChat.d.ts +2 -4
  31. package/dist/runtime/AISDKChat.d.ts.map +1 -1
  32. package/dist/runtime/AISDKChat.js +1 -1
  33. package/dist/runtime/AISDKThreads.d.ts +29 -31
  34. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  35. package/dist/runtime/AISDKThreads.js +22 -23
  36. package/dist/runtime/AISDKThreads.js.map +1 -1
  37. package/dist/runtime/sdkIdentity.d.ts +1 -3
  38. package/dist/runtime/sdkIdentity.d.ts.map +1 -1
  39. package/dist/runtime/sdkIdentity.js +1 -1
  40. package/dist/runtime/useAISDKRuntime.d.ts +69 -70
  41. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  42. package/dist/runtime/useAISDKRuntime.js +112 -56
  43. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  44. package/dist/runtime/useChatRuntime.d.ts +6 -8
  45. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  46. package/dist/runtime/useChatRuntime.js +14 -10
  47. package/dist/runtime/useChatRuntime.js.map +1 -1
  48. package/dist/runtime/useChatThread.d.ts +94 -89
  49. package/dist/runtime/useChatThread.d.ts.map +1 -1
  50. package/dist/runtime/useChatThread.js +4 -5
  51. package/dist/runtime/useChatThread.js.map +1 -1
  52. package/dist/runtime/useExternalHistory.d.ts +7 -7
  53. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  54. package/dist/runtime/useExternalHistory.js +229 -59
  55. package/dist/runtime/useExternalHistory.js.map +1 -1
  56. package/dist/runtime/useResourceCleanup.d.ts +1 -3
  57. package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
  58. package/dist/runtime/useResourceCleanup.js +38 -14
  59. package/dist/runtime/useResourceCleanup.js.map +1 -1
  60. package/dist/runtime/useStreamingTiming.d.ts +3 -5
  61. package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
  62. package/dist/runtime/useStreamingTiming.js +1 -1
  63. package/dist/tools/frontendTools.d.ts +21 -23
  64. package/dist/tools/frontendTools.d.ts.map +1 -1
  65. package/dist/tools/generativeTools.d.ts +24 -26
  66. package/dist/tools/generativeTools.d.ts.map +1 -1
  67. package/dist/tools/mcp-stdio.node.d.ts +2 -2
  68. package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
  69. package/dist/tools/mcp-stdio.unsupported.d.ts +2 -4
  70. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
  71. package/dist/transport/AssistantChatTransport.d.ts +14 -16
  72. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  73. package/dist/transport/resumable.d.ts +25 -27
  74. package/dist/transport/resumable.d.ts.map +1 -1
  75. package/dist/usage.d.ts +8 -10
  76. package/dist/usage.d.ts.map +1 -1
  77. package/dist/usage.js.map +1 -1
  78. package/dist/utils/getVercelAIMessages.d.ts +2 -4
  79. package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
  80. package/dist/utils/sliceMessagesUntil.d.ts +1 -3
  81. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  82. package/package.json +9 -8
  83. package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
  84. package/src/adapters/vercelAttachmentAdapter.ts +2 -2
  85. package/src/converters/convertMessage.test.ts +478 -3
  86. package/src/converters/convertMessage.ts +100 -14
  87. package/src/index.native.ts +1 -1
  88. package/src/index.ts +1 -1
  89. package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
  90. package/src/runtime/AISDKThreads.test.ts +79 -46
  91. package/src/runtime/AISDKThreads.ts +1 -1
  92. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +51 -0
  93. package/src/runtime/useAISDKRuntime.approval.test.tsx +225 -9
  94. package/src/runtime/useAISDKRuntime.test.ts +275 -5
  95. package/src/runtime/useAISDKRuntime.ts +141 -37
  96. package/src/runtime/useAISDKRuntime.voice.test.tsx +50 -1
  97. package/src/runtime/useChatRuntime.integration.test.tsx +56 -0
  98. package/src/runtime/useChatRuntime.test.ts +5 -1
  99. package/src/runtime/useChatRuntime.ts +6 -2
  100. package/src/runtime/useChatThread.transport.test.tsx +149 -0
  101. package/src/runtime/useChatThread.ts +33 -5
  102. package/src/runtime/useExternalHistory.test.ts +943 -3
  103. package/src/runtime/useExternalHistory.ts +366 -14
  104. package/src/runtime/useResourceCleanup.ts +41 -15
  105. package/src/usage.ts +1 -1
@@ -38,12 +38,14 @@ import type {
38
38
  RunConfig,
39
39
  McpAppMetadata,
40
40
  RespondToToolApprovalOptions,
41
+ Unstable_ToolInteractionLog,
41
42
  } from "@assistant-ui/core";
42
43
  import {
43
44
  getExternalStoreMessages,
44
45
  pickExternalStoreSharedOptions,
45
46
  } from "@assistant-ui/core";
46
47
  import {
48
+ appendToolInteraction,
47
49
  consumeSuggestionResult,
48
50
  MessageRepository,
49
51
  } from "@assistant-ui/core/internal";
@@ -95,7 +97,7 @@ const toVoiceTranscriptUIMessage = <UI_MESSAGE extends UIMessage>(
95
97
  .filter((part) => part.type === "text")
96
98
  .map((part) => ({ type: "text", text: part.text })),
97
99
  metadata: {
98
- modality: "voice",
100
+ ...(message.metadata.modality && { modality: message.metadata.modality }),
99
101
  ...(Object.keys(message.metadata.custom).length > 0 && {
100
102
  custom: message.metadata.custom,
101
103
  }),
@@ -139,9 +141,9 @@ export type AISDKRuntimeAdapter<UI_MESSAGE extends UIMessage = UIMessage> =
139
141
  /**
140
142
  * Answers tool approval requests through a host-owned channel instead of the AI SDK's `addToolApprovalResponse`.
141
143
  *
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.
144
+ * 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. With a history adapter, the answer is stored with its message once the handler resolves and returns on reload, and a second response to the same request rejects; without one it lasts as long as this runtime, and a runtime mounted again over the same chat shows the request open until the resumed run records its resolution in the chat.
143
145
  *
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.
146
+ * While a handler is set, an approval's `display`, `allowFreeform`, `dismissible` and `options` reach the renderer, because the handler can receive answers the AI SDK cannot carry. A stream declares them through the `approvalDescriptor` of its `tool-approval-request` chunk, the one approval field the AI SDK keeps opaque; the converter reads the request and answer fields from that descriptor when the approval itself lacks them.
145
147
  */
146
148
  onRespondToToolApproval?:
147
149
  | ((
@@ -304,15 +306,33 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
304
306
  const [toolApprovalResponses, setToolApprovalResponses] = useState<
305
307
  ReadonlyMap<string, RespondToToolApprovalOptions>
306
308
  >(NO_TOOL_APPROVAL_RESPONSES);
309
+ const [toolArtifactEpoch, setToolArtifactEpoch] = useState(0);
310
+ const [toolInteractionEpoch, setToolInteractionEpoch] = useState(0);
307
311
  const hostApprovalIdsRef = useRef(new Set<string>());
312
+ const toolApprovalResponsesRef = useRef<
313
+ Map<string, RespondToToolApprovalOptions>
314
+ >(new Map());
308
315
  const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
309
316
  new Map(),
310
317
  );
311
318
  const toolLastInputCacheRef = useRef<Map<string, ReadonlyJSONObject>>(
312
319
  new Map(),
313
320
  );
321
+ const toolArgsTextCacheRef = useRef<
322
+ WeakMap<ReadonlyJSONObject, Map<string, string>>
323
+ >(new WeakMap());
314
324
  const mcpAppMetadataCacheRef = useRef<Map<string, McpAppMetadata>>(new Map());
325
+ const toolArtifactsRef = useRef<Map<string, unknown>>(new Map());
326
+ const toolInteractionsRef = useRef<Map<string, Unstable_ToolInteractionLog>>(
327
+ new Map(),
328
+ );
315
329
  const lastRunConfigRef = useRef<RunConfig | undefined>(undefined);
330
+ const markToolArtifactsChanged = useCallback(() => {
331
+ setToolArtifactEpoch((epoch) => epoch + 1);
332
+ }, []);
333
+ const markToolInteractionsChanged = useCallback(() => {
334
+ setToolInteractionEpoch((epoch) => epoch + 1);
335
+ }, []);
316
336
 
317
337
  const hasExecutingTools = Object.values(toolStatuses).some(
318
338
  (s) => s?.type === "executing",
@@ -341,6 +361,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
341
361
  (sourceMessages: UI_MESSAGE[]) => {
342
362
  const metadata: AISDKMessageConverterMetadata = {
343
363
  supportsRichToolApprovalResponses,
364
+ toolArtifacts: toolArtifactsRef.current,
365
+ toolInteractions: toolInteractionsRef.current,
366
+ toolApprovalResponses: toolApprovalResponsesRef.current,
344
367
  };
345
368
  return AISDKMessageConverter.toThreadMessages(
346
369
  sourceMessages,
@@ -391,8 +414,11 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
391
414
  toolStatuses,
392
415
  messageTiming,
393
416
  toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
417
+ toolArgsTextCache: toolArgsTextCacheRef.current,
394
418
  toolLastInputCache: toolLastInputCacheRef.current,
395
419
  mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
420
+ toolArtifacts: toolArtifactsRef.current,
421
+ toolInteractions: toolInteractionsRef.current,
396
422
  supportsRichToolApprovalResponses,
397
423
  ...(optimisticMessageId && { optimisticMessageId }),
398
424
  ...(chatHelpers.error && {
@@ -409,6 +435,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
409
435
  cancelledMessageIds,
410
436
  toolApprovalResponses,
411
437
  supportsRichToolApprovalResponses,
438
+ toolArtifactEpoch,
439
+ toolInteractionEpoch,
412
440
  ],
413
441
  ),
414
442
  });
@@ -434,7 +462,12 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
434
462
  },
435
463
  }));
436
464
 
437
- const { isLoading, deleteMessage: deleteHistoryMessage } = useExternalHistory(
465
+ const {
466
+ isLoading,
467
+ deleteMessage: deleteHistoryMessage,
468
+ persistToolInteractions,
469
+ persistToolApprovalResponses,
470
+ } = useExternalHistory(
438
471
  runtimeRef,
439
472
  adapters?.history ?? contextAdapters?.history,
440
473
  toThreadMessages,
@@ -445,6 +478,17 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
445
478
  (messages) => {
446
479
  chatHelpers.setMessages(messages);
447
480
  },
481
+ toolArtifactsRef.current,
482
+ markToolArtifactsChanged,
483
+ toolInteractionsRef.current,
484
+ markToolInteractionsChanged,
485
+ toolApprovalResponsesRef.current,
486
+ () => {
487
+ hostApprovalIdsRef.current = new Set(
488
+ toolApprovalResponsesRef.current.keys(),
489
+ );
490
+ setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
491
+ },
448
492
  );
449
493
 
450
494
  const {
@@ -467,35 +511,43 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
467
511
  if (!cancelPendingToolCallsOnSend) return;
468
512
 
469
513
  // The runtime auto-aborts in-flight tool invocations when a new run
470
- // is dispatched (append() / startRun()). All we need to do here is
471
- // mark any tool without a result as cancelled in the UI message list.
472
-
473
- // Mark any tool without a result as cancelled (uses setMessages to avoid triggering sendAutomaticallyWhen)
514
+ // is dispatched (append() / startRun()), so this only has to mark the
515
+ // abandoned tools cancelled in the UI message list. Every non-terminal
516
+ // tool call qualifies wherever it sits: the run that produced it is over,
517
+ // and a staged `startRun: false` message can sit between it and the tail.
518
+ // Uses setMessages to avoid triggering sendAutomaticallyWhen.
474
519
  chatHelpers.setMessages((messages) => {
475
- const lastMessage = messages.at(-1);
476
- if (lastMessage?.role !== "assistant") return messages;
477
-
478
520
  let hasChanges = false;
479
- const parts = lastMessage.parts?.map((part) => {
480
- if (!isToolUIPart(part)) return part;
481
- if (
482
- part.state === "output-available" ||
483
- part.state === "output-error" ||
484
- part.state === "output-denied"
485
- )
486
- return part;
487
521
 
522
+ const next = messages.map((message) => {
523
+ if (message.role !== "assistant") return message;
524
+
525
+ let messageChanged = false;
526
+ const parts = message.parts?.map((part) => {
527
+ if (!isToolUIPart(part)) return part;
528
+ if (
529
+ part.state === "output-available" ||
530
+ part.state === "output-error" ||
531
+ part.state === "output-denied"
532
+ )
533
+ return part;
534
+
535
+ messageChanged = true;
536
+ const { approval: _approval, ...rest } = part;
537
+ return {
538
+ ...rest,
539
+ state: "output-error" as const,
540
+ errorText: "User cancelled tool call by sending a new message.",
541
+ };
542
+ });
543
+
544
+ if (!messageChanged) return message;
488
545
  hasChanges = true;
489
- const { approval: _approval, ...rest } = part;
490
- return {
491
- ...rest,
492
- state: "output-error" as const,
493
- errorText: "User cancelled tool call by sending a new message.",
494
- };
546
+ return { ...message, parts };
495
547
  });
496
548
 
497
549
  if (!hasChanges) return messages;
498
- return [...messages.slice(0, -1), { ...lastMessage, parts }];
550
+ return next;
499
551
  });
500
552
  };
501
553
 
@@ -519,10 +571,13 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
519
571
  ) => {
520
572
  const { approvalId } = response;
521
573
  const requested = chatHelpers.messages
522
- .flatMap((message) => message.parts)
523
- .filter(isToolUIPart)
574
+ .flatMap((message) =>
575
+ message.parts.flatMap((part) =>
576
+ isToolUIPart(part) ? [{ messageId: message.id, part }] : [],
577
+ ),
578
+ )
524
579
  .find(
525
- (part) =>
580
+ ({ part }) =>
526
581
  part.state === "approval-requested" &&
527
582
  part.approval.id === approvalId,
528
583
  );
@@ -535,19 +590,16 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
535
590
  const applyResponse = (applied: boolean) => {
536
591
  if (applied) hostApprovalIdsRef.current.add(approvalId);
537
592
  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
- });
593
+ if (applied) toolApprovalResponsesRef.current.set(approvalId, response);
594
+ else toolApprovalResponsesRef.current.delete(approvalId);
595
+ setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
544
596
  };
545
597
 
546
598
  applyResponse(true);
547
599
  try {
548
600
  await onRespond(response, {
549
- toolCallId: requested.toolCallId,
550
- toolName: getToolName(requested),
601
+ toolCallId: requested.part.toolCallId,
602
+ toolName: getToolName(requested.part),
551
603
  respondViaAISDK: async () => {
552
604
  try {
553
605
  await respondViaAISDK(response);
@@ -560,6 +612,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
560
612
  if (hostApprovalIdsRef.current.has(approvalId)) applyResponse(false);
561
613
  throw error;
562
614
  }
615
+ if (hostApprovalIdsRef.current.has(approvalId)) {
616
+ await persistToolApprovalResponses(requested.messageId);
617
+ }
563
618
  };
564
619
 
565
620
  const hasSeededRepositoryRef = useRef(false);
@@ -569,6 +624,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
569
624
  messages.length === 0;
570
625
 
571
626
  const runtime = useExternalStoreRuntime({
627
+ unstable_persistsHistory: true,
572
628
  isRunning: providerIsRunning,
573
629
  ...(shouldFeedRepository
574
630
  ? { messageRepository: exportedMessageRepository }
@@ -714,6 +770,34 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
714
770
 
715
771
  await deleteHistoryMessage(messageId);
716
772
 
773
+ let removedToolArtifact = false;
774
+ let removedToolInteractions = false;
775
+ let removedToolApprovalResponse = false;
776
+ let removedHostApprovalId = false;
777
+ for (const part of threadMessages[messageIndex]!.content) {
778
+ if (part.type === "tool-call") {
779
+ removedToolArtifact =
780
+ toolArtifactsRef.current.delete(part.toolCallId) ||
781
+ removedToolArtifact;
782
+ removedToolInteractions =
783
+ toolInteractionsRef.current.delete(part.toolCallId) ||
784
+ removedToolInteractions;
785
+ if (part.approval) {
786
+ removedToolApprovalResponse =
787
+ toolApprovalResponsesRef.current.delete(part.approval.id) ||
788
+ removedToolApprovalResponse;
789
+ removedHostApprovalId =
790
+ hostApprovalIdsRef.current.delete(part.approval.id) ||
791
+ removedHostApprovalId;
792
+ }
793
+ }
794
+ }
795
+ if (removedToolArtifact) markToolArtifactsChanged();
796
+ if (removedToolInteractions) markToolInteractionsChanged();
797
+ if (removedToolApprovalResponse || removedHostApprovalId) {
798
+ setToolApprovalResponses(new Map(toolApprovalResponsesRef.current));
799
+ }
800
+
717
801
  const deleteIds = new Set(
718
802
  getExternalStoreMessages<UI_MESSAGE>(threadMessages[messageIndex]!).map(
719
803
  (message) => message.id,
@@ -735,8 +819,13 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
735
819
  toolName,
736
820
  result,
737
821
  isError,
822
+ artifact,
738
823
  modelContent,
739
824
  }) => {
825
+ if (artifact !== undefined) {
826
+ toolArtifactsRef.current.set(toolCallId, artifact);
827
+ markToolArtifactsChanged();
828
+ }
740
829
  const options = { metadata: lastRunConfigRef.current };
741
830
  if (isError) {
742
831
  return Promise.resolve(
@@ -767,6 +856,21 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
767
856
  onRespondToToolApproval: customOnRespondToToolApproval
768
857
  ? (response) => respondViaHost(customOnRespondToToolApproval, response)
769
858
  : respondViaAISDK,
859
+ unstable_onRecordToolInteraction: ({
860
+ messageId,
861
+ toolCallId,
862
+ interaction,
863
+ }) => {
864
+ toolInteractionsRef.current.set(
865
+ toolCallId,
866
+ appendToolInteraction(
867
+ toolInteractionsRef.current.get(toolCallId),
868
+ interaction,
869
+ ),
870
+ );
871
+ markToolInteractionsChanged();
872
+ return persistToolInteractions(messageId);
873
+ },
770
874
  ...pickExternalStoreSharedOptions(adapter),
771
875
  ...(adapter.unstable_messageRepositoryInstance && {
772
876
  unstable_messageRepositoryInstance:
@@ -36,7 +36,9 @@ const createChatHelpers = (initialMessages: UIMessage[] = []) => {
36
36
  return chatHelpers;
37
37
  };
38
38
 
39
- const createVoiceAdapter = () => {
39
+ const createVoiceAdapter = ({
40
+ sendText,
41
+ }: { sendText?: RealtimeVoiceAdapter.Session["sendText"] } = {}) => {
40
42
  let transcriptCallback:
41
43
  | ((transcript: RealtimeVoiceAdapter.TranscriptItem) => void)
42
44
  | undefined;
@@ -46,6 +48,7 @@ const createVoiceAdapter = () => {
46
48
  disconnect: vi.fn(),
47
49
  mute: vi.fn(),
48
50
  unmute: vi.fn(),
51
+ ...(sendText && { sendText }),
49
52
  onStatusChange: () => () => {},
50
53
  onTranscript: (callback) => {
51
54
  transcriptCallback = callback;
@@ -267,4 +270,50 @@ describe("useAISDKRuntime voice transcripts", () => {
267
270
  message: transcript,
268
271
  });
269
272
  });
273
+
274
+ it("persists a message typed into the session as a typed turn", async () => {
275
+ const chat = createChatHelpers();
276
+ const sendText = vi.fn(async (_text: string) => {});
277
+ const voice = createVoiceAdapter({ sendText });
278
+ const history = createHistoryAdapter();
279
+ const { result, rerender } = await renderVoiceRuntime(chat, {
280
+ voice: voice.adapter,
281
+ history: history.adapter,
282
+ });
283
+
284
+ act(() => {
285
+ result.current.thread.connectVoice();
286
+ });
287
+ await act(async () => {
288
+ await result.current.thread.append({
289
+ role: "user",
290
+ content: [{ type: "text", text: "Typed" }],
291
+ });
292
+ });
293
+
294
+ expect(sendText).toHaveBeenCalledExactlyOnceWith("Typed");
295
+ expect(chat.sendMessage).not.toHaveBeenCalled();
296
+ const typed = chat.messages[0];
297
+ expect(typed).toEqual({
298
+ id: expect.any(String),
299
+ role: "user",
300
+ parts: [{ type: "text", text: "Typed" }],
301
+ metadata: {},
302
+ });
303
+ rerender();
304
+
305
+ await waitFor(() => {
306
+ const messages = result.current.thread
307
+ .getState()
308
+ .messages.filter((message) => message.id === typed.id);
309
+ expect(messages).toHaveLength(1);
310
+ expect(messages[0]?.metadata.modality).toBeUndefined();
311
+ expect(textOf(messages[0]!)).toBe("Typed");
312
+ });
313
+ await waitFor(() => expect(history.append).toHaveBeenCalledTimes(1));
314
+ expect(history.append).toHaveBeenCalledWith({
315
+ parentId: null,
316
+ message: typed,
317
+ });
318
+ });
270
319
  });
@@ -1,8 +1,10 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
+ import { getEventListeners } from "node:events";
3
4
  import { act, render, screen, waitFor } from "@testing-library/react";
4
5
  import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
5
6
  import { AuiConfig, AuiProvider, useAuiState } from "@assistant-ui/store";
7
+ import { useAssistantClientDestroySignal } from "@assistant-ui/store/internal";
6
8
  import type { AssistantRuntime } from "@assistant-ui/core";
7
9
  import { AISDKChat } from "./AISDKChat";
8
10
  import type { ChatTransport, UIMessage } from "ai";
@@ -183,6 +185,60 @@ describe("useChatRuntime integration", () => {
183
185
  view.unmount();
184
186
  await waitFor(() => expect(getCancelCount()).toBe(1));
185
187
  });
188
+
189
+ it("aborts a nested runtime when only its own component unmounts", async () => {
190
+ const outer = createCancellableTransport();
191
+ const { transport, getCancelCount } = createCancellableTransport();
192
+ let nested: AssistantRuntime | undefined;
193
+ let providerSignal: AbortSignal | undefined;
194
+ let setVisible: ((visible: boolean) => void) | undefined;
195
+
196
+ const NestedChat = () => {
197
+ providerSignal = useAssistantClientDestroySignal();
198
+ nested = useChatRuntime({ transport });
199
+ return null;
200
+ };
201
+ const Shell = () => {
202
+ const [visible, set] = useState(true);
203
+ setVisible = set;
204
+ return (
205
+ <AuiProvider
206
+ config={AuiConfig({
207
+ threads: AISDKChat({ transport: outer.transport }),
208
+ })}
209
+ >
210
+ {visible && <NestedChat />}
211
+ </AuiProvider>
212
+ );
213
+ };
214
+ const listeners = () => getEventListeners(providerSignal!, "abort").length;
215
+
216
+ const view = render(<Shell />);
217
+ await waitFor(() => expect(nested).toBeDefined());
218
+ const mounted = listeners();
219
+
220
+ for (const cycle of [1, 2]) {
221
+ await act(async () => {
222
+ await nested!.thread.append(`stream ${cycle}`);
223
+ });
224
+ await waitFor(() =>
225
+ expect(nested!.thread.getState().isRunning).toBe(true),
226
+ );
227
+
228
+ await act(async () => setVisible?.(false));
229
+ await waitFor(() => expect(getCancelCount()).toBe(cycle));
230
+ expect(listeners()).toBe(mounted - 1);
231
+
232
+ nested = undefined;
233
+ await act(async () => setVisible?.(true));
234
+ await waitFor(() => expect(nested).toBeDefined());
235
+ expect(listeners()).toBe(mounted);
236
+ }
237
+
238
+ view.unmount();
239
+ await act(nextTask);
240
+ expect(getCancelCount()).toBe(2);
241
+ });
186
242
  });
187
243
 
188
244
  const StreamingApp = ({
@@ -52,7 +52,11 @@ const mocks = vi.hoisted(() => {
52
52
  });
53
53
 
54
54
  vi.mock("@ai-sdk/react", () => ({
55
- useChat: mocks.useChat,
55
+ useChat: (...args: unknown[]) => {
56
+ const chat = mocks.useChat(...args);
57
+ if (chat) chat.stop ??= vi.fn(async () => {});
58
+ return chat;
59
+ },
56
60
  Chat: class MockChat {
57
61
  constructor(config: unknown) {
58
62
  Object.assign(this, config);
@@ -8,6 +8,7 @@ import {
8
8
  useRemoteThreadListRuntime,
9
9
  } from "@assistant-ui/core/react";
10
10
  import { useAui, useAuiState } from "@assistant-ui/store";
11
+ import { useHostDestroySignal } from "@assistant-ui/store/internal";
11
12
  import { useChatThread, type ChatThreadOptions } from "./useChatThread";
12
13
  import { AI_SDK_SDK } from "./sdkIdentity";
13
14
 
@@ -18,7 +19,8 @@ export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
18
19
  };
19
20
 
20
21
  const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
21
- options?: ChatThreadOptions<UI_MESSAGE>,
22
+ options: ChatThreadOptions<UI_MESSAGE> | undefined,
23
+ hostDestroySignal: AbortSignal,
22
24
  ): AssistantRuntime => {
23
25
  const id = useAuiState((s) => s.threadListItem.id);
24
26
  const isMainThread = useAuiState(
@@ -31,6 +33,7 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
31
33
  getThreadListItem: () =>
32
34
  aui.threadListItem.source ? aui.threadListItem : undefined,
33
35
  stopOnClientDestroy: true,
36
+ hostDestroySignal,
34
37
  });
35
38
  };
36
39
 
@@ -39,10 +42,11 @@ export const useChatRuntime = <UI_MESSAGE extends UIMessage = UIMessage>({
39
42
  onThreadIdChange,
40
43
  ...options
41
44
  }: UseChatRuntimeOptions<UI_MESSAGE> = {}): AssistantRuntime => {
45
+ const hostDestroySignal = useHostDestroySignal();
42
46
  const cloudAdapter = useCloudThreadListAdapter({ cloud, sdk: AI_SDK_SDK });
43
47
  return useRemoteThreadListRuntime({
44
48
  runtimeHook: function RuntimeHook() {
45
- return useChatThreadRuntime(options);
49
+ return useChatThreadRuntime(options, hostDestroySignal);
46
50
  },
47
51
  adapter: cloudAdapter,
48
52
  allowNesting: true,
@@ -0,0 +1,149 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { useEffect } from "react";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { Chat } from "@ai-sdk/react";
7
+ import {
8
+ AssistantChatTransport,
9
+ type InitializableThreadListItem,
10
+ } from "../transport/AssistantChatTransport";
11
+ import { useChatThread } from "./useChatThread";
12
+
13
+ const itemFor = (remoteId: string) => ({
14
+ initialize: async () => ({ remoteId, externalId: undefined }),
15
+ });
16
+
17
+ const finishedStream = () =>
18
+ new Response(
19
+ new ReadableStream<Uint8Array>({
20
+ start(controller) {
21
+ controller.enqueue(
22
+ new TextEncoder().encode(
23
+ 'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
24
+ ),
25
+ );
26
+ controller.close();
27
+ },
28
+ }),
29
+ { headers: { "content-type": "text/event-stream" } },
30
+ );
31
+
32
+ describe("useChatThread shared transport isolation", () => {
33
+ it("gives each thread its own clone wired to its own thread-list item", async () => {
34
+ const transport = new AssistantChatTransport({ api: "/api/chat" });
35
+ const setRuntime = vi.spyOn(transport, "setRuntime");
36
+ const setGetItem = vi.spyOn(transport, "__internal_setGetThreadListItem");
37
+ const clones: AssistantChatTransport<never>[] = [];
38
+ const getters: (() => InitializableThreadListItem | undefined)[] = [];
39
+ const realClone = transport.__internal_clone.bind(transport);
40
+ vi.spyOn(transport, "__internal_clone").mockImplementation(() => {
41
+ const clone = realClone();
42
+ const index = clones.push(clone as AssistantChatTransport<never>) - 1;
43
+ vi.spyOn(clone, "__internal_setGetThreadListItem").mockImplementation(
44
+ (getter) => {
45
+ getters[index] = getter;
46
+ },
47
+ );
48
+ return clone;
49
+ });
50
+
51
+ renderHook(() =>
52
+ useChatThread(
53
+ { transport },
54
+ {
55
+ id: "thread-a",
56
+ isMainThread: true,
57
+ getThreadListItem: () => itemFor("remote-a"),
58
+ },
59
+ ),
60
+ );
61
+ renderHook(() =>
62
+ useChatThread(
63
+ { transport },
64
+ {
65
+ id: "thread-b",
66
+ isMainThread: false,
67
+ getThreadListItem: () => itemFor("remote-b"),
68
+ },
69
+ ),
70
+ );
71
+
72
+ expect(setRuntime).not.toHaveBeenCalled();
73
+ expect(setGetItem).not.toHaveBeenCalled();
74
+ expect(clones).toHaveLength(2);
75
+ expect(clones[0]).not.toBe(clones[1]);
76
+
77
+ const remoteIdOf = async (
78
+ getter?: () => InitializableThreadListItem | undefined,
79
+ ) => (await getter?.()?.initialize())?.remoteId;
80
+ expect(await remoteIdOf(getters[0])).toBe("remote-a");
81
+ expect(await remoteIdOf(getters[1])).toBe("remote-b");
82
+ });
83
+
84
+ it("sends each thread's request with that thread's remoteId and model context", async () => {
85
+ const sent: { id: unknown; system: unknown }[] = [];
86
+ const transport = new AssistantChatTransport({
87
+ api: "/api/chat",
88
+ fetch: async (_input, init) => {
89
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
90
+ sent.push({ id: body["id"], system: body["system"] });
91
+ return finishedStream();
92
+ },
93
+ });
94
+
95
+ const mount = (id: string, remoteId: string, system: string) =>
96
+ renderHook(() => {
97
+ const runtime = useChatThread(
98
+ { transport },
99
+ {
100
+ id,
101
+ isMainThread: id === "thread-a",
102
+ getThreadListItem: () => itemFor(remoteId),
103
+ },
104
+ );
105
+ useEffect(
106
+ () =>
107
+ runtime.registerModelContextProvider({
108
+ getModelContext: () => ({ system }),
109
+ }),
110
+ [runtime],
111
+ );
112
+ return runtime;
113
+ });
114
+
115
+ const threadA = mount("thread-a", "remote-a", "system-a");
116
+ const threadB = mount("thread-b", "remote-b", "system-b");
117
+
118
+ threadA.rerender();
119
+
120
+ await act(async () => {
121
+ threadB.result.current.thread.append("hello");
122
+ });
123
+
124
+ await waitFor(() => expect(sent).toHaveLength(1));
125
+ expect(sent[0]).toEqual({ id: "remote-b", system: "system-b" });
126
+ });
127
+
128
+ it("uses the supplied instance when the caller owns the chat", () => {
129
+ const transport = new AssistantChatTransport({ api: "/api/chat" });
130
+ const clone = vi.spyOn(transport, "__internal_clone");
131
+ const chat = new Chat({ id: "thread-a", transport });
132
+
133
+ renderHook(() =>
134
+ useChatThread(
135
+ { transport },
136
+ {
137
+ id: "thread-a",
138
+ isMainThread: true,
139
+ getThreadListItem: () => itemFor("remote-a"),
140
+ chat,
141
+ },
142
+ ),
143
+ );
144
+
145
+ // A caller-owned chat is already bound to its transport, so cloning again
146
+ // would wire an instance the chat never sends through.
147
+ expect(clone).not.toHaveBeenCalled();
148
+ });
149
+ });