@assistant-ui/ai-sdk 0.0.1

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +8 -0
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/aiSDKFormatAdapter.js +26 -0
  6. package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
  7. package/dist/adapters/vercelAttachmentAdapter.d.ts +6 -0
  8. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
  9. package/dist/adapters/vercelAttachmentAdapter.js +37 -0
  10. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
  11. package/dist/aiSDKExtras.d.ts +11 -0
  12. package/dist/aiSDKExtras.d.ts.map +1 -0
  13. package/dist/aiSDKExtras.js +7 -0
  14. package/dist/aiSDKExtras.js.map +1 -0
  15. package/dist/converters/convertMessage.d.ts +28 -0
  16. package/dist/converters/convertMessage.d.ts.map +1 -0
  17. package/dist/converters/convertMessage.js +249 -0
  18. package/dist/converters/convertMessage.js.map +1 -0
  19. package/dist/converters/modelContentEnvelope.d.ts +16 -0
  20. package/dist/converters/modelContentEnvelope.d.ts.map +1 -0
  21. package/dist/converters/modelContentEnvelope.js +22 -0
  22. package/dist/converters/modelContentEnvelope.js.map +1 -0
  23. package/dist/converters/toCreateMessage.d.ts +7 -0
  24. package/dist/converters/toCreateMessage.d.ts.map +1 -0
  25. package/dist/converters/toCreateMessage.js +58 -0
  26. package/dist/converters/toCreateMessage.js.map +1 -0
  27. package/dist/converters/toolOutputConversion.d.ts +29 -0
  28. package/dist/converters/toolOutputConversion.d.ts.map +1 -0
  29. package/dist/converters/toolOutputConversion.js +31 -0
  30. package/dist/converters/toolOutputConversion.js.map +1 -0
  31. package/dist/hooks.d.ts +19 -0
  32. package/dist/hooks.d.ts.map +1 -0
  33. package/dist/hooks.js +20 -0
  34. package/dist/hooks.js.map +1 -0
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +13 -0
  37. package/dist/index.native.d.ts +12 -0
  38. package/dist/index.native.js +11 -0
  39. package/dist/model-context/injectInteractableContext.d.ts +37 -0
  40. package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
  41. package/dist/model-context/injectInteractableContext.js +52 -0
  42. package/dist/model-context/injectInteractableContext.js.map +1 -0
  43. package/dist/model-context/injectQuoteContext.d.ts +27 -0
  44. package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
  45. package/dist/model-context/injectQuoteContext.js +51 -0
  46. package/dist/model-context/injectQuoteContext.js.map +1 -0
  47. package/dist/runtime/AISDKChat.d.ts +16 -0
  48. package/dist/runtime/AISDKChat.d.ts.map +1 -0
  49. package/dist/runtime/AISDKChat.js +69 -0
  50. package/dist/runtime/AISDKChat.js.map +1 -0
  51. package/dist/runtime/AISDKThreads.d.ts +45 -0
  52. package/dist/runtime/AISDKThreads.d.ts.map +1 -0
  53. package/dist/runtime/AISDKThreads.js +203 -0
  54. package/dist/runtime/AISDKThreads.js.map +1 -0
  55. package/dist/runtime/useAISDKRuntime.d.ts +49 -0
  56. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
  57. package/dist/runtime/useAISDKRuntime.js +266 -0
  58. package/dist/runtime/useAISDKRuntime.js.map +1 -0
  59. package/dist/runtime/useChatRuntime.d.ts +13 -0
  60. package/dist/runtime/useChatRuntime.d.ts.map +1 -0
  61. package/dist/runtime/useChatRuntime.js +52 -0
  62. package/dist/runtime/useChatRuntime.js.map +1 -0
  63. package/dist/runtime/useChatThread.d.ts +77 -0
  64. package/dist/runtime/useChatThread.d.ts.map +1 -0
  65. package/dist/runtime/useChatThread.js +134 -0
  66. package/dist/runtime/useChatThread.js.map +1 -0
  67. package/dist/runtime/useExternalHistory.d.ts +11 -0
  68. package/dist/runtime/useExternalHistory.d.ts.map +1 -0
  69. package/dist/runtime/useExternalHistory.js +278 -0
  70. package/dist/runtime/useExternalHistory.js.map +1 -0
  71. package/dist/runtime/useResourceCleanup.d.ts +5 -0
  72. package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
  73. package/dist/runtime/useResourceCleanup.js +47 -0
  74. package/dist/runtime/useResourceCleanup.js.map +1 -0
  75. package/dist/runtime/useStreamingTiming.d.ts +15 -0
  76. package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
  77. package/dist/runtime/useStreamingTiming.js +38 -0
  78. package/dist/runtime/useStreamingTiming.js.map +1 -0
  79. package/dist/tools/frontendTools.d.ts +33 -0
  80. package/dist/tools/frontendTools.d.ts.map +1 -0
  81. package/dist/tools/frontendTools.js +33 -0
  82. package/dist/tools/frontendTools.js.map +1 -0
  83. package/dist/tools/generativeTools.d.ts +73 -0
  84. package/dist/tools/generativeTools.d.ts.map +1 -0
  85. package/dist/tools/generativeTools.js +245 -0
  86. package/dist/tools/generativeTools.js.map +1 -0
  87. package/dist/tools/mcp-stdio.node.d.ts +2 -0
  88. package/dist/tools/mcp-stdio.node.js +2 -0
  89. package/dist/tools/mcp-stdio.unsupported.d.ts +6 -0
  90. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
  91. package/dist/tools/mcp-stdio.unsupported.js +11 -0
  92. package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
  93. package/dist/transport/AssistantChatTransport.d.ts +23 -0
  94. package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
  95. package/dist/transport/AssistantChatTransport.js +134 -0
  96. package/dist/transport/AssistantChatTransport.js.map +1 -0
  97. package/dist/transport/resumable.d.ts +38 -0
  98. package/dist/transport/resumable.d.ts.map +1 -0
  99. package/dist/transport/resumable.js +113 -0
  100. package/dist/transport/resumable.js.map +1 -0
  101. package/dist/usage.d.ts +19 -0
  102. package/dist/usage.d.ts.map +1 -0
  103. package/dist/usage.js +127 -0
  104. package/dist/usage.js.map +1 -0
  105. package/dist/utils/getVercelAIMessages.d.ts +7 -0
  106. package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
  107. package/dist/utils/getVercelAIMessages.js +9 -0
  108. package/dist/utils/getVercelAIMessages.js.map +1 -0
  109. package/dist/utils/sliceMessagesUntil.d.ts +6 -0
  110. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
  111. package/dist/utils/sliceMessagesUntil.js +12 -0
  112. package/dist/utils/sliceMessagesUntil.js.map +1 -0
  113. package/package.json +100 -0
  114. package/src/__tests__/standalone.e2e.ts +59 -0
  115. package/src/adapters/aiSDKFormatAdapter.ts +41 -0
  116. package/src/adapters/vercelAttachmentAdapter.test.ts +29 -0
  117. package/src/adapters/vercelAttachmentAdapter.ts +36 -0
  118. package/src/aiSDKExtras.ts +11 -0
  119. package/src/converters/convertMessage.test.ts +1130 -0
  120. package/src/converters/convertMessage.ts +457 -0
  121. package/src/converters/modelContentEnvelope.ts +41 -0
  122. package/src/converters/toCreateMessage.test.ts +596 -0
  123. package/src/converters/toCreateMessage.ts +99 -0
  124. package/src/converters/toolOutputConversion.ts +26 -0
  125. package/src/hooks.ts +23 -0
  126. package/src/index.native.ts +21 -0
  127. package/src/index.ts +29 -0
  128. package/src/model-context/injectInteractableContext.test.ts +167 -0
  129. package/src/model-context/injectInteractableContext.ts +61 -0
  130. package/src/model-context/injectQuoteContext.test.ts +33 -0
  131. package/src/model-context/injectQuoteContext.ts +62 -0
  132. package/src/runtime/AISDKChat.integration.test.tsx +47 -0
  133. package/src/runtime/AISDKChat.react-compiler.test.ts +49 -0
  134. package/src/runtime/AISDKChat.test.ts +193 -0
  135. package/src/runtime/AISDKChat.ts +47 -0
  136. package/src/runtime/AISDKThreads.cloud.test.ts +136 -0
  137. package/src/runtime/AISDKThreads.test.ts +548 -0
  138. package/src/runtime/AISDKThreads.ts +223 -0
  139. package/src/runtime/__tests__/controlled-transport.ts +43 -0
  140. package/src/runtime/useAISDKRuntime.approval.test.tsx +69 -0
  141. package/src/runtime/useAISDKRuntime.test.ts +1021 -0
  142. package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
  143. package/src/runtime/useAISDKRuntime.ts +524 -0
  144. package/src/runtime/useChatOnTap.test.ts +58 -0
  145. package/src/runtime/useChatRuntime.integration.test.tsx +67 -0
  146. package/src/runtime/useChatRuntime.test.ts +522 -0
  147. package/src/runtime/useChatRuntime.ts +49 -0
  148. package/src/runtime/useChatThread.ts +286 -0
  149. package/src/runtime/useExternalHistory.test.ts +1289 -0
  150. package/src/runtime/useExternalHistory.ts +435 -0
  151. package/src/runtime/useResourceCleanup.ts +32 -0
  152. package/src/runtime/useStreamingTiming.ts +68 -0
  153. package/src/tools/frontendTools.test.ts +205 -0
  154. package/src/tools/frontendTools.ts +83 -0
  155. package/src/tools/generativeTools.test.ts +785 -0
  156. package/src/tools/generativeTools.ts +470 -0
  157. package/src/tools/mcp-stdio.node.ts +1 -0
  158. package/src/tools/mcp-stdio.unsupported.ts +12 -0
  159. package/src/transport/AssistantChatTransport.test.ts +300 -0
  160. package/src/transport/AssistantChatTransport.ts +204 -0
  161. package/src/transport/resumable.test.ts +262 -0
  162. package/src/transport/resumable.ts +161 -0
  163. package/src/usage.test.ts +186 -0
  164. package/src/usage.ts +174 -0
  165. package/src/utils/getVercelAIMessages.ts +11 -0
  166. package/src/utils/sliceMessagesUntil.test.ts +55 -0
  167. package/src/utils/sliceMessagesUntil.ts +20 -0
@@ -0,0 +1,113 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderHook } from "@testing-library/react";
4
+ import type { ExternalStoreAdapter } from "@assistant-ui/core";
5
+ import { describe, expect, it, vi } from "vitest";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ adapter: undefined as ExternalStoreAdapter | undefined,
9
+ }));
10
+
11
+ vi.mock("@assistant-ui/core/react", async (importOriginal) => {
12
+ const original =
13
+ await importOriginal<typeof import("@assistant-ui/core/react")>();
14
+ return {
15
+ ...original,
16
+ useExternalStoreRuntime: vi.fn((adapter: ExternalStoreAdapter) => {
17
+ mocks.adapter = adapter;
18
+ return {};
19
+ }),
20
+ useRuntimeAdapters: vi.fn(() => ({})),
21
+ };
22
+ });
23
+
24
+ vi.mock("./useExternalHistory", async (importOriginal) => {
25
+ const original =
26
+ await importOriginal<typeof import("./useExternalHistory")>();
27
+ return {
28
+ ...original,
29
+ useExternalHistory: vi.fn(() => ({
30
+ isLoading: false,
31
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
32
+ })),
33
+ };
34
+ });
35
+
36
+ import { useAISDKRuntime } from "./useAISDKRuntime";
37
+
38
+ const createChatHelpers = (addToolOutput: ReturnType<typeof vi.fn>) => ({
39
+ id: "chat-1",
40
+ status: "ready",
41
+ error: undefined,
42
+ messages: [],
43
+ setMessages: vi.fn(),
44
+ sendMessage: vi.fn(),
45
+ regenerate: vi.fn(),
46
+ addToolOutput,
47
+ addToolApprovalResponse: vi.fn(),
48
+ stop: vi.fn(),
49
+ });
50
+
51
+ const callOnAddToolResult = (
52
+ options: Parameters<NonNullable<ExternalStoreAdapter["onAddToolResult"]>>[0],
53
+ ) => {
54
+ const callback = mocks.adapter?.onAddToolResult;
55
+ expect(callback).toBeDefined();
56
+ return callback!(options);
57
+ };
58
+
59
+ describe("useAISDKRuntime tool output callbacks", () => {
60
+ it("returns the addToolOutput promise for successful results", () => {
61
+ const outputPromise = Promise.resolve();
62
+ const addToolOutput = vi.fn(() => outputPromise);
63
+
64
+ renderHook(() =>
65
+ useAISDKRuntime(createChatHelpers(addToolOutput) as never),
66
+ );
67
+
68
+ const callbackPromise = callOnAddToolResult({
69
+ messageId: "message-1",
70
+ toolCallId: "tool-call-1",
71
+ toolName: "weather",
72
+ result: { temperature: 72 },
73
+ isError: false,
74
+ });
75
+
76
+ expect(callbackPromise).toBe(outputPromise);
77
+ expect(addToolOutput).toHaveBeenCalledWith({
78
+ tool: "weather",
79
+ toolCallId: "tool-call-1",
80
+ output: { temperature: 72 },
81
+ options: { metadata: undefined },
82
+ });
83
+ });
84
+
85
+ it("returns the addToolOutput promise for error results", async () => {
86
+ const error = new Error("tool failed");
87
+ const outputPromise = Promise.reject(error);
88
+ void outputPromise.catch(() => undefined);
89
+ const addToolOutput = vi.fn(() => outputPromise);
90
+
91
+ renderHook(() =>
92
+ useAISDKRuntime(createChatHelpers(addToolOutput) as never),
93
+ );
94
+
95
+ const callbackPromise = callOnAddToolResult({
96
+ messageId: "message-1",
97
+ toolCallId: "tool-call-1",
98
+ toolName: "weather",
99
+ result: "tool failed",
100
+ isError: true,
101
+ });
102
+
103
+ expect(callbackPromise).toBe(outputPromise);
104
+ expect(addToolOutput).toHaveBeenCalledWith({
105
+ state: "output-error",
106
+ tool: "weather",
107
+ toolCallId: "tool-call-1",
108
+ errorText: "tool failed",
109
+ options: { metadata: undefined },
110
+ });
111
+ await expect(outputPromise).rejects.toBe(error);
112
+ });
113
+ });
@@ -0,0 +1,524 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import type {
5
+ UIMessage,
6
+ useChat,
7
+ CreateUIMessage,
8
+ UseChatHelpers,
9
+ } from "@ai-sdk/react";
10
+ import { isToolUIPart, generateId } from "ai";
11
+ import {
12
+ useExternalStoreRuntime,
13
+ useRuntimeAdapters,
14
+ type JoinStrategy,
15
+ } from "@assistant-ui/core/react";
16
+ import type {
17
+ SuggestionAdapter,
18
+ ThreadSuggestion,
19
+ ToolExecutionStatus,
20
+ } from "@assistant-ui/core";
21
+ import type {
22
+ ExternalStoreAdapter,
23
+ ExternalStoreSharedOptions,
24
+ ThreadHistoryAdapter,
25
+ AssistantRuntime,
26
+ ThreadMessage,
27
+ MessageFormatAdapter,
28
+ MessageFormatItem,
29
+ MessageFormatRepository,
30
+ AppendMessage,
31
+ RunConfig,
32
+ McpAppMetadata,
33
+ } from "@assistant-ui/core";
34
+ import {
35
+ getExternalStoreMessages,
36
+ pickExternalStoreSharedOptions,
37
+ } from "@assistant-ui/core";
38
+ import { consumeSuggestionResult } from "@assistant-ui/core/internal";
39
+ import type { ReadonlyJSONObject } from "assistant-stream/utils";
40
+ import { sliceMessagesUntil } from "../utils/sliceMessagesUntil";
41
+ import { toCreateMessage } from "../converters/toCreateMessage";
42
+ import { vercelAttachmentAdapter } from "../adapters/vercelAttachmentAdapter";
43
+ import { getVercelAIMessages } from "../utils/getVercelAIMessages";
44
+ import { AISDKMessageConverter } from "../converters/convertMessage";
45
+ import { wrapModelContentEnvelope } from "../converters/modelContentEnvelope";
46
+ import {
47
+ type AISDKStorageFormat,
48
+ aiSDKV6FormatAdapter,
49
+ } from "../adapters/aiSDKFormatAdapter";
50
+ import {
51
+ useExternalHistory,
52
+ toExportedMessageRepository,
53
+ } from "./useExternalHistory";
54
+ import { useStreamingTiming } from "./useStreamingTiming";
55
+ import { aiSDKExtras } from "../aiSDKExtras";
56
+
57
+ export type CustomToCreateMessageFunction = <
58
+ UI_MESSAGE extends UIMessage = UIMessage,
59
+ >(
60
+ message: AppendMessage,
61
+ ) => CreateUIMessage<UI_MESSAGE>;
62
+
63
+ const toUIMessage = <UI_MESSAGE extends UIMessage>(
64
+ createMessage: CreateUIMessage<UI_MESSAGE>,
65
+ fallbackRole: UI_MESSAGE["role"],
66
+ ): UI_MESSAGE =>
67
+ ({
68
+ ...createMessage,
69
+ id: createMessage.id ?? generateId(),
70
+ role: createMessage.role ?? fallbackRole,
71
+ }) as UI_MESSAGE;
72
+
73
+ export type AISDKRuntimeAdapter = ExternalStoreSharedOptions & {
74
+ adapters?:
75
+ | (NonNullable<ExternalStoreAdapter["adapters"]> & {
76
+ history?: ThreadHistoryAdapter | undefined;
77
+ suggestion?: SuggestionAdapter | undefined;
78
+ })
79
+ | undefined;
80
+ toCreateMessage?: CustomToCreateMessageFunction;
81
+ /**
82
+ * Whether to automatically cancel pending interactive tool calls when the user sends a new message.
83
+ *
84
+ * When enabled (default), the pending tool calls will be marked as failed with an error message
85
+ * indicating the user cancelled the tool call by sending a new message.
86
+ *
87
+ * @default true
88
+ */
89
+ cancelPendingToolCallsOnSend?: boolean | undefined;
90
+ /**
91
+ * Called when `runtime.thread.resumeRun(config)` is invoked.
92
+ *
93
+ * When omitted, `resumeRun` throws `"Runtime does not support resuming runs."`.
94
+ * Provide this to bridge resume invocations into a custom replay channel
95
+ * (for example, an SSE reconnect endpoint keyed by turn id).
96
+ */
97
+ onResume?: ExternalStoreAdapter["onResume"];
98
+ /**
99
+ * Called when `runtime.thread.resumeToolCall(options)` is invoked for a tool call the in-process tracker does not own.
100
+ *
101
+ * When omitted, `resumeToolCall` throws `"Tool call ${toolCallId} is not waiting for resume."`.
102
+ * Provide this to bridge resume-tool-call invocations into a custom handler.
103
+ */
104
+ onResumeToolCall?: ExternalStoreAdapter["onResumeToolCall"];
105
+ /**
106
+ * How consecutive assistant messages are rendered.
107
+ *
108
+ * `"concat-content"` (the default) merges them into a single thread message.
109
+ * `"none"` keeps each assistant message as its own thread message, which is
110
+ * useful when a backend persists proactive or consecutive assistant messages
111
+ * as separate entries.
112
+ */
113
+ joinStrategy?: JoinStrategy | undefined;
114
+ };
115
+
116
+ const EMPTY_SUGGESTIONS: readonly ThreadSuggestion[] = [];
117
+
118
+ const useGeneratedSuggestions = (
119
+ suggestionAdapter: SuggestionAdapter | undefined,
120
+ messages: readonly ThreadMessage[],
121
+ isRunning: boolean,
122
+ ): readonly ThreadSuggestion[] => {
123
+ const [suggestions, setSuggestions] =
124
+ useState<readonly ThreadSuggestion[]>(EMPTY_SUGGESTIONS);
125
+ const controllerRef = useRef<AbortController | null>(null);
126
+ const wasRunningRef = useRef(false);
127
+ const messagesRef = useRef(messages);
128
+ messagesRef.current = messages;
129
+ const adapterRef = useRef(suggestionAdapter);
130
+ adapterRef.current = suggestionAdapter;
131
+ const hasAdapter = suggestionAdapter != null;
132
+
133
+ useEffect(() => {
134
+ const clearSuggestions = () => {
135
+ controllerRef.current?.abort();
136
+ controllerRef.current = null;
137
+ setSuggestions((prev) => (prev.length === 0 ? prev : EMPTY_SUGGESTIONS));
138
+ };
139
+
140
+ const adapter = adapterRef.current;
141
+ if (!adapter) {
142
+ clearSuggestions();
143
+ wasRunningRef.current = isRunning;
144
+ return;
145
+ }
146
+
147
+ if (isRunning) {
148
+ if (!wasRunningRef.current) {
149
+ clearSuggestions();
150
+ }
151
+ wasRunningRef.current = true;
152
+ return;
153
+ }
154
+
155
+ if (!wasRunningRef.current) return;
156
+ wasRunningRef.current = false;
157
+
158
+ const currentMessages = messagesRef.current;
159
+ const last = currentMessages.at(-1);
160
+ if (last?.role !== "assistant") return;
161
+ if (last.status?.type === "requires-action") return;
162
+
163
+ const controller = new AbortController();
164
+ controllerRef.current = controller;
165
+ const { signal } = controller;
166
+
167
+ void (async () => {
168
+ try {
169
+ const promiseOrGenerator = adapter.generate({
170
+ messages: currentMessages,
171
+ signal,
172
+ });
173
+
174
+ await consumeSuggestionResult(promiseOrGenerator, {
175
+ signal,
176
+ onUpdate: setSuggestions,
177
+ });
178
+ } catch {}
179
+ })();
180
+ }, [hasAdapter, isRunning]);
181
+
182
+ useEffect(() => {
183
+ return () => {
184
+ controllerRef.current?.abort();
185
+ };
186
+ }, []);
187
+
188
+ return suggestions;
189
+ };
190
+
191
+ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
192
+ chatHelpers: ReturnType<typeof useChat<UI_MESSAGE>>,
193
+ adapter: AISDKRuntimeAdapter = {},
194
+ ) => {
195
+ const {
196
+ adapters,
197
+ toCreateMessage: customToCreateMessage,
198
+ cancelPendingToolCallsOnSend = true,
199
+ onResume,
200
+ onResumeToolCall,
201
+ joinStrategy,
202
+ } = adapter;
203
+ const suggestionAdapter = adapters?.suggestion;
204
+ const contextAdapters = useRuntimeAdapters();
205
+ const [toolStatuses, setToolStatuses] = useState<
206
+ Record<string, ToolExecutionStatus>
207
+ >({});
208
+ const toolArgsKeyOrderCacheRef = useRef<Map<string, Map<string, string[]>>>(
209
+ new Map(),
210
+ );
211
+ const toolLastInputCacheRef = useRef<Map<string, ReadonlyJSONObject>>(
212
+ new Map(),
213
+ );
214
+ const mcpAppMetadataCacheRef = useRef<Map<string, McpAppMetadata>>(new Map());
215
+ const lastRunConfigRef = useRef<RunConfig | undefined>(undefined);
216
+
217
+ const hasExecutingTools = Object.values(toolStatuses).some(
218
+ (s) => s?.type === "executing",
219
+ );
220
+ const isRunning =
221
+ chatHelpers.status === "submitted" ||
222
+ chatHelpers.status === "streaming" ||
223
+ hasExecutingTools;
224
+
225
+ const messageTiming = useStreamingTiming(chatHelpers.messages, isRunning);
226
+
227
+ // Flag the streaming message optimistic: its id can be swapped for a server
228
+ // id mid-run, and the repository then drops the orphaned pre-swap id (#4037).
229
+ const lastMessage = chatHelpers.messages.at(-1);
230
+ const optimisticMessageId =
231
+ isRunning && lastMessage?.role === "assistant" ? lastMessage.id : undefined;
232
+
233
+ const messages = AISDKMessageConverter.useThreadMessages({
234
+ isRunning,
235
+ messages: chatHelpers.messages,
236
+ joinStrategy,
237
+ metadata: useMemo(
238
+ () => ({
239
+ toolStatuses,
240
+ messageTiming,
241
+ toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
242
+ toolLastInputCache: toolLastInputCacheRef.current,
243
+ mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
244
+ ...(optimisticMessageId && { optimisticMessageId }),
245
+ ...(chatHelpers.error && { error: chatHelpers.error.message }),
246
+ }),
247
+ [toolStatuses, messageTiming, optimisticMessageId, chatHelpers.error],
248
+ ),
249
+ });
250
+
251
+ const generatedSuggestions = useGeneratedSuggestions(
252
+ suggestionAdapter,
253
+ messages,
254
+ isRunning,
255
+ );
256
+
257
+ const [runtimeRef] = useState(() => ({
258
+ get current(): AssistantRuntime {
259
+ return runtime;
260
+ },
261
+ }));
262
+
263
+ const { isLoading, deleteMessage: deleteHistoryMessage } = useExternalHistory(
264
+ runtimeRef,
265
+ adapters?.history ?? contextAdapters?.history,
266
+ AISDKMessageConverter.toThreadMessages as (
267
+ messages: UI_MESSAGE[],
268
+ ) => ThreadMessage[],
269
+ aiSDKV6FormatAdapter as MessageFormatAdapter<
270
+ UI_MESSAGE,
271
+ AISDKStorageFormat
272
+ >,
273
+ (messages) => {
274
+ chatHelpers.setMessages(messages);
275
+ },
276
+ );
277
+
278
+ const {
279
+ id: chatId,
280
+ messages: chatMessages,
281
+ status: chatStatus,
282
+ error,
283
+ } = chatHelpers;
284
+ const extras = useMemo(
285
+ () =>
286
+ aiSDKExtras.provide({
287
+ chat: chatHelpers as unknown as UseChatHelpers<UIMessage>,
288
+ error,
289
+ }),
290
+ // oxlint-disable-next-line react/exhaustive-deps -- keyed on the chat's identity and reactive snapshots; useChat re-mints the helpers object every render while its remaining fields are instance-bound methods, and a render-stable extras identity is what lets the external-store core dedupe adapter updates
291
+ [chatId, chatMessages, chatStatus, error],
292
+ );
293
+
294
+ const completePendingToolCalls = async () => {
295
+ if (!cancelPendingToolCallsOnSend) return;
296
+
297
+ // The runtime auto-aborts in-flight tool invocations when a new run
298
+ // is dispatched (append() / startRun()). All we need to do here is
299
+ // mark any tool without a result as cancelled in the UI message list.
300
+
301
+ // Mark any tool without a result as cancelled (uses setMessages to avoid triggering sendAutomaticallyWhen)
302
+ chatHelpers.setMessages((messages) => {
303
+ const lastMessage = messages.at(-1);
304
+ if (lastMessage?.role !== "assistant") return messages;
305
+
306
+ let hasChanges = false;
307
+ const parts = lastMessage.parts?.map((part) => {
308
+ if (!isToolUIPart(part)) return part;
309
+ if (part.state === "output-available" || part.state === "output-error")
310
+ return part;
311
+
312
+ hasChanges = true;
313
+ const { approval: _approval, ...rest } = part;
314
+ return {
315
+ ...rest,
316
+ state: "output-error" as const,
317
+ errorText: "User cancelled tool call by sending a new message.",
318
+ };
319
+ });
320
+
321
+ if (!hasChanges) return messages;
322
+ return [...messages.slice(0, -1), { ...lastMessage, parts }];
323
+ });
324
+ };
325
+
326
+ const runtime = useExternalStoreRuntime({
327
+ isRunning,
328
+ messages,
329
+ unstable_enableToolInvocations: true,
330
+ setToolStatuses,
331
+ setMessages: (messages) =>
332
+ chatHelpers.setMessages(
333
+ messages
334
+ .map(getVercelAIMessages<UI_MESSAGE>)
335
+ .filter(Boolean)
336
+ .flat(),
337
+ ),
338
+ onImport: (messages) =>
339
+ chatHelpers.setMessages(
340
+ messages
341
+ .map(getVercelAIMessages<UI_MESSAGE>)
342
+ .filter(Boolean)
343
+ .flat(),
344
+ ),
345
+ onExportExternalState: (): MessageFormatRepository<UI_MESSAGE> => {
346
+ const exported = runtimeRef.current.thread.export();
347
+
348
+ const expandedMessages: MessageFormatItem<UI_MESSAGE>[] = [];
349
+ const lastInnerIdMap = new Map<string, string>();
350
+
351
+ for (const item of exported.messages) {
352
+ const innerMessages = getExternalStoreMessages<UI_MESSAGE>(
353
+ item.message,
354
+ );
355
+ let parentId =
356
+ item.parentId != null
357
+ ? (lastInnerIdMap.get(item.parentId) ?? item.parentId)
358
+ : null;
359
+ for (const innerMessage of innerMessages) {
360
+ expandedMessages.push({ parentId, message: innerMessage });
361
+ parentId = aiSDKV6FormatAdapter.getId(innerMessage as UIMessage);
362
+ }
363
+ if (innerMessages.length > 0) {
364
+ lastInnerIdMap.set(
365
+ item.message.id,
366
+ aiSDKV6FormatAdapter.getId(
367
+ innerMessages[innerMessages.length - 1]! as UIMessage,
368
+ ),
369
+ );
370
+ }
371
+ }
372
+
373
+ const result: MessageFormatRepository<UI_MESSAGE> = {
374
+ messages: expandedMessages,
375
+ };
376
+
377
+ if (exported.headId != null) {
378
+ result.headId = lastInnerIdMap.get(exported.headId) ?? exported.headId;
379
+ }
380
+
381
+ return result;
382
+ },
383
+ onLoadExternalState: (repo: MessageFormatRepository<UI_MESSAGE>) => {
384
+ // Convert MessageFormatRepository to ExportedMessageRepository
385
+ const exportedRepo = toExportedMessageRepository(
386
+ AISDKMessageConverter.toThreadMessages,
387
+ repo,
388
+ );
389
+
390
+ // Import into the thread's MessageRepository
391
+ runtimeRef.current.thread.import(exportedRepo);
392
+ },
393
+ onCancel: async () => {
394
+ try {
395
+ await chatHelpers.stop();
396
+ } catch (error) {
397
+ if (!(error instanceof Error && error.name === "AbortError")) {
398
+ throw error;
399
+ }
400
+ }
401
+ },
402
+ onNew: async (message) => {
403
+ const createMessage = (
404
+ customToCreateMessage ?? toCreateMessage
405
+ )<UI_MESSAGE>(message);
406
+
407
+ if (!(message.startRun ?? message.role === "user")) {
408
+ chatHelpers.setMessages((current) => [
409
+ ...current,
410
+ toUIMessage<UI_MESSAGE>(createMessage, message.role),
411
+ ]);
412
+ return;
413
+ }
414
+
415
+ lastRunConfigRef.current = message.runConfig;
416
+ await completePendingToolCalls();
417
+ await chatHelpers.sendMessage(createMessage, {
418
+ metadata: message.runConfig,
419
+ });
420
+ },
421
+ onEdit: async (message) => {
422
+ const createMessage = (
423
+ customToCreateMessage ?? toCreateMessage
424
+ )<UI_MESSAGE>(message);
425
+
426
+ if (!(message.startRun ?? message.role === "user")) {
427
+ chatHelpers.setMessages((current) => [
428
+ ...sliceMessagesUntil(current, message.parentId),
429
+ toUIMessage<UI_MESSAGE>(createMessage, message.role),
430
+ ]);
431
+ return;
432
+ }
433
+
434
+ lastRunConfigRef.current = message.runConfig;
435
+ chatHelpers.setMessages((current) =>
436
+ sliceMessagesUntil(current, message.parentId),
437
+ );
438
+ await chatHelpers.sendMessage(createMessage, {
439
+ metadata: message.runConfig,
440
+ });
441
+ },
442
+ onDelete: async (messageId) => {
443
+ const threadMessages = runtimeRef.current.thread.getState().messages;
444
+ const messageIndex = threadMessages.findIndex(
445
+ (message) => message.id === messageId,
446
+ );
447
+ if (messageIndex === -1) return;
448
+
449
+ await deleteHistoryMessage(messageId);
450
+
451
+ const deleteIds = new Set(
452
+ getExternalStoreMessages<UI_MESSAGE>(threadMessages[messageIndex]!).map(
453
+ (message) => message.id,
454
+ ),
455
+ );
456
+ chatHelpers.setMessages((current) =>
457
+ current.filter((message) => !deleteIds.has(message.id)),
458
+ );
459
+ },
460
+ onReload: async (parentId: string | null, config) => {
461
+ lastRunConfigRef.current = config.runConfig;
462
+ const newMessages = sliceMessagesUntil(chatHelpers.messages, parentId);
463
+ chatHelpers.setMessages(newMessages);
464
+
465
+ await chatHelpers.regenerate({ metadata: config.runConfig });
466
+ },
467
+ onAddToolResult: ({
468
+ toolCallId,
469
+ toolName,
470
+ result,
471
+ isError,
472
+ modelContent,
473
+ }) => {
474
+ const options = { metadata: lastRunConfigRef.current };
475
+ if (isError) {
476
+ return Promise.resolve(
477
+ chatHelpers.addToolOutput({
478
+ state: "output-error",
479
+ tool: toolName ?? toolCallId,
480
+ toolCallId,
481
+ errorText:
482
+ typeof result === "string" ? result : JSON.stringify(result),
483
+ options,
484
+ }),
485
+ );
486
+ } else {
487
+ const output =
488
+ modelContent !== undefined
489
+ ? wrapModelContentEnvelope(result, modelContent)
490
+ : result;
491
+ return Promise.resolve(
492
+ chatHelpers.addToolOutput({
493
+ tool: toolName,
494
+ toolCallId,
495
+ output,
496
+ options,
497
+ }),
498
+ );
499
+ }
500
+ },
501
+ onRespondToToolApproval: ({ approvalId, approved, reason }) =>
502
+ Promise.resolve(
503
+ chatHelpers.addToolApprovalResponse({
504
+ id: approvalId,
505
+ approved,
506
+ ...(reason != null && { reason }),
507
+ options: { metadata: lastRunConfigRef.current },
508
+ }),
509
+ ),
510
+ ...pickExternalStoreSharedOptions(adapter),
511
+ ...(suggestionAdapter ? { suggestions: generatedSuggestions } : {}),
512
+ ...(onResume && { onResume }),
513
+ ...(onResumeToolCall && { onResumeToolCall }),
514
+ adapters: {
515
+ attachments: vercelAttachmentAdapter,
516
+ ...contextAdapters,
517
+ ...adapters,
518
+ },
519
+ extras,
520
+ isLoading,
521
+ });
522
+
523
+ return runtime;
524
+ };
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createTapRoot } from "@assistant-ui/tap";
3
+ import { useChat } from "@ai-sdk/react";
4
+ import { createControlledTransport } from "./__tests__/controlled-transport";
5
+
6
+ describe("useChat hosted in a tap root", () => {
7
+ it("streams a full round trip without React", async () => {
8
+ const { transport, emit, close } = createControlledTransport();
9
+ const root = createTapRoot(function UseChatOnTap() {
10
+ return useChat({ transport });
11
+ });
12
+
13
+ try {
14
+ expect(root.getValue().status).toBe("ready");
15
+ expect(root.getValue().messages).toHaveLength(0);
16
+
17
+ const sendPromise = root.getValue().sendMessage({ text: "hi" });
18
+ await vi.waitFor(() => {
19
+ expect(root.getValue().status).toBe("submitted");
20
+ expect(root.getValue().messages).toHaveLength(1);
21
+ });
22
+
23
+ emit(
24
+ { type: "start" },
25
+ { type: "text-start", id: "t1" },
26
+ { type: "text-delta", id: "t1", delta: "hello " },
27
+ );
28
+ await vi.waitFor(() => {
29
+ expect(root.getValue().status).toBe("streaming");
30
+ const parts = root.getValue().messages.at(-1)?.parts;
31
+ expect(parts).toContainEqual(
32
+ expect.objectContaining({ type: "text", text: "hello " }),
33
+ );
34
+ });
35
+
36
+ emit(
37
+ { type: "text-delta", id: "t1", delta: "world" },
38
+ { type: "text-end", id: "t1" },
39
+ { type: "finish" },
40
+ );
41
+ close();
42
+ await sendPromise;
43
+
44
+ await vi.waitFor(() => {
45
+ expect(root.getValue().status).toBe("ready");
46
+ const { messages } = root.getValue();
47
+ expect(messages).toHaveLength(2);
48
+ expect(messages[0]!.role).toBe("user");
49
+ expect(messages[1]!.role).toBe("assistant");
50
+ expect(messages[1]!.parts).toContainEqual(
51
+ expect.objectContaining({ type: "text", text: "hello world" }),
52
+ );
53
+ });
54
+ } finally {
55
+ root.unmount();
56
+ }
57
+ });
58
+ });