@ulearning/ai-sdk-adapter 0.0.0-stage → 1.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -2
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +6 -0
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/aiSDKFormatAdapter.js +17 -0
  6. package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
  7. package/dist/adapters/vercelAttachmentAdapter.d.ts +3 -0
  8. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
  9. package/dist/adapters/vercelAttachmentAdapter.js +36 -0
  10. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
  11. package/dist/aiSDKExtras.d.ts +13 -0
  12. package/dist/aiSDKExtras.d.ts.map +1 -0
  13. package/dist/aiSDKExtras.js +6 -0
  14. package/dist/aiSDKExtras.js.map +1 -0
  15. package/dist/converters/convertMessage.d.ts +44 -0
  16. package/dist/converters/convertMessage.d.ts.map +1 -0
  17. package/dist/converters/convertMessage.js +465 -0
  18. package/dist/converters/convertMessage.js.map +1 -0
  19. package/dist/converters/toCreateMessage.d.ts +4 -0
  20. package/dist/converters/toCreateMessage.d.ts.map +1 -0
  21. package/dist/converters/toCreateMessage.js +65 -0
  22. package/dist/converters/toCreateMessage.js.map +1 -0
  23. package/dist/converters/toolApprovalAnswers.d.ts +3 -0
  24. package/dist/converters/toolApprovalAnswers.d.ts.map +1 -0
  25. package/dist/converters/toolApprovalAnswers.js +16 -0
  26. package/dist/converters/toolApprovalAnswers.js.map +1 -0
  27. package/dist/hooks.d.ts +17 -0
  28. package/dist/hooks.d.ts.map +1 -0
  29. package/dist/hooks.js +19 -0
  30. package/dist/hooks.js.map +1 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +13 -0
  34. package/dist/model-context/injectInteractableContext.d.ts +37 -0
  35. package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
  36. package/dist/model-context/injectInteractableContext.js +54 -0
  37. package/dist/model-context/injectInteractableContext.js.map +1 -0
  38. package/dist/model-context/injectQuoteContext.d.ts +25 -0
  39. package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
  40. package/dist/model-context/injectQuoteContext.js +51 -0
  41. package/dist/model-context/injectQuoteContext.js.map +1 -0
  42. package/dist/runtime/AISDKChat.d.ts +13 -0
  43. package/dist/runtime/AISDKChat.d.ts.map +1 -0
  44. package/dist/runtime/AISDKChat.js +34 -0
  45. package/dist/runtime/AISDKChat.js.map +1 -0
  46. package/dist/runtime/AISDKThreads.d.ts +24 -0
  47. package/dist/runtime/AISDKThreads.d.ts.map +1 -0
  48. package/dist/runtime/AISDKThreads.js +92 -0
  49. package/dist/runtime/AISDKThreads.js.map +1 -0
  50. package/dist/runtime/DynamicChatTransport.d.ts +50 -0
  51. package/dist/runtime/DynamicChatTransport.d.ts.map +1 -0
  52. package/dist/runtime/DynamicChatTransport.js +153 -0
  53. package/dist/runtime/DynamicChatTransport.js.map +1 -0
  54. package/dist/runtime/getResumableAdapter.d.ts +5 -0
  55. package/dist/runtime/getResumableAdapter.d.ts.map +1 -0
  56. package/dist/runtime/getResumableAdapter.js +11 -0
  57. package/dist/runtime/getResumableAdapter.js.map +1 -0
  58. package/dist/runtime/toolHistoryCodec.d.ts +20 -0
  59. package/dist/runtime/toolHistoryCodec.d.ts.map +1 -0
  60. package/dist/runtime/toolHistoryCodec.js +122 -0
  61. package/dist/runtime/toolHistoryCodec.js.map +1 -0
  62. package/dist/runtime/useAISDKRuntime.d.ts +95 -0
  63. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
  64. package/dist/runtime/useAISDKRuntime.js +754 -0
  65. package/dist/runtime/useAISDKRuntime.js.map +1 -0
  66. package/dist/runtime/useChatRuntime.d.ts +17 -0
  67. package/dist/runtime/useChatRuntime.d.ts.map +1 -0
  68. package/dist/runtime/useChatRuntime.js +50 -0
  69. package/dist/runtime/useChatRuntime.js.map +1 -0
  70. package/dist/runtime/useChatSnapshot.d.ts +6 -0
  71. package/dist/runtime/useChatSnapshot.d.ts.map +1 -0
  72. package/dist/runtime/useChatSnapshot.js +62 -0
  73. package/dist/runtime/useChatSnapshot.js.map +1 -0
  74. package/dist/runtime/useChatThread.d.ts +121 -0
  75. package/dist/runtime/useChatThread.d.ts.map +1 -0
  76. package/dist/runtime/useChatThread.js +255 -0
  77. package/dist/runtime/useChatThread.js.map +1 -0
  78. package/dist/runtime/useDynamicChatTransport.d.ts +4 -0
  79. package/dist/runtime/useDynamicChatTransport.d.ts.map +1 -0
  80. package/dist/runtime/useDynamicChatTransport.js +26 -0
  81. package/dist/runtime/useDynamicChatTransport.js.map +1 -0
  82. package/dist/runtime/useExternalHistory.d.ts +12 -0
  83. package/dist/runtime/useExternalHistory.d.ts.map +1 -0
  84. package/dist/runtime/useExternalHistory.js +556 -0
  85. package/dist/runtime/useExternalHistory.js.map +1 -0
  86. package/dist/runtime/useMessageQueue.d.ts +14 -0
  87. package/dist/runtime/useMessageQueue.d.ts.map +1 -0
  88. package/dist/runtime/useMessageQueue.js +142 -0
  89. package/dist/runtime/useMessageQueue.js.map +1 -0
  90. package/dist/runtime/useResourceCleanup.d.ts +2 -0
  91. package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
  92. package/dist/runtime/useResourceCleanup.js +47 -0
  93. package/dist/runtime/useResourceCleanup.js.map +1 -0
  94. package/dist/runtime/useStreamingTiming.d.ts +12 -0
  95. package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
  96. package/dist/runtime/useStreamingTiming.js +36 -0
  97. package/dist/runtime/useStreamingTiming.js.map +1 -0
  98. package/dist/tools/generativeTools.d.ts +71 -0
  99. package/dist/tools/generativeTools.d.ts.map +1 -0
  100. package/dist/tools/generativeTools.js +258 -0
  101. package/dist/tools/generativeTools.js.map +1 -0
  102. package/dist/tools/mcp-stdio.node.d.ts +2 -0
  103. package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
  104. package/dist/tools/mcp-stdio.node.js +1 -0
  105. package/dist/tools/mcp-stdio.unsupported.d.ts +3 -0
  106. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
  107. package/dist/tools/mcp-stdio.unsupported.js +10 -0
  108. package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
  109. package/dist/transport/AssistantChatTransport.d.ts +20 -0
  110. package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
  111. package/dist/transport/AssistantChatTransport.js +151 -0
  112. package/dist/transport/AssistantChatTransport.js.map +1 -0
  113. package/dist/transport/resumable.d.ts +36 -0
  114. package/dist/transport/resumable.d.ts.map +1 -0
  115. package/dist/transport/resumable.js +112 -0
  116. package/dist/transport/resumable.js.map +1 -0
  117. package/dist/usage.d.ts +24 -0
  118. package/dist/usage.d.ts.map +1 -0
  119. package/dist/usage.js +121 -0
  120. package/dist/usage.js.map +1 -0
  121. package/dist/utils/getVercelAIMessages.d.ts +4 -0
  122. package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
  123. package/dist/utils/getVercelAIMessages.js +8 -0
  124. package/dist/utils/getVercelAIMessages.js.map +1 -0
  125. package/dist/utils/sliceMessagesUntil.d.ts +3 -0
  126. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
  127. package/dist/utils/sliceMessagesUntil.js +10 -0
  128. package/dist/utils/sliceMessagesUntil.js.map +1 -0
  129. package/package.json +96 -3
  130. package/src/__tests__/standalone.e2e.ts +118 -0
  131. package/src/adapters/aiSDKFormatAdapter.test.ts +36 -0
  132. package/src/adapters/aiSDKFormatAdapter.ts +18 -0
  133. package/src/adapters/vercelAttachmentAdapter.test.ts +52 -0
  134. package/src/adapters/vercelAttachmentAdapter.ts +36 -0
  135. package/src/aiSDKExtras.ts +31 -0
  136. package/src/converters/convertMessage.metadata-key.test.tsx +182 -0
  137. package/src/converters/convertMessage.test.ts +2428 -0
  138. package/src/converters/convertMessage.tool-args-status.test.tsx +185 -0
  139. package/src/converters/convertMessage.ts +919 -0
  140. package/src/converters/toCreateMessage.test.ts +716 -0
  141. package/src/converters/toCreateMessage.ts +110 -0
  142. package/src/converters/toolApprovalAnswers.ts +27 -0
  143. package/src/hooks.test.ts +168 -0
  144. package/src/hooks.ts +26 -0
  145. package/src/index.ts +29 -0
  146. package/src/model-context/injectInteractableContext.test.ts +167 -0
  147. package/src/model-context/injectInteractableContext.ts +64 -0
  148. package/src/model-context/injectQuoteContext.test.ts +33 -0
  149. package/src/model-context/injectQuoteContext.ts +63 -0
  150. package/src/runtime/AISDKChat.artifacts.test.ts +48 -0
  151. package/src/runtime/AISDKChat.integration.test.tsx +148 -0
  152. package/src/runtime/AISDKChat.test.ts +241 -0
  153. package/src/runtime/AISDKChat.ts +43 -0
  154. package/src/runtime/AISDKThreads.test.ts +811 -0
  155. package/src/runtime/AISDKThreads.ts +194 -0
  156. package/src/runtime/DynamicChatTransport.test.ts +228 -0
  157. package/src/runtime/DynamicChatTransport.ts +281 -0
  158. package/src/runtime/__tests__/controlled-transport.ts +70 -0
  159. package/src/runtime/__tests__/tap.ts +80 -0
  160. package/src/runtime/__tests__/vue.ts +46 -0
  161. package/src/runtime/getResumableAdapter.ts +16 -0
  162. package/src/runtime/toolHistoryCodec.test.ts +187 -0
  163. package/src/runtime/toolHistoryCodec.ts +245 -0
  164. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1956 -0
  165. package/src/runtime/useAISDKRuntime.approval.test.tsx +580 -0
  166. package/src/runtime/useAISDKRuntime.denied-tool.test.tsx +122 -0
  167. package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +302 -0
  168. package/src/runtime/useAISDKRuntime.metadata-cache.test.tsx +157 -0
  169. package/src/runtime/useAISDKRuntime.queue.test.tsx +707 -0
  170. package/src/runtime/useAISDKRuntime.reload.test.tsx +217 -0
  171. package/src/runtime/useAISDKRuntime.test.ts +2345 -0
  172. package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
  173. package/src/runtime/useAISDKRuntime.ts +1503 -0
  174. package/src/runtime/useAISDKRuntime.voice.test.tsx +319 -0
  175. package/src/runtime/useChatOnTap.test.ts +60 -0
  176. package/src/runtime/useChatRuntime.fast-refresh.test.tsx +83 -0
  177. package/src/runtime/useChatRuntime.integration.test.tsx +661 -0
  178. package/src/runtime/useChatRuntime.local-storage.test.tsx +114 -0
  179. package/src/runtime/useChatRuntime.native.test.ts +147 -0
  180. package/src/runtime/useChatRuntime.run-telemetry.test.tsx +226 -0
  181. package/src/runtime/useChatRuntime.test.ts +619 -0
  182. package/src/runtime/useChatRuntime.ts +80 -0
  183. package/src/runtime/useChatSnapshot.test.ts +60 -0
  184. package/src/runtime/useChatSnapshot.ts +86 -0
  185. package/src/runtime/useChatThread.binding.test.tsx +140 -0
  186. package/src/runtime/useChatThread.test.ts +993 -0
  187. package/src/runtime/useChatThread.transport.test.tsx +149 -0
  188. package/src/runtime/useChatThread.ts +533 -0
  189. package/src/runtime/useDynamicChatTransport.ts +26 -0
  190. package/src/runtime/useExternalHistory.test.ts +4187 -0
  191. package/src/runtime/useExternalHistory.ts +902 -0
  192. package/src/runtime/useMessageQueue.effects.test.tsx +88 -0
  193. package/src/runtime/useMessageQueue.test.tsx +256 -0
  194. package/src/runtime/useMessageQueue.ts +189 -0
  195. package/src/runtime/useResourceCleanup.ts +58 -0
  196. package/src/runtime/useStreamingTiming.test.tsx +96 -0
  197. package/src/runtime/useStreamingTiming.ts +69 -0
  198. package/src/tools/generativeTools.test.ts +1058 -0
  199. package/src/tools/generativeTools.ts +491 -0
  200. package/src/tools/mcp-stdio.node.ts +1 -0
  201. package/src/tools/mcp-stdio.unsupported.ts +12 -0
  202. package/src/transport/AssistantChatTransport.test.ts +631 -0
  203. package/src/transport/AssistantChatTransport.ts +236 -0
  204. package/src/transport/resumable.test.ts +262 -0
  205. package/src/transport/resumable.ts +162 -0
  206. package/src/usage.test.ts +204 -0
  207. package/src/usage.ts +179 -0
  208. package/src/utils/getVercelAIMessages.ts +11 -0
  209. package/src/utils/sliceMessagesUntil.test.ts +51 -0
  210. package/src/utils/sliceMessagesUntil.ts +16 -0
@@ -0,0 +1,2345 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "./__tests__/tap";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { validateUIMessages } from "ai";
6
+ import { ToolResponse } from "@ulearning/ai-stream";
7
+ import type { UIMessage } from "ai";
8
+ import {
9
+ createSuggestionAdapter,
10
+ type MessageFormatRepository,
11
+ } from "@ulearning/ai-core";
12
+
13
+ // Mock only the sibling module that requires AUI store context (not available
14
+ // in isolation). Every other dependency — useExternalStoreRuntime,
15
+ // useToolInvocations, the message converter — runs for real.
16
+ vi.mock("./useExternalHistory", async (importOriginal) => {
17
+ const original =
18
+ await importOriginal<typeof import("./useExternalHistory")>();
19
+ return {
20
+ ...original,
21
+ useExternalHistory: vi.fn(() => ({
22
+ isLoading: false,
23
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
24
+ markRunCancelled: vi.fn(),
25
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
26
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
27
+ })),
28
+ };
29
+ });
30
+
31
+ import { useExternalHistory } from "./useExternalHistory";
32
+ import { useAISDKRuntime } from "./useAISDKRuntime";
33
+ import { aiSDKExtras } from "../aiSDKExtras";
34
+
35
+ const createChatHelpers = (messages: any[] = []) => {
36
+ let currentMessages = [...messages];
37
+
38
+ const chatHelpers: any = {
39
+ id: "chat-1",
40
+ status: "ready",
41
+ error: null,
42
+ messages: currentMessages,
43
+ setMessages: vi.fn((next: any) => {
44
+ currentMessages =
45
+ typeof next === "function" ? next(currentMessages) : [...next];
46
+ chatHelpers.messages = currentMessages;
47
+ return currentMessages;
48
+ }),
49
+ sendMessage: vi.fn().mockResolvedValue(undefined),
50
+ regenerate: vi.fn().mockResolvedValue(undefined),
51
+ addToolResult: vi.fn(),
52
+ addToolOutput: vi.fn(),
53
+ stop: vi.fn(),
54
+ };
55
+
56
+ return chatHelpers;
57
+ };
58
+
59
+ const captureUnhandledRejections = async (
60
+ callback: () => Promise<void> | void,
61
+ ) => {
62
+ const reasons: unknown[] = [];
63
+ const listener = (reason: unknown) => {
64
+ reasons.push(reason);
65
+ };
66
+ const priorListeners = process.listeners("unhandledRejection");
67
+ process.removeAllListeners("unhandledRejection");
68
+ process.on("unhandledRejection", listener);
69
+ try {
70
+ await callback();
71
+ await new Promise((resolve) => setTimeout(resolve, 20));
72
+ return reasons;
73
+ } finally {
74
+ process.removeListener("unhandledRejection", listener);
75
+ for (const prior of priorListeners) {
76
+ process.on("unhandledRejection", prior);
77
+ }
78
+ }
79
+ };
80
+
81
+ const textOf = (message: any): string =>
82
+ message.content
83
+ .filter((part: any) => part.type === "text")
84
+ .map((part: any) => part.text)
85
+ .join("|");
86
+
87
+ describe("useAISDKRuntime", () => {
88
+ beforeEach(() => {
89
+ vi.mocked(useExternalHistory).mockReturnValue({
90
+ isLoading: false,
91
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
92
+ markRunCancelled: vi.fn(),
93
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
94
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
95
+ });
96
+ });
97
+
98
+ it("sends a new user message through the runtime", async () => {
99
+ const chat = createChatHelpers();
100
+
101
+ const { result } = renderHook(() => useAISDKRuntime(chat));
102
+
103
+ act(() => {
104
+ result.current.thread.append({
105
+ role: "user",
106
+ content: [{ type: "text", text: "hello" }],
107
+ });
108
+ });
109
+
110
+ await waitFor(() => {
111
+ expect(chat.sendMessage).toHaveBeenCalledTimes(1);
112
+ });
113
+
114
+ expect(chat.sendMessage).toHaveBeenCalledWith(
115
+ expect.objectContaining({
116
+ role: "user",
117
+ parts: expect.arrayContaining([
118
+ expect.objectContaining({ type: "text", text: "hello" }),
119
+ ]),
120
+ }),
121
+ expect.anything(),
122
+ );
123
+ });
124
+
125
+ it("forwards runConfig as metadata when sending", async () => {
126
+ const chat = createChatHelpers();
127
+
128
+ const { result } = renderHook(() => useAISDKRuntime(chat));
129
+
130
+ act(() => {
131
+ result.current.thread.append({
132
+ role: "user",
133
+ content: [{ type: "text", text: "hello" }],
134
+ runConfig: { custom: { model: "gpt-6-luna" } },
135
+ });
136
+ });
137
+
138
+ await waitFor(() => {
139
+ expect(chat.sendMessage).toHaveBeenCalledWith(expect.anything(), {
140
+ metadata: { custom: { model: "gpt-6-luna" } },
141
+ });
142
+ });
143
+ });
144
+
145
+ it("adopts a rejected stop so cancellation is not an unhandled rejection", async () => {
146
+ const abortError = new Error("signal is aborted without reason");
147
+ abortError.name = "AbortError";
148
+ const chat = createChatHelpers();
149
+ let stopCalls = 0;
150
+ let rejectStop!: (error: unknown) => void;
151
+ chat.stop = () => {
152
+ stopCalls += 1;
153
+ return new Promise((_, reject) => {
154
+ rejectStop = reject;
155
+ });
156
+ };
157
+ const consoleError = vi
158
+ .spyOn(console, "error")
159
+ .mockImplementation(() => {});
160
+
161
+ try {
162
+ const { result } = renderHook(() => useAISDKRuntime(chat));
163
+ const unhandledRejections = await captureUnhandledRejections(async () => {
164
+ await act(async () => {
165
+ result.current.thread.cancelRun();
166
+ rejectStop(abortError);
167
+ });
168
+ });
169
+
170
+ expect(stopCalls).toBe(1);
171
+ expect(unhandledRejections).toEqual([]);
172
+ expect(consoleError).not.toHaveBeenCalled();
173
+ } finally {
174
+ consoleError.mockRestore();
175
+ }
176
+ });
177
+
178
+ it("marks only the stopped output cancelled", async () => {
179
+ let resolveStop!: () => void;
180
+ const chat = createChatHelpers([
181
+ {
182
+ id: "assistant-1",
183
+ role: "assistant",
184
+ parts: [{ type: "text", text: "partial", state: "streaming" }],
185
+ },
186
+ ]);
187
+ chat.status = "streaming";
188
+ chat.stop = vi.fn(
189
+ () =>
190
+ new Promise<void>((resolve) => {
191
+ resolveStop = resolve;
192
+ }),
193
+ );
194
+
195
+ const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
196
+
197
+ act(() => {
198
+ result.current.thread.cancelRun();
199
+ });
200
+ rerender();
201
+
202
+ expect(
203
+ result.current.thread.getState().messages.at(-1)?.status,
204
+ ).toMatchObject({ type: "running" });
205
+
206
+ act(() => {
207
+ chat.status = "ready";
208
+ rerender();
209
+ });
210
+
211
+ await waitFor(() => {
212
+ expect(
213
+ result.current.thread.getState().messages.at(-1)?.status,
214
+ ).toMatchObject({
215
+ type: "incomplete",
216
+ reason: "cancelled",
217
+ });
218
+ });
219
+
220
+ await act(async () => {
221
+ resolveStop();
222
+ await Promise.resolve();
223
+ });
224
+
225
+ act(() => {
226
+ chat.setMessages([
227
+ {
228
+ id: "assistant-2",
229
+ role: "assistant",
230
+ parts: [{ type: "text", text: "replacement" }],
231
+ },
232
+ ]);
233
+ rerender();
234
+ });
235
+ await waitFor(() => {
236
+ expect(
237
+ result.current.thread.getState().messages.at(-1)?.status,
238
+ ).toMatchObject({ type: "complete", reason: "unknown" });
239
+ });
240
+
241
+ act(() => {
242
+ chat.setMessages([
243
+ {
244
+ id: "assistant-1",
245
+ role: "assistant",
246
+ parts: [{ type: "text", text: "partial" }],
247
+ },
248
+ ]);
249
+ chat.status = "streaming";
250
+ rerender();
251
+ });
252
+
253
+ expect(
254
+ result.current.thread.getState().messages.at(-1)?.status,
255
+ ).toMatchObject({ type: "running" });
256
+
257
+ act(() => {
258
+ chat.status = "ready";
259
+ rerender();
260
+ });
261
+
262
+ await waitFor(() => {
263
+ expect(
264
+ result.current.thread.getState().messages.at(-1)?.status,
265
+ ).toMatchObject({
266
+ type: "complete",
267
+ reason: "unknown",
268
+ });
269
+ });
270
+ });
271
+
272
+ it("keeps the stopped output cancelled through the next turn", async () => {
273
+ const chat = createChatHelpers([
274
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
275
+ {
276
+ id: "assistant-1",
277
+ role: "assistant",
278
+ parts: [{ type: "text", text: "partial", state: "streaming" }],
279
+ },
280
+ ]);
281
+ chat.status = "streaming";
282
+
283
+ const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
284
+
285
+ await act(async () => {
286
+ result.current.thread.cancelRun();
287
+ });
288
+ act(() => {
289
+ chat.status = "ready";
290
+ rerender();
291
+ });
292
+
293
+ await waitFor(() => {
294
+ expect(
295
+ result.current.thread.getState().messages.at(-1)?.status,
296
+ ).toMatchObject({ type: "incomplete", reason: "cancelled" });
297
+ });
298
+
299
+ act(() => {
300
+ chat.setMessages([
301
+ ...chat.messages,
302
+ { id: "u2", role: "user", parts: [{ type: "text", text: "next" }] },
303
+ {
304
+ id: "assistant-2",
305
+ role: "assistant",
306
+ parts: [{ type: "text", text: "answer" }],
307
+ },
308
+ ]);
309
+ rerender();
310
+ });
311
+
312
+ await waitFor(() => {
313
+ const messages = result.current.thread.getState().messages;
314
+ expect(
315
+ messages.find((message) => message.id === "assistant-1")?.status,
316
+ ).toMatchObject({ type: "incomplete", reason: "cancelled" });
317
+ expect(messages.at(-1)?.status).toMatchObject({
318
+ type: "complete",
319
+ reason: "unknown",
320
+ });
321
+ });
322
+ });
323
+
324
+ it("retracts the cancellation when the provider picks the message back up", async () => {
325
+ const chat = createChatHelpers([
326
+ {
327
+ id: "assistant-1",
328
+ role: "assistant",
329
+ parts: [{ type: "text", text: "partial", state: "streaming" }],
330
+ },
331
+ ]);
332
+ chat.status = "streaming";
333
+
334
+ const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
335
+
336
+ await act(async () => {
337
+ result.current.thread.cancelRun();
338
+ });
339
+ act(() => {
340
+ chat.status = "ready";
341
+ rerender();
342
+ });
343
+
344
+ await waitFor(() => {
345
+ expect(
346
+ result.current.thread.getState().messages.at(-1)?.status,
347
+ ).toMatchObject({ type: "incomplete", reason: "cancelled" });
348
+ });
349
+
350
+ act(() => {
351
+ chat.status = "streaming";
352
+ rerender();
353
+ });
354
+ act(() => {
355
+ chat.status = "ready";
356
+ rerender();
357
+ });
358
+
359
+ await waitFor(() => {
360
+ expect(
361
+ result.current.thread.getState().messages.at(-1)?.status,
362
+ ).toMatchObject({ type: "complete", reason: "unknown" });
363
+ });
364
+ });
365
+
366
+ it("does not mark completed output cancelled when already idle", async () => {
367
+ const chat = createChatHelpers([
368
+ {
369
+ id: "assistant-1",
370
+ role: "assistant",
371
+ parts: [{ type: "text", text: "finished" }],
372
+ },
373
+ ]);
374
+
375
+ const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
376
+
377
+ act(() => {
378
+ result.current.thread.cancelRun();
379
+ rerender();
380
+ });
381
+
382
+ await waitFor(() => {
383
+ expect(
384
+ result.current.thread.getState().messages.at(-1)?.status,
385
+ ).toMatchObject({ type: "complete", reason: "unknown" });
386
+ });
387
+ });
388
+
389
+ it("keeps rendering through a run when the last assistant message has malformed parts", () => {
390
+ const chat = createChatHelpers([
391
+ { id: "user-1", role: "user", parts: [{ type: "text", text: "hi" }] },
392
+ {
393
+ id: "assistant-1",
394
+ role: "assistant",
395
+ parts: [
396
+ null,
397
+ { text: "no type" },
398
+ { type: "text" },
399
+ { type: "text", text: "yo" },
400
+ ],
401
+ },
402
+ ]);
403
+ chat.status = "submitted";
404
+
405
+ const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
406
+ act(() => {
407
+ chat.status = "ready";
408
+ rerender();
409
+ });
410
+
411
+ expect(textOf(result.current.thread.getState().messages.at(-1))).toBe("yo");
412
+ });
413
+
414
+ it("marks output cancelled while a client tool is still executing", async () => {
415
+ let resolveTool!: (value: string) => void;
416
+ const execute = vi.fn(
417
+ () =>
418
+ new Promise<string>((resolve) => {
419
+ resolveTool = resolve;
420
+ }),
421
+ );
422
+ const chat = createChatHelpers();
423
+
424
+ const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
425
+ const unregister = result.current.registerModelContextProvider({
426
+ getModelContext: () => ({
427
+ tools: {
428
+ weather: {
429
+ parameters: { type: "object", properties: {} },
430
+ execute,
431
+ },
432
+ },
433
+ }),
434
+ });
435
+
436
+ try {
437
+ act(() => {
438
+ chat.setMessages([
439
+ {
440
+ id: "assistant-1",
441
+ role: "assistant",
442
+ parts: [
443
+ {
444
+ type: "tool-weather",
445
+ toolCallId: "tool-1",
446
+ state: "input-available",
447
+ input: { city: "London" },
448
+ },
449
+ ],
450
+ },
451
+ ]);
452
+ rerender();
453
+ });
454
+
455
+ await waitFor(() => {
456
+ expect(execute).toHaveBeenCalledOnce();
457
+ expect(result.current.thread.getState().isRunning).toBe(true);
458
+ });
459
+
460
+ act(() => {
461
+ result.current.thread.cancelRun();
462
+ chat.setMessages([
463
+ {
464
+ id: "assistant-1",
465
+ role: "assistant",
466
+ parts: [{ type: "text", text: "stopped" }],
467
+ },
468
+ ]);
469
+ resolveTool("sunny");
470
+ rerender();
471
+ });
472
+
473
+ await waitFor(() => {
474
+ expect(
475
+ result.current.thread.getState().messages.at(-1)?.status,
476
+ ).toMatchObject({ type: "incomplete", reason: "cancelled" });
477
+ });
478
+ } finally {
479
+ unregister();
480
+ }
481
+ });
482
+
483
+ it("reports non-AbortError cancellation failures", async () => {
484
+ const stopError = new Error("stop failed");
485
+ const chat = createChatHelpers();
486
+ chat.stop = vi.fn().mockRejectedValue(stopError);
487
+ const consoleError = vi
488
+ .spyOn(console, "error")
489
+ .mockImplementation(() => {});
490
+
491
+ try {
492
+ const { result } = renderHook(() => useAISDKRuntime(chat));
493
+
494
+ act(() => {
495
+ result.current.thread.cancelRun();
496
+ });
497
+
498
+ await waitFor(() => {
499
+ expect(consoleError).toHaveBeenCalledWith(
500
+ "[ExternalStoreThreadRuntimeCore] onCancel callback rejected",
501
+ stopError,
502
+ );
503
+ });
504
+ } finally {
505
+ consoleError.mockRestore();
506
+ }
507
+ });
508
+
509
+ it("cancels pending tool calls before sending a new message", async () => {
510
+ const chat = createChatHelpers([
511
+ {
512
+ id: "a1",
513
+ role: "assistant",
514
+ parts: [
515
+ {
516
+ type: "tool-weather",
517
+ toolCallId: "tc-1",
518
+ state: "input-available",
519
+ input: { city: "NYC" },
520
+ },
521
+ {
522
+ type: "tool-weather",
523
+ toolCallId: "tc-2",
524
+ state: "output-available",
525
+ input: { city: "LA" },
526
+ output: { temp: 70 },
527
+ },
528
+ ],
529
+ },
530
+ ]);
531
+
532
+ const { result } = renderHook(() => useAISDKRuntime(chat));
533
+
534
+ // Wait for the runtime to process the initial messages
535
+ await waitFor(() => {
536
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
537
+ 0,
538
+ );
539
+ });
540
+
541
+ act(() => {
542
+ result.current.thread.append({
543
+ role: "user",
544
+ content: [{ type: "text", text: "continue" }],
545
+ });
546
+ });
547
+
548
+ await waitFor(() => {
549
+ expect(chat.sendMessage).toHaveBeenCalledTimes(1);
550
+ });
551
+
552
+ // Pending tool (tc-1) should be marked as cancelled
553
+ expect(chat.messages[0].parts[0].state).toBe("output-error");
554
+ expect(chat.messages[0].parts[0].errorText).toBe(
555
+ "User cancelled tool call by sending a new message.",
556
+ );
557
+ // Completed tool (tc-2) should remain unchanged
558
+ expect(chat.messages[0].parts[1].state).toBe("output-available");
559
+ });
560
+
561
+ it("sends past an assistant message with malformed parts", async () => {
562
+ const chat = createChatHelpers([
563
+ {
564
+ id: "a1",
565
+ role: "assistant",
566
+ parts: [
567
+ null,
568
+ { text: "no type" },
569
+ {
570
+ type: "tool-weather",
571
+ toolCallId: "tc-1",
572
+ state: "input-available",
573
+ input: { city: "NYC" },
574
+ },
575
+ ],
576
+ },
577
+ ]);
578
+
579
+ const { result } = renderHook(() => useAISDKRuntime(chat));
580
+ await waitFor(() => {
581
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
582
+ 0,
583
+ );
584
+ });
585
+
586
+ await act(async () => {
587
+ result.current.thread.append({
588
+ role: "user",
589
+ content: [{ type: "text", text: "continue" }],
590
+ });
591
+ });
592
+
593
+ await waitFor(() => {
594
+ expect(chat.sendMessage).toHaveBeenCalledTimes(1);
595
+ });
596
+ expect(chat.messages[0].parts.slice(0, 2)).toEqual([
597
+ null,
598
+ { text: "no type" },
599
+ ]);
600
+ expect(chat.messages[0].parts[2].state).toBe("output-error");
601
+ });
602
+
603
+ it("answers a tool call in an earlier assistant message with malformed parts", async () => {
604
+ const chat = createChatHelpers([
605
+ {
606
+ id: "a1",
607
+ role: "assistant",
608
+ parts: [
609
+ null,
610
+ {
611
+ type: "tool-weather",
612
+ toolCallId: "tc-1",
613
+ state: "input-available",
614
+ input: { city: "NYC" },
615
+ },
616
+ ],
617
+ },
618
+ { id: "u2", role: "user", parts: [{ type: "text", text: "and?" }] },
619
+ { id: "a2", role: "assistant", parts: [{ type: "text", text: "ok" }] },
620
+ ]);
621
+
622
+ const { result } = renderHook(() => useAISDKRuntime(chat));
623
+ await waitFor(() => {
624
+ expect(result.current.thread.getState().messages.length).toBe(3);
625
+ });
626
+
627
+ await act(async () => {
628
+ result.current.thread
629
+ .getMessageById("a1")
630
+ .getMessagePartByToolCallId("tc-1")
631
+ .addToolResult({ temp: 72 });
632
+ });
633
+
634
+ await waitFor(() => {
635
+ expect(chat.messages[0].parts[1].state).toBe("output-available");
636
+ });
637
+ expect(chat.messages[0].parts[0]).toBeNull();
638
+ expect(chat.messages[0].parts[1].output).toEqual({ temp: 72 });
639
+ });
640
+
641
+ it("strips stale approval when cancelling a tool pending approval so history stays valid", async () => {
642
+ const chat = createChatHelpers([
643
+ {
644
+ id: "a1",
645
+ role: "assistant",
646
+ parts: [
647
+ {
648
+ type: "dynamic-tool",
649
+ toolName: "mcp_search",
650
+ toolCallId: "tc-1",
651
+ state: "approval-requested",
652
+ input: { q: "hi" },
653
+ approval: { id: "appr-1" },
654
+ },
655
+ ],
656
+ },
657
+ ]);
658
+
659
+ const { result } = renderHook(() => useAISDKRuntime(chat));
660
+
661
+ await waitFor(() => {
662
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
663
+ 0,
664
+ );
665
+ });
666
+
667
+ act(() => {
668
+ result.current.thread.append({
669
+ role: "user",
670
+ content: [{ type: "text", text: "what" }],
671
+ });
672
+ });
673
+
674
+ await waitFor(() => {
675
+ expect(chat.sendMessage).toHaveBeenCalledTimes(1);
676
+ });
677
+
678
+ const part = chat.messages[0].parts[0];
679
+ expect(part.state).toBe("output-error");
680
+ // The pending-approval object must not survive into the terminal state,
681
+ // otherwise AI SDK's validateUIMessages rejects the next request.
682
+ expect(part.approval).toBeUndefined();
683
+
684
+ await expect(
685
+ validateUIMessages({ messages: chat.messages }),
686
+ ).resolves.toBeDefined();
687
+ });
688
+
689
+ it("cancels a pending tool call left behind a staged message", async () => {
690
+ const chat = createChatHelpers([
691
+ {
692
+ id: "a1",
693
+ role: "assistant",
694
+ parts: [
695
+ {
696
+ type: "dynamic-tool",
697
+ toolName: "mcp_search",
698
+ toolCallId: "tc-1",
699
+ state: "approval-requested",
700
+ input: { q: "hi" },
701
+ approval: { id: "appr-1" },
702
+ },
703
+ ],
704
+ },
705
+ ]);
706
+
707
+ const { result } = renderHook(() => useAISDKRuntime(chat));
708
+
709
+ await waitFor(() => {
710
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
711
+ 0,
712
+ );
713
+ });
714
+
715
+ act(() => {
716
+ result.current.thread.append({
717
+ role: "user",
718
+ content: [{ type: "text", text: "context" }],
719
+ startRun: false,
720
+ });
721
+ });
722
+
723
+ await waitFor(() => {
724
+ expect(chat.messages).toHaveLength(2);
725
+ });
726
+
727
+ act(() => {
728
+ result.current.thread.append({
729
+ role: "user",
730
+ content: [{ type: "text", text: "what" }],
731
+ });
732
+ });
733
+
734
+ await waitFor(() => {
735
+ expect(chat.sendMessage).toHaveBeenCalledTimes(1);
736
+ });
737
+
738
+ const part = chat.messages[0].parts[0];
739
+ expect(part.state).toBe("output-error");
740
+ expect(part.approval).toBeUndefined();
741
+
742
+ await expect(
743
+ validateUIMessages({ messages: chat.messages }),
744
+ ).resolves.toBeDefined();
745
+ });
746
+
747
+ it("attaches a tool artifact to the live part and forwards its result through addToolOutput", async () => {
748
+ const chat = createChatHelpers([
749
+ {
750
+ id: "a1",
751
+ role: "assistant",
752
+ parts: [
753
+ {
754
+ type: "tool-weather",
755
+ toolCallId: "tc-1",
756
+ state: "input-available",
757
+ input: { city: "NYC" },
758
+ },
759
+ ],
760
+ },
761
+ ]);
762
+
763
+ const { result, rerender } = renderHook(() => useAISDKRuntime(chat));
764
+
765
+ await waitFor(() => {
766
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
767
+ 0,
768
+ );
769
+ });
770
+
771
+ act(() => {
772
+ result.current.thread
773
+ .getMessageById("a1")
774
+ .getMessagePartByToolCallId("tc-1")
775
+ .addToolResult(
776
+ new ToolResponse({
777
+ result: { temp: 72 },
778
+ artifact: { preview: "72°F and sunny" },
779
+ }),
780
+ );
781
+ });
782
+
783
+ await waitFor(() => {
784
+ expect(chat.addToolOutput).toHaveBeenCalledTimes(1);
785
+ });
786
+
787
+ const livePart = result.current.thread
788
+ .getMessageById("a1")
789
+ .getMessagePartByToolCallId("tc-1")
790
+ .getState();
791
+ expect(
792
+ livePart.type === "tool-call" ? livePart.artifact : undefined,
793
+ ).toEqual({ preview: "72°F and sunny" });
794
+
795
+ expect(chat.addToolOutput).toHaveBeenCalledWith(
796
+ expect.objectContaining({
797
+ tool: "weather",
798
+ toolCallId: "tc-1",
799
+ output: { temp: 72 },
800
+ options: { metadata: undefined },
801
+ }),
802
+ );
803
+ expect(chat.addToolOutput.mock.calls[0]?.[0]).not.toHaveProperty(
804
+ "artifact",
805
+ );
806
+ expect(chat.addToolResult).not.toHaveBeenCalled();
807
+ expect(
808
+ vi.mocked(useExternalHistory).mock.calls.at(-1)?.[5]?.().get("tc-1"),
809
+ ).toEqual({ preview: "72°F and sunny" });
810
+
811
+ chat.messages = chat.messages.map((message: UIMessage) => ({
812
+ ...message,
813
+ }));
814
+ rerender();
815
+
816
+ await waitFor(() => {
817
+ const part = result.current.thread
818
+ .getMessageById("a1")
819
+ .getMessagePartByToolCallId("tc-1")
820
+ .getState();
821
+ expect(part.type === "tool-call" ? part.artifact : undefined).toEqual({
822
+ preview: "72°F and sunny",
823
+ });
824
+ });
825
+ expect(chat.messages[0]?.metadata).toBeUndefined();
826
+ });
827
+
828
+ it("shows recorded tool interactions without writing them to chat messages", async () => {
829
+ const chat = createChatHelpers([
830
+ {
831
+ id: "a1",
832
+ role: "assistant",
833
+ parts: [
834
+ {
835
+ type: "tool-weather",
836
+ toolCallId: "tc-1",
837
+ state: "input-available",
838
+ input: { city: "NYC" },
839
+ },
840
+ ],
841
+ },
842
+ ]);
843
+ const persistToolInteractions = vi.fn().mockResolvedValue(undefined);
844
+ vi.mocked(useExternalHistory).mockReturnValue({
845
+ isLoading: false,
846
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
847
+ markRunCancelled: vi.fn(),
848
+ persistToolInteractions,
849
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
850
+ });
851
+
852
+ const { result } = renderHook(() => useAISDKRuntime(chat));
853
+
854
+ await waitFor(() => {
855
+ expect(result.current.thread.getState().messages).toHaveLength(1);
856
+ });
857
+
858
+ await act(async () => {
859
+ await result.current.thread
860
+ .getMessageById("a1")
861
+ .getMessagePartByToolCallId("tc-1").unstable_recordInteraction!({
862
+ type: "action",
863
+ payload: { refresh: true },
864
+ });
865
+ });
866
+
867
+ await waitFor(() => {
868
+ const part = result.current.thread
869
+ .getMessageById("a1")
870
+ .getMessagePartByToolCallId("tc-1")
871
+ .getState();
872
+ expect(
873
+ part.type === "tool-call" ? part.unstable_interactions : undefined,
874
+ ).toEqual({
875
+ entries: [
876
+ {
877
+ type: "action",
878
+ occurredAt: expect.any(Number),
879
+ payload: { refresh: true },
880
+ },
881
+ ],
882
+ });
883
+ });
884
+ expect(persistToolInteractions).toHaveBeenCalledExactlyOnceWith("a1");
885
+ expect(chat.messages[0]?.metadata).toBeUndefined();
886
+ expect(chat.addToolOutput).not.toHaveBeenCalled();
887
+ expect(chat.sendMessage).not.toHaveBeenCalled();
888
+ });
889
+
890
+ it("appends a new user message without sending when startRun is false", async () => {
891
+ const chat = createChatHelpers([
892
+ { id: "u1", role: "user", parts: [{ type: "text", text: "earlier" }] },
893
+ ]);
894
+
895
+ const { result } = renderHook(() => useAISDKRuntime(chat));
896
+
897
+ await waitFor(() => {
898
+ expect(result.current.thread.getState().messages.length).toBe(1);
899
+ });
900
+
901
+ act(() => {
902
+ result.current.thread.append({
903
+ role: "user",
904
+ content: [{ type: "text", text: "hold this" }],
905
+ startRun: false,
906
+ });
907
+ });
908
+
909
+ await waitFor(() => {
910
+ expect(chat.setMessages).toHaveBeenCalled();
911
+ });
912
+
913
+ expect(chat.sendMessage).not.toHaveBeenCalled();
914
+ expect(chat.messages).toHaveLength(2);
915
+ expect(chat.messages[1]).toEqual(
916
+ expect.objectContaining({
917
+ role: "user",
918
+ id: expect.any(String),
919
+ parts: expect.arrayContaining([
920
+ expect.objectContaining({ type: "text", text: "hold this" }),
921
+ ]),
922
+ }),
923
+ );
924
+ });
925
+
926
+ it("edits without sending when startRun is false", async () => {
927
+ const chat = createChatHelpers([
928
+ { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
929
+ {
930
+ id: "a1",
931
+ role: "assistant",
932
+ parts: [{ type: "text", text: "first-answer" }],
933
+ },
934
+ { id: "u2", role: "user", parts: [{ type: "text", text: "second" }] },
935
+ ]);
936
+
937
+ const { result } = renderHook(() => useAISDKRuntime(chat));
938
+
939
+ await waitFor(() => {
940
+ expect(result.current.thread.getState().messages.length).toBe(3);
941
+ });
942
+
943
+ act(() => {
944
+ result.current.thread.append({
945
+ role: "user",
946
+ parentId: "u1",
947
+ content: [{ type: "text", text: "rewrite, no run" }],
948
+ startRun: false,
949
+ });
950
+ });
951
+
952
+ await waitFor(() => {
953
+ expect(chat.setMessages).toHaveBeenCalled();
954
+ });
955
+
956
+ expect(chat.sendMessage).not.toHaveBeenCalled();
957
+ expect(chat.messages.map((m: any) => m.id)).toEqual([
958
+ "u1",
959
+ expect.any(String),
960
+ ]);
961
+ expect(chat.messages[1]).toEqual(
962
+ expect.objectContaining({
963
+ role: "user",
964
+ parts: expect.arrayContaining([
965
+ expect.objectContaining({ type: "text", text: "rewrite, no run" }),
966
+ ]),
967
+ }),
968
+ );
969
+ });
970
+
971
+ it("deletes only the selected message from AI SDK state", async () => {
972
+ const deleteMessage = vi.fn().mockResolvedValue(undefined);
973
+ vi.mocked(useExternalHistory).mockReturnValue({
974
+ isLoading: false,
975
+ deleteMessage,
976
+ markRunCancelled: vi.fn(),
977
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
978
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
979
+ });
980
+ const chat = createChatHelpers([
981
+ { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
982
+ {
983
+ id: "a1",
984
+ role: "assistant",
985
+ parts: [{ type: "text", text: "first-answer" }],
986
+ },
987
+ { id: "u2", role: "user", parts: [{ type: "text", text: "second" }] },
988
+ {
989
+ id: "a2",
990
+ role: "assistant",
991
+ parts: [{ type: "text", text: "second-answer" }],
992
+ },
993
+ ]);
994
+
995
+ const { result } = renderHook(() => useAISDKRuntime(chat));
996
+
997
+ await waitFor(() => {
998
+ expect(result.current.thread.getState().messages).toHaveLength(4);
999
+ });
1000
+
1001
+ await act(async () => {
1002
+ result.current.thread.getMessageById("u2").delete();
1003
+ });
1004
+
1005
+ expect(deleteMessage).toHaveBeenCalledWith("u2");
1006
+ expect(chat.messages.map((message: any) => message.id)).toEqual([
1007
+ "u1",
1008
+ "a1",
1009
+ "a2",
1010
+ ]);
1011
+ });
1012
+
1013
+ it("removes tool artifacts with their deleted message", async () => {
1014
+ const deleteMessage = vi.fn().mockResolvedValue(undefined);
1015
+ vi.mocked(useExternalHistory).mockReturnValue({
1016
+ isLoading: false,
1017
+ deleteMessage,
1018
+ markRunCancelled: vi.fn(),
1019
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
1020
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
1021
+ });
1022
+ const chat = createChatHelpers([
1023
+ {
1024
+ id: "a1",
1025
+ role: "assistant",
1026
+ parts: [
1027
+ {
1028
+ type: "tool-weather",
1029
+ toolCallId: "tc-1",
1030
+ state: "input-available",
1031
+ input: { city: "NYC" },
1032
+ },
1033
+ ],
1034
+ },
1035
+ ]);
1036
+
1037
+ const { result } = renderHook(() => useAISDKRuntime(chat));
1038
+
1039
+ await waitFor(() => {
1040
+ expect(result.current.thread.getState().messages).toHaveLength(1);
1041
+ });
1042
+
1043
+ act(() => {
1044
+ result.current.thread
1045
+ .getMessageById("a1")
1046
+ .getMessagePartByToolCallId("tc-1")
1047
+ .addToolResult(
1048
+ new ToolResponse({
1049
+ result: { temp: 72 },
1050
+ artifact: { preview: "72°F and sunny" },
1051
+ }),
1052
+ );
1053
+ });
1054
+
1055
+ await waitFor(() => {
1056
+ expect(chat.addToolOutput).toHaveBeenCalledTimes(1);
1057
+ });
1058
+
1059
+ expect(
1060
+ vi.mocked(useExternalHistory).mock.calls.at(-1)?.[5]?.().get("tc-1"),
1061
+ ).toEqual({
1062
+ preview: "72°F and sunny",
1063
+ });
1064
+ await act(async () => {
1065
+ await result.current.thread.getMessageById("a1").delete();
1066
+ });
1067
+
1068
+ expect(deleteMessage).toHaveBeenCalledWith("a1");
1069
+ expect(
1070
+ vi.mocked(useExternalHistory).mock.calls.at(-1)?.[5]?.().has("tc-1"),
1071
+ ).toBe(false);
1072
+ });
1073
+
1074
+ it("removes tool interactions with their deleted message", async () => {
1075
+ const deleteMessage = vi.fn().mockResolvedValue(undefined);
1076
+ vi.mocked(useExternalHistory).mockReturnValue({
1077
+ isLoading: false,
1078
+ deleteMessage,
1079
+ markRunCancelled: vi.fn(),
1080
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
1081
+ persistToolApprovalResponses: vi.fn().mockResolvedValue(undefined),
1082
+ });
1083
+ const chat = createChatHelpers([
1084
+ {
1085
+ id: "a1",
1086
+ role: "assistant",
1087
+ parts: [
1088
+ {
1089
+ type: "tool-weather",
1090
+ toolCallId: "tc-1",
1091
+ state: "output-available",
1092
+ input: { city: "NYC" },
1093
+ output: { temp: 72 },
1094
+ },
1095
+ ],
1096
+ },
1097
+ ]);
1098
+
1099
+ const { result } = renderHook(() => useAISDKRuntime(chat));
1100
+
1101
+ await waitFor(() => {
1102
+ expect(result.current.thread.getState().messages).toHaveLength(1);
1103
+ });
1104
+
1105
+ await act(async () => {
1106
+ await result.current.thread
1107
+ .getMessageById("a1")
1108
+ .getMessagePartByToolCallId("tc-1").unstable_recordInteraction!({
1109
+ type: "action",
1110
+ payload: { type: "refresh" },
1111
+ });
1112
+ });
1113
+
1114
+ expect(
1115
+ vi.mocked(useExternalHistory).mock.calls.at(-1)?.[7]?.().has("tc-1"),
1116
+ ).toBe(true);
1117
+
1118
+ await act(async () => {
1119
+ await result.current.thread.getMessageById("a1").delete();
1120
+ });
1121
+
1122
+ expect(deleteMessage).toHaveBeenCalledWith("a1");
1123
+ expect(
1124
+ vi.mocked(useExternalHistory).mock.calls.at(-1)?.[7]?.().has("tc-1"),
1125
+ ).toBe(false);
1126
+ });
1127
+
1128
+ it("edit slices history to parentId and sends the edited message", async () => {
1129
+ const chat = createChatHelpers([
1130
+ { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
1131
+ {
1132
+ id: "a1",
1133
+ role: "assistant",
1134
+ parts: [{ type: "text", text: "first-answer" }],
1135
+ },
1136
+ { id: "u2", role: "user", parts: [{ type: "text", text: "second" }] },
1137
+ {
1138
+ id: "a2",
1139
+ role: "assistant",
1140
+ parts: [{ type: "text", text: "second-answer" }],
1141
+ },
1142
+ ]);
1143
+
1144
+ const { result } = renderHook(() => useAISDKRuntime(chat));
1145
+
1146
+ await waitFor(() => {
1147
+ expect(result.current.thread.getState().messages.length).toBe(4);
1148
+ });
1149
+
1150
+ // Append with parentId != last message triggers onEdit
1151
+ act(() => {
1152
+ result.current.thread.append({
1153
+ role: "user",
1154
+ parentId: "u1",
1155
+ content: [{ type: "text", text: "rewrite first" }],
1156
+ runConfig: { custom: { temperature: 0.2 } },
1157
+ });
1158
+ });
1159
+
1160
+ await waitFor(() => {
1161
+ expect(chat.sendMessage).toHaveBeenCalledTimes(1);
1162
+ });
1163
+
1164
+ expect(chat.messages.map((m: any) => m.id)).toEqual(["u1"]);
1165
+ expect(chat.sendMessage).toHaveBeenCalledWith(
1166
+ expect.objectContaining({ role: "user" }),
1167
+ { metadata: { custom: { temperature: 0.2 } } },
1168
+ );
1169
+ });
1170
+
1171
+ it("forwards onResume so runtime.thread.resumeRun is delivered to the adapter", async () => {
1172
+ const chat = createChatHelpers([
1173
+ { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
1174
+ ]);
1175
+ const onResume = vi.fn().mockResolvedValue(undefined);
1176
+
1177
+ const { result } = renderHook(() => useAISDKRuntime(chat, { onResume }));
1178
+
1179
+ await waitFor(() => {
1180
+ expect(result.current.thread.getState().messages.length).toBe(1);
1181
+ });
1182
+
1183
+ act(() => {
1184
+ result.current.thread.resumeRun({
1185
+ parentId: "u1",
1186
+ runConfig: { custom: { turnId: "t-42" } },
1187
+ });
1188
+ });
1189
+
1190
+ await waitFor(() => {
1191
+ expect(onResume).toHaveBeenCalledTimes(1);
1192
+ });
1193
+
1194
+ expect(onResume).toHaveBeenCalledWith(
1195
+ expect.objectContaining({
1196
+ parentId: "u1",
1197
+ sourceId: null,
1198
+ runConfig: { custom: { turnId: "t-42" } },
1199
+ }),
1200
+ );
1201
+ });
1202
+
1203
+ it("rejects when resumeRun is called without an onResume adapter", async () => {
1204
+ const chat = createChatHelpers([
1205
+ { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
1206
+ ]);
1207
+
1208
+ const { result } = renderHook(() => useAISDKRuntime(chat));
1209
+
1210
+ await waitFor(() => {
1211
+ expect(result.current.thread.getState().messages.length).toBe(1);
1212
+ });
1213
+
1214
+ await expect(
1215
+ result.current.thread.resumeRun({
1216
+ parentId: "u1",
1217
+ }) as unknown as Promise<void>,
1218
+ ).rejects.toThrow("Runtime does not support resuming runs.");
1219
+ });
1220
+
1221
+ it("forwards onResumeToolCall so runtime.thread.resumeToolCall is delivered to the adapter", async () => {
1222
+ const chat = createChatHelpers([
1223
+ {
1224
+ id: "a1",
1225
+ role: "assistant",
1226
+ parts: [
1227
+ {
1228
+ type: "tool-weather",
1229
+ toolCallId: "tc-42",
1230
+ state: "input-available",
1231
+ input: { city: "NYC" },
1232
+ },
1233
+ ],
1234
+ },
1235
+ ]);
1236
+ const onResumeToolCall = vi.fn();
1237
+
1238
+ const { result } = renderHook(() =>
1239
+ useAISDKRuntime(chat, { onResumeToolCall }),
1240
+ );
1241
+
1242
+ await waitFor(() => {
1243
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
1244
+ 0,
1245
+ );
1246
+ });
1247
+
1248
+ act(() => {
1249
+ result.current.thread
1250
+ .getMessageById("a1")
1251
+ .getMessagePartByToolCallId("tc-42")
1252
+ .resumeToolCall({ answer: "yes" });
1253
+ });
1254
+
1255
+ expect(onResumeToolCall).toHaveBeenCalledTimes(1);
1256
+ expect(onResumeToolCall).toHaveBeenCalledWith({
1257
+ toolCallId: "tc-42",
1258
+ payload: { answer: "yes" },
1259
+ });
1260
+ });
1261
+
1262
+ it("throws when resumeToolCall is called without an onResumeToolCall adapter", async () => {
1263
+ const chat = createChatHelpers([
1264
+ {
1265
+ id: "a1",
1266
+ role: "assistant",
1267
+ parts: [
1268
+ {
1269
+ type: "tool-weather",
1270
+ toolCallId: "tc-missing",
1271
+ state: "input-available",
1272
+ input: { city: "NYC" },
1273
+ },
1274
+ ],
1275
+ },
1276
+ ]);
1277
+
1278
+ const { result } = renderHook(() => useAISDKRuntime(chat));
1279
+
1280
+ await waitFor(() => {
1281
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
1282
+ 0,
1283
+ );
1284
+ });
1285
+
1286
+ expect(() =>
1287
+ result.current.thread
1288
+ .getMessageById("a1")
1289
+ .getMessagePartByToolCallId("tc-missing")
1290
+ .resumeToolCall({ answer: "yes" }),
1291
+ ).toThrow("Tool call tc-missing is not waiting for resume.");
1292
+ });
1293
+
1294
+ it("reload slices history and regenerates with metadata", async () => {
1295
+ const chat = createChatHelpers([
1296
+ { id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
1297
+ {
1298
+ id: "a1",
1299
+ role: "assistant",
1300
+ parts: [{ type: "text", text: "first-answer" }],
1301
+ },
1302
+ { id: "u2", role: "user", parts: [{ type: "text", text: "second" }] },
1303
+ {
1304
+ id: "a2",
1305
+ role: "assistant",
1306
+ parts: [{ type: "text", text: "second-answer" }],
1307
+ },
1308
+ ]);
1309
+
1310
+ const { result } = renderHook(() => useAISDKRuntime(chat));
1311
+
1312
+ await waitFor(() => {
1313
+ expect(result.current.thread.getState().messages.length).toBe(4);
1314
+ });
1315
+
1316
+ act(() => {
1317
+ result.current.thread.startRun({
1318
+ parentId: "u1",
1319
+ runConfig: { custom: { maxTokens: 100 } },
1320
+ });
1321
+ });
1322
+
1323
+ await waitFor(() => {
1324
+ expect(chat.regenerate).toHaveBeenCalledTimes(1);
1325
+ });
1326
+
1327
+ expect(chat.messages.map((m: any) => m.id)).toEqual(["u1", "a1"]);
1328
+ expect(chat.regenerate).toHaveBeenCalledWith({
1329
+ metadata: { custom: { maxTokens: 100 } },
1330
+ });
1331
+ });
1332
+
1333
+ it("forwards isDisabled to thread state", () => {
1334
+ const chat = createChatHelpers();
1335
+ const { result } = renderHook(() =>
1336
+ useAISDKRuntime(chat, { isDisabled: true }),
1337
+ );
1338
+ expect(result.current.thread.getState().isDisabled).toBe(true);
1339
+ });
1340
+
1341
+ it("forwards isSendDisabled to the composer canSend gate", () => {
1342
+ const chat = createChatHelpers();
1343
+ const { result } = renderHook(() =>
1344
+ useAISDKRuntime(chat, { isSendDisabled: true }),
1345
+ );
1346
+ act(() => {
1347
+ result.current.thread.composer.setText("hello");
1348
+ });
1349
+ expect(result.current.thread.composer.getState().canSend).toBe(false);
1350
+ });
1351
+
1352
+ it("forwards unstable_capabilities to thread capabilities", () => {
1353
+ const chat = createChatHelpers();
1354
+ const { result } = renderHook(() =>
1355
+ useAISDKRuntime(chat, { unstable_capabilities: { copy: false } }),
1356
+ );
1357
+ expect(result.current.thread.getState().capabilities.unstable_copy).toBe(
1358
+ false,
1359
+ );
1360
+ });
1361
+
1362
+ it("forwards suggestions to thread state", () => {
1363
+ const chat = createChatHelpers();
1364
+ const suggestions = [{ prompt: "tell me a joke" }];
1365
+ const { result } = renderHook(() => useAISDKRuntime(chat, { suggestions }));
1366
+ expect(result.current.thread.getState().suggestions).toEqual(suggestions);
1367
+ });
1368
+
1369
+ it("imports a child-first message tree without replacing the chat feed", async () => {
1370
+ const chat = createChatHelpers();
1371
+ const onBranchChange = vi.fn();
1372
+ const messageRepository: MessageFormatRepository<UIMessage> = {
1373
+ headId: "a2",
1374
+ messages: [
1375
+ {
1376
+ parentId: "u1",
1377
+ message: {
1378
+ id: "a1",
1379
+ role: "assistant" as const,
1380
+ parts: [{ type: "text" as const, text: "first" }],
1381
+ },
1382
+ },
1383
+ {
1384
+ parentId: "u1",
1385
+ message: {
1386
+ id: "a2",
1387
+ role: "assistant" as const,
1388
+ parts: [{ type: "text" as const, text: "second" }],
1389
+ },
1390
+ },
1391
+ {
1392
+ parentId: null,
1393
+ message: {
1394
+ id: "u1",
1395
+ role: "user" as const,
1396
+ parts: [{ type: "text" as const, text: "question" }],
1397
+ },
1398
+ },
1399
+ ],
1400
+ };
1401
+
1402
+ const { result, rerender } = renderHook(() =>
1403
+ useAISDKRuntime(chat, {
1404
+ messageRepository,
1405
+ unstable_onBranchChange: onBranchChange,
1406
+ }),
1407
+ );
1408
+
1409
+ await waitFor(() => {
1410
+ expect(
1411
+ result.current.thread.getState().messages.map((message) => message.id),
1412
+ ).toEqual(["u1", "a2"]);
1413
+ });
1414
+ expect(result.current.thread.getMessageById("a2").getState()).toMatchObject(
1415
+ {
1416
+ branchNumber: 2,
1417
+ branchCount: 2,
1418
+ },
1419
+ );
1420
+ expect(chat.messages.map((message: { id: string }) => message.id)).toEqual([
1421
+ "u1",
1422
+ "a2",
1423
+ ]);
1424
+
1425
+ act(() => {
1426
+ result.current.thread
1427
+ .getMessageById("a2")
1428
+ .switchToBranch({ branchId: "a1" });
1429
+ });
1430
+
1431
+ expect(onBranchChange).toHaveBeenCalledWith({
1432
+ headId: "a1",
1433
+ visibleMessageIds: ["u1", "a1"],
1434
+ });
1435
+ expect(chat.messages.map((message: { id: string }) => message.id)).toEqual([
1436
+ "u1",
1437
+ "a1",
1438
+ ]);
1439
+ expect(chat.messages).not.toEqual([]);
1440
+
1441
+ chat.messages = [
1442
+ { id: "u1", role: "user", parts: [{ type: "text", text: "question" }] },
1443
+ {
1444
+ id: "a1",
1445
+ role: "assistant",
1446
+ parts: [{ type: "text", text: "first token" }],
1447
+ },
1448
+ ];
1449
+ rerender();
1450
+
1451
+ await waitFor(() => {
1452
+ expect(textOf(result.current.thread.getState().messages.at(-1))).toBe(
1453
+ "first token",
1454
+ );
1455
+ });
1456
+ expect(result.current.thread.getMessageById("a1").getState()).toMatchObject(
1457
+ {
1458
+ branchNumber: 1,
1459
+ branchCount: 2,
1460
+ },
1461
+ );
1462
+ });
1463
+
1464
+ it("does not overwrite a nonempty chat with the repository", async () => {
1465
+ const chat = createChatHelpers([
1466
+ { id: "live", role: "user", parts: [{ type: "text", text: "keep me" }] },
1467
+ ]);
1468
+ const messageRepository: MessageFormatRepository<UIMessage> = {
1469
+ headId: "a1",
1470
+ messages: [
1471
+ {
1472
+ parentId: null,
1473
+ message: {
1474
+ id: "u1",
1475
+ role: "user" as const,
1476
+ parts: [{ type: "text" as const, text: "question" }],
1477
+ },
1478
+ },
1479
+ {
1480
+ parentId: "u1",
1481
+ message: {
1482
+ id: "a1",
1483
+ role: "assistant" as const,
1484
+ parts: [{ type: "text" as const, text: "first" }],
1485
+ },
1486
+ },
1487
+ ],
1488
+ };
1489
+
1490
+ const { result } = renderHook(() =>
1491
+ useAISDKRuntime(chat, { messageRepository }),
1492
+ );
1493
+
1494
+ await waitFor(() => {
1495
+ expect(
1496
+ result.current.thread.getState().messages.map((message) => message.id),
1497
+ ).toEqual(["live"]);
1498
+ });
1499
+ expect(chat.messages.map((message: { id: string }) => message.id)).toEqual([
1500
+ "live",
1501
+ ]);
1502
+ });
1503
+
1504
+ it("does not reseed when the repository object identity changes", async () => {
1505
+ const chat = createChatHelpers();
1506
+ const makeRepository = (
1507
+ text: string,
1508
+ ): MessageFormatRepository<UIMessage> => ({
1509
+ headId: "a1",
1510
+ messages: [
1511
+ {
1512
+ parentId: null,
1513
+ message: {
1514
+ id: "u1",
1515
+ role: "user" as const,
1516
+ parts: [{ type: "text" as const, text: "question" }],
1517
+ },
1518
+ },
1519
+ {
1520
+ parentId: "u1",
1521
+ message: {
1522
+ id: "a1",
1523
+ role: "assistant" as const,
1524
+ parts: [{ type: "text" as const, text }],
1525
+ },
1526
+ },
1527
+ ],
1528
+ });
1529
+
1530
+ const { result, rerender } = renderHook(
1531
+ ({ repository }) =>
1532
+ useAISDKRuntime(chat, { messageRepository: repository }),
1533
+ { initialProps: { repository: makeRepository("first") } },
1534
+ );
1535
+
1536
+ await waitFor(() => {
1537
+ expect(textOf(result.current.thread.getState().messages.at(-1))).toBe(
1538
+ "first",
1539
+ );
1540
+ });
1541
+ const setMessagesCalls = chat.setMessages.mock.calls.length;
1542
+
1543
+ rerender({ repository: makeRepository("second") });
1544
+
1545
+ expect(textOf(result.current.thread.getState().messages.at(-1))).toBe(
1546
+ "first",
1547
+ );
1548
+ expect(chat.setMessages.mock.calls.length).toBe(setMessagesCalls);
1549
+ });
1550
+
1551
+ it("calls adapters.suggestion after settle with messages and signal", async () => {
1552
+ const generate = vi.fn().mockResolvedValue([{ prompt: "next" }]);
1553
+ const chat = createChatHelpers([
1554
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1555
+ ]);
1556
+
1557
+ const { result, rerender } = renderHook(
1558
+ ({ status }) => {
1559
+ chat.status = status;
1560
+ return useAISDKRuntime(chat, {
1561
+ adapters: { suggestion: { generate } },
1562
+ });
1563
+ },
1564
+ { initialProps: { status: "submitted" as string } },
1565
+ );
1566
+
1567
+ expect(generate).not.toHaveBeenCalled();
1568
+ expect(result.current.thread.getState().suggestions).toEqual([]);
1569
+
1570
+ chat.messages = [
1571
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1572
+ {
1573
+ id: "a1",
1574
+ role: "assistant",
1575
+ parts: [{ type: "text", text: "hello" }],
1576
+ },
1577
+ ];
1578
+ rerender({ status: "ready" });
1579
+
1580
+ await waitFor(() => {
1581
+ expect(generate).toHaveBeenCalledTimes(1);
1582
+ });
1583
+
1584
+ expect(generate).toHaveBeenCalledWith({
1585
+ messages: expect.any(Array),
1586
+ signal: expect.any(AbortSignal),
1587
+ });
1588
+ const call = generate.mock.calls[0]![0];
1589
+ expect(call.messages.some((m: any) => m.role === "user")).toBe(true);
1590
+ expect(call.messages.some((m: any) => m.role === "assistant")).toBe(true);
1591
+
1592
+ await waitFor(() => {
1593
+ expect(result.current.thread.getState().suggestions).toEqual([
1594
+ { prompt: "next" },
1595
+ ]);
1596
+ });
1597
+ });
1598
+
1599
+ it("aborts and clears suggestions when a new run starts", async () => {
1600
+ let resolveGenerate!: (value: readonly { prompt: string }[]) => void;
1601
+ const generate = vi.fn().mockImplementation(
1602
+ () =>
1603
+ new Promise<readonly { prompt: string }[]>((resolve) => {
1604
+ resolveGenerate = resolve;
1605
+ }),
1606
+ );
1607
+ const chat = createChatHelpers([
1608
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1609
+ {
1610
+ id: "a1",
1611
+ role: "assistant",
1612
+ parts: [{ type: "text", text: "hello" }],
1613
+ },
1614
+ ]);
1615
+
1616
+ const { result, rerender } = renderHook(
1617
+ ({ status }) => {
1618
+ chat.status = status;
1619
+ return useAISDKRuntime(chat, {
1620
+ adapters: { suggestion: { generate } },
1621
+ });
1622
+ },
1623
+ { initialProps: { status: "submitted" as string } },
1624
+ );
1625
+
1626
+ rerender({ status: "ready" });
1627
+
1628
+ await waitFor(() => {
1629
+ expect(generate).toHaveBeenCalledTimes(1);
1630
+ });
1631
+ const firstSignal = generate.mock.calls[0]![0].signal as AbortSignal;
1632
+
1633
+ resolveGenerate([{ prompt: "stale" }]);
1634
+ await waitFor(() => {
1635
+ expect(result.current.thread.getState().suggestions).toEqual([
1636
+ { prompt: "stale" },
1637
+ ]);
1638
+ });
1639
+
1640
+ chat.messages = [
1641
+ ...chat.messages,
1642
+ { id: "u2", role: "user", parts: [{ type: "text", text: "again" }] },
1643
+ ];
1644
+ rerender({ status: "submitted" });
1645
+
1646
+ expect(firstSignal.aborted).toBe(true);
1647
+ await waitFor(() => {
1648
+ expect(result.current.thread.getState().suggestions).toEqual([]);
1649
+ });
1650
+ });
1651
+
1652
+ it("applies async generator yields progressively", async () => {
1653
+ const generate = vi.fn().mockImplementation(async function* () {
1654
+ yield [{ prompt: "a" }];
1655
+ yield [{ prompt: "a" }, { prompt: "b" }];
1656
+ });
1657
+ const chat = createChatHelpers([
1658
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1659
+ {
1660
+ id: "a1",
1661
+ role: "assistant",
1662
+ parts: [{ type: "text", text: "hello" }],
1663
+ },
1664
+ ]);
1665
+
1666
+ const { result, rerender } = renderHook(
1667
+ ({ status }) => {
1668
+ chat.status = status;
1669
+ return useAISDKRuntime(chat, {
1670
+ adapters: { suggestion: { generate } },
1671
+ });
1672
+ },
1673
+ { initialProps: { status: "submitted" as string } },
1674
+ );
1675
+
1676
+ rerender({ status: "ready" });
1677
+
1678
+ await waitFor(() => {
1679
+ expect(result.current.thread.getState().suggestions).toEqual([
1680
+ { prompt: "a" },
1681
+ { prompt: "b" },
1682
+ ]);
1683
+ });
1684
+ });
1685
+
1686
+ it("ignores static suggestions when adapters.suggestion is set", async () => {
1687
+ const generate = vi.fn().mockResolvedValue([{ prompt: "dynamic" }]);
1688
+ const chat = createChatHelpers([
1689
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1690
+ {
1691
+ id: "a1",
1692
+ role: "assistant",
1693
+ parts: [{ type: "text", text: "hello" }],
1694
+ },
1695
+ ]);
1696
+
1697
+ const { result, rerender } = renderHook(
1698
+ ({ status }) => {
1699
+ chat.status = status;
1700
+ return useAISDKRuntime(chat, {
1701
+ suggestions: [{ prompt: "static" }],
1702
+ adapters: { suggestion: { generate } },
1703
+ });
1704
+ },
1705
+ { initialProps: { status: "submitted" as string } },
1706
+ );
1707
+
1708
+ expect(result.current.thread.getState().suggestions).toEqual([]);
1709
+
1710
+ rerender({ status: "ready" });
1711
+
1712
+ await waitFor(() => {
1713
+ expect(result.current.thread.getState().suggestions).toEqual([
1714
+ { prompt: "dynamic" },
1715
+ ]);
1716
+ });
1717
+ expect(result.current.thread.getState().suggestions).not.toEqual([
1718
+ { prompt: "static" },
1719
+ ]);
1720
+ });
1721
+
1722
+ it("skips suggestion generation when the final assistant message requires action", async () => {
1723
+ const generate = vi.fn().mockResolvedValue([{ prompt: "next" }]);
1724
+ const chat = createChatHelpers([
1725
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1726
+ {
1727
+ id: "a1",
1728
+ role: "assistant",
1729
+ parts: [
1730
+ {
1731
+ type: "tool-weather",
1732
+ toolCallId: "tc-1",
1733
+ state: "input-available",
1734
+ input: { city: "NYC" },
1735
+ },
1736
+ ],
1737
+ },
1738
+ ]);
1739
+
1740
+ const { result, rerender } = renderHook(
1741
+ ({ status }) => {
1742
+ chat.status = status;
1743
+ return useAISDKRuntime(chat, {
1744
+ adapters: { suggestion: { generate } },
1745
+ });
1746
+ },
1747
+ { initialProps: { status: "submitted" as string } },
1748
+ );
1749
+
1750
+ rerender({ status: "ready" });
1751
+
1752
+ await waitFor(() => {
1753
+ const last = result.current.thread.getState().messages.at(-1);
1754
+ expect(last?.role).toBe("assistant");
1755
+ expect(last?.status?.type).toBe("requires-action");
1756
+ });
1757
+
1758
+ await act(async () => {
1759
+ await new Promise((resolve) => setTimeout(resolve, 20));
1760
+ });
1761
+ expect(generate).not.toHaveBeenCalled();
1762
+ expect(result.current.thread.getState().suggestions).toEqual([]);
1763
+ });
1764
+
1765
+ it("aborts in-flight generation and drops the stale result when the adapter is removed", async () => {
1766
+ let resolveGenerate!: (value: readonly { prompt: string }[]) => void;
1767
+ const generate = vi.fn().mockImplementation(
1768
+ () =>
1769
+ new Promise<readonly { prompt: string }[]>((resolve) => {
1770
+ resolveGenerate = resolve;
1771
+ }),
1772
+ );
1773
+ const chat = createChatHelpers([
1774
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1775
+ {
1776
+ id: "a1",
1777
+ role: "assistant",
1778
+ parts: [{ type: "text", text: "hello" }],
1779
+ },
1780
+ ]);
1781
+
1782
+ const { result, rerender } = renderHook(
1783
+ ({ status, withAdapter }) => {
1784
+ chat.status = status;
1785
+ return useAISDKRuntime(
1786
+ chat,
1787
+ withAdapter ? { adapters: { suggestion: { generate } } } : {},
1788
+ );
1789
+ },
1790
+ {
1791
+ initialProps: {
1792
+ status: "submitted" as string,
1793
+ withAdapter: true,
1794
+ },
1795
+ },
1796
+ );
1797
+
1798
+ rerender({ status: "ready", withAdapter: true });
1799
+
1800
+ await waitFor(() => {
1801
+ expect(generate).toHaveBeenCalledTimes(1);
1802
+ });
1803
+ const firstSignal = generate.mock.calls[0]![0].signal as AbortSignal;
1804
+
1805
+ rerender({ status: "ready", withAdapter: false });
1806
+
1807
+ expect(firstSignal.aborted).toBe(true);
1808
+
1809
+ resolveGenerate([{ prompt: "stale" }]);
1810
+ await act(async () => {
1811
+ await new Promise((resolve) => setTimeout(resolve, 20));
1812
+ });
1813
+ expect(result.current.thread.getState().suggestions).toEqual([]);
1814
+ });
1815
+
1816
+ it("regenerates suggestions when the same adapter is restored", async () => {
1817
+ const generate = vi
1818
+ .fn()
1819
+ .mockResolvedValueOnce([{ prompt: "first" }])
1820
+ .mockResolvedValueOnce([{ prompt: "second" }]);
1821
+ const chat = createChatHelpers([
1822
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1823
+ {
1824
+ id: "a1",
1825
+ role: "assistant",
1826
+ parts: [{ type: "text", text: "hello" }],
1827
+ },
1828
+ ]);
1829
+
1830
+ const { result, rerender } = renderHook(
1831
+ ({ status, withAdapter }) => {
1832
+ chat.status = status;
1833
+ return useAISDKRuntime(
1834
+ chat,
1835
+ withAdapter ? { adapters: { suggestion: { generate } } } : {},
1836
+ );
1837
+ },
1838
+ {
1839
+ initialProps: {
1840
+ status: "submitted" as string,
1841
+ withAdapter: true,
1842
+ },
1843
+ },
1844
+ );
1845
+
1846
+ rerender({ status: "ready", withAdapter: true });
1847
+ await waitFor(() => {
1848
+ expect(result.current.thread.getState().suggestions).toEqual([
1849
+ { prompt: "first" },
1850
+ ]);
1851
+ });
1852
+
1853
+ rerender({ status: "ready", withAdapter: false });
1854
+ expect(result.current.thread.getState().suggestions).toEqual([]);
1855
+
1856
+ rerender({ status: "ready", withAdapter: true });
1857
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(2));
1858
+ await waitFor(() => {
1859
+ expect(result.current.thread.getState().suggestions).toEqual([
1860
+ { prompt: "second" },
1861
+ ]);
1862
+ });
1863
+ });
1864
+
1865
+ it("preserves the suggestion adapter receiver", async () => {
1866
+ const adapter = {
1867
+ prompt: "bound",
1868
+ async generate() {
1869
+ return [{ prompt: this.prompt }];
1870
+ },
1871
+ };
1872
+ const chat = createChatHelpers([
1873
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1874
+ {
1875
+ id: "a1",
1876
+ role: "assistant",
1877
+ parts: [{ type: "text", text: "hello" }],
1878
+ },
1879
+ ]);
1880
+
1881
+ const { result, rerender } = renderHook(
1882
+ ({ status }) => {
1883
+ chat.status = status;
1884
+ return useAISDKRuntime(chat, {
1885
+ adapters: { suggestion: adapter },
1886
+ });
1887
+ },
1888
+ { initialProps: { status: "submitted" as string } },
1889
+ );
1890
+
1891
+ rerender({ status: "ready" });
1892
+ await waitFor(() => {
1893
+ expect(result.current.thread.getState().suggestions).toEqual([
1894
+ { prompt: "bound" },
1895
+ ]);
1896
+ });
1897
+ });
1898
+
1899
+ it("keeps a keyless inline suggestion adapter stable across rerenders", async () => {
1900
+ let resolveGenerate!: (value: readonly { prompt: string }[]) => void;
1901
+ const generate = vi.fn(
1902
+ ({ signal: _signal }: { signal?: AbortSignal }) =>
1903
+ new Promise<readonly { prompt: string }[]>((resolve) => {
1904
+ resolveGenerate = resolve;
1905
+ }),
1906
+ );
1907
+ const chat = createChatHelpers([
1908
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1909
+ {
1910
+ id: "a1",
1911
+ role: "assistant",
1912
+ parts: [{ type: "text", text: "hello" }],
1913
+ },
1914
+ ]);
1915
+
1916
+ const { result, rerender } = renderHook(
1917
+ ({ status }) => {
1918
+ chat.status = status;
1919
+ return useAISDKRuntime(chat, {
1920
+ adapters: {
1921
+ suggestion: {
1922
+ async generate({ signal }) {
1923
+ return generate({
1924
+ ...(signal !== undefined && { signal }),
1925
+ });
1926
+ },
1927
+ },
1928
+ },
1929
+ });
1930
+ },
1931
+ { initialProps: { status: "submitted" as string } },
1932
+ );
1933
+
1934
+ rerender({ status: "ready" });
1935
+ await waitFor(() => expect(generate).toHaveBeenCalledOnce());
1936
+ const signal = generate.mock.calls[0]![0].signal;
1937
+
1938
+ rerender({ status: "ready" });
1939
+ expect(generate).toHaveBeenCalledOnce();
1940
+ expect(signal?.aborted).toBe(false);
1941
+
1942
+ resolveGenerate([{ prompt: "next" }]);
1943
+ await waitFor(() => {
1944
+ expect(result.current.thread.getState().suggestions).toEqual([
1945
+ { prompt: "next" },
1946
+ ]);
1947
+ });
1948
+ await act(async () => {
1949
+ await new Promise((resolve) => setTimeout(resolve, 20));
1950
+ });
1951
+ expect(generate).toHaveBeenCalledOnce();
1952
+ });
1953
+
1954
+ it("does not generate for an inline adapter on mount or parent rerender", async () => {
1955
+ const complete = vi.fn().mockResolvedValue(["unexpected"]);
1956
+ const chat = createChatHelpers([
1957
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1958
+ {
1959
+ id: "a1",
1960
+ role: "assistant",
1961
+ parts: [{ type: "text", text: "hello" }],
1962
+ },
1963
+ ]);
1964
+
1965
+ const { rerender } = renderHook(() =>
1966
+ useAISDKRuntime(chat, {
1967
+ adapters: {
1968
+ suggestion: createSuggestionAdapter({ complete }),
1969
+ },
1970
+ }),
1971
+ );
1972
+
1973
+ rerender();
1974
+ await act(async () => {
1975
+ await new Promise((resolve) => setTimeout(resolve, 20));
1976
+ });
1977
+ expect(complete).not.toHaveBeenCalled();
1978
+ });
1979
+
1980
+ it("restarts an inline suggestion adapter when its key changes", async () => {
1981
+ const firstComplete = vi.fn().mockResolvedValue(["first"]);
1982
+ const secondComplete = vi.fn().mockResolvedValue(["second"]);
1983
+ const chat = createChatHelpers([
1984
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
1985
+ {
1986
+ id: "a1",
1987
+ role: "assistant",
1988
+ parts: [{ type: "text", text: "hello" }],
1989
+ },
1990
+ ]);
1991
+
1992
+ const { result, rerender } = renderHook(
1993
+ ({ status, adapterKey, complete }) => {
1994
+ chat.status = status;
1995
+ return useAISDKRuntime(chat, {
1996
+ adapters: {
1997
+ suggestion: createSuggestionAdapter({
1998
+ key: adapterKey,
1999
+ complete,
2000
+ }),
2001
+ },
2002
+ });
2003
+ },
2004
+ {
2005
+ initialProps: {
2006
+ status: "submitted" as string,
2007
+ adapterKey: "first",
2008
+ complete: firstComplete,
2009
+ },
2010
+ },
2011
+ );
2012
+
2013
+ rerender({
2014
+ status: "ready",
2015
+ adapterKey: "first",
2016
+ complete: firstComplete,
2017
+ });
2018
+ await waitFor(() => {
2019
+ expect(result.current.thread.getState().suggestions).toEqual([
2020
+ { prompt: "first" },
2021
+ ]);
2022
+ });
2023
+
2024
+ rerender({
2025
+ status: "ready",
2026
+ adapterKey: "first",
2027
+ complete: firstComplete,
2028
+ });
2029
+ expect(firstComplete).toHaveBeenCalledOnce();
2030
+
2031
+ rerender({
2032
+ status: "ready",
2033
+ adapterKey: "second",
2034
+ complete: secondComplete,
2035
+ });
2036
+ await waitFor(() => expect(secondComplete).toHaveBeenCalledOnce());
2037
+ await waitFor(() => {
2038
+ expect(result.current.thread.getState().suggestions).toEqual([
2039
+ { prompt: "second" },
2040
+ ]);
2041
+ });
2042
+ });
2043
+
2044
+ it("restarts suggestion generation across stable adapter replacements", async () => {
2045
+ let resolveFirst!: (value: readonly { prompt: string }[]) => void;
2046
+ const firstGenerate = vi.fn().mockImplementation(
2047
+ () =>
2048
+ new Promise<readonly { prompt: string }[]>((resolve) => {
2049
+ resolveFirst = resolve;
2050
+ }),
2051
+ );
2052
+ let resolveSecond!: (value: readonly { prompt: string }[]) => void;
2053
+ const secondGenerate = vi.fn().mockImplementation(
2054
+ () =>
2055
+ new Promise<readonly { prompt: string }[]>((resolve) => {
2056
+ resolveSecond = resolve;
2057
+ }),
2058
+ );
2059
+ const thirdGenerate = vi.fn().mockResolvedValue([{ prompt: "third" }]);
2060
+ const firstAdapter = { key: "first", generate: firstGenerate };
2061
+ const secondAdapter = { key: "second", generate: secondGenerate };
2062
+ const thirdAdapter = { key: "third", generate: thirdGenerate };
2063
+ const chat = createChatHelpers([
2064
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
2065
+ {
2066
+ id: "a1",
2067
+ role: "assistant",
2068
+ parts: [{ type: "text", text: "hello" }],
2069
+ },
2070
+ ]);
2071
+
2072
+ const { result, rerender } = renderHook(
2073
+ ({ status, adapter }) => {
2074
+ chat.status = status;
2075
+ return useAISDKRuntime(chat, {
2076
+ adapters: { suggestion: adapter },
2077
+ });
2078
+ },
2079
+ {
2080
+ initialProps: {
2081
+ status: "submitted" as string,
2082
+ adapter: firstAdapter,
2083
+ },
2084
+ },
2085
+ );
2086
+
2087
+ rerender({ status: "ready", adapter: firstAdapter });
2088
+ await waitFor(() => expect(firstGenerate).toHaveBeenCalledOnce());
2089
+ const firstSignal = firstGenerate.mock.calls[0]![0].signal as AbortSignal;
2090
+
2091
+ rerender({ status: "ready", adapter: secondAdapter });
2092
+
2093
+ expect(firstSignal.aborted).toBe(true);
2094
+ await waitFor(() => expect(secondGenerate).toHaveBeenCalledOnce());
2095
+ const secondSignal = secondGenerate.mock.calls[0]![0].signal as AbortSignal;
2096
+
2097
+ rerender({ status: "ready", adapter: thirdAdapter });
2098
+
2099
+ expect(secondSignal.aborted).toBe(true);
2100
+ await waitFor(() => expect(thirdGenerate).toHaveBeenCalledOnce());
2101
+ await waitFor(() => {
2102
+ expect(result.current.thread.getState().suggestions).toEqual([
2103
+ { prompt: "third" },
2104
+ ]);
2105
+ });
2106
+
2107
+ resolveFirst([{ prompt: "stale" }]);
2108
+ resolveSecond([{ prompt: "also stale" }]);
2109
+ await act(async () => {
2110
+ await new Promise((resolve) => setTimeout(resolve, 20));
2111
+ });
2112
+ expect(result.current.thread.getState().suggestions).toEqual([
2113
+ { prompt: "third" },
2114
+ ]);
2115
+ });
2116
+
2117
+ it("merges consecutive assistant messages into one turn by default", async () => {
2118
+ const chat = createChatHelpers([
2119
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
2120
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "first" }] },
2121
+ {
2122
+ id: "a2",
2123
+ role: "assistant",
2124
+ parts: [{ type: "text", text: "second" }],
2125
+ },
2126
+ ]);
2127
+
2128
+ const { result } = renderHook(() => useAISDKRuntime(chat));
2129
+
2130
+ await waitFor(() => {
2131
+ expect(result.current.thread.getState().messages.length).toBe(2);
2132
+ });
2133
+
2134
+ const messages = result.current.thread.getState().messages;
2135
+ expect(messages.map((m: any) => m.role)).toEqual(["user", "assistant"]);
2136
+ expect(textOf(messages[1])).toBe("first|second");
2137
+ });
2138
+
2139
+ it('keeps consecutive assistant messages separate when joinStrategy is "none"', async () => {
2140
+ const chat = createChatHelpers([
2141
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
2142
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "first" }] },
2143
+ {
2144
+ id: "a2",
2145
+ role: "assistant",
2146
+ parts: [{ type: "text", text: "second" }],
2147
+ },
2148
+ ]);
2149
+
2150
+ const { result } = renderHook(() =>
2151
+ useAISDKRuntime(chat, { joinStrategy: "none" }),
2152
+ );
2153
+
2154
+ await waitFor(() => {
2155
+ expect(result.current.thread.getState().messages.length).toBe(3);
2156
+ });
2157
+
2158
+ const messages = result.current.thread.getState().messages;
2159
+ expect(messages.map((m: any) => m.role)).toEqual([
2160
+ "user",
2161
+ "assistant",
2162
+ "assistant",
2163
+ ]);
2164
+ expect(messages.slice(1).map(textOf)).toEqual(["first", "second"]);
2165
+ });
2166
+
2167
+ it("exposes branded extras carrying the chat helpers and error", () => {
2168
+ const chat = createChatHelpers();
2169
+ chat.error = new Error("boom");
2170
+
2171
+ const { result } = renderHook(() => useAISDKRuntime(chat));
2172
+
2173
+ const extras = result.current.thread.getState().extras;
2174
+ expect(aiSDKExtras.is(extras)).toBe(true);
2175
+ expect(aiSDKExtras.tryGet(extras)).toMatchObject({
2176
+ chat,
2177
+ error: chat.error,
2178
+ });
2179
+ });
2180
+
2181
+ it("keeps the error name and code on the failed message status", () => {
2182
+ const chat = createChatHelpers([
2183
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
2184
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "" }] },
2185
+ ]);
2186
+ chat.error = Object.assign(new Error("rate limited"), {
2187
+ name: "AI_APICallError",
2188
+ code: "rate_limited",
2189
+ });
2190
+
2191
+ const { result } = renderHook(() => useAISDKRuntime(chat));
2192
+
2193
+ expect(
2194
+ result.current.thread.getState().messages.at(-1)?.status,
2195
+ ).toMatchObject({
2196
+ type: "incomplete",
2197
+ reason: "error",
2198
+ error: { code: "rate_limited", message: "rate limited" },
2199
+ });
2200
+ });
2201
+
2202
+ it("uses the error name as the code when the error carries none", () => {
2203
+ const chat = createChatHelpers([
2204
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
2205
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "" }] },
2206
+ ]);
2207
+ chat.error = Object.assign(new Error("upstream failed"), {
2208
+ name: "AI_APICallError",
2209
+ });
2210
+
2211
+ const { result } = renderHook(() => useAISDKRuntime(chat));
2212
+
2213
+ expect(
2214
+ result.current.thread.getState().messages.at(-1)?.status,
2215
+ ).toMatchObject({
2216
+ error: { code: "AI_APICallError", message: "upstream failed" },
2217
+ });
2218
+ });
2219
+
2220
+ it("keeps a cancelled output when the runtime remounts over its owner", async () => {
2221
+ let resolveStop!: () => void;
2222
+ const chat = createChatHelpers([
2223
+ {
2224
+ id: "assistant-1",
2225
+ role: "assistant",
2226
+ parts: [{ type: "text", text: "partial", state: "streaming" }],
2227
+ },
2228
+ ]);
2229
+ chat.status = "streaming";
2230
+ chat.stop = vi.fn(
2231
+ () =>
2232
+ new Promise<void>((resolve) => {
2233
+ resolveStop = resolve;
2234
+ }),
2235
+ );
2236
+ const owner = {};
2237
+
2238
+ const first = renderHook(() =>
2239
+ useAISDKRuntime(chat, { unstable_hostApprovalOwner: owner }),
2240
+ );
2241
+
2242
+ act(() => {
2243
+ first.result.current.thread.cancelRun();
2244
+ chat.status = "ready";
2245
+ first.rerender();
2246
+ });
2247
+
2248
+ await waitFor(() => {
2249
+ expect(
2250
+ first.result.current.thread.getState().messages.at(-1)?.status,
2251
+ ).toMatchObject({
2252
+ type: "incomplete",
2253
+ reason: "cancelled",
2254
+ });
2255
+ });
2256
+
2257
+ await act(async () => {
2258
+ resolveStop();
2259
+ await Promise.resolve();
2260
+ });
2261
+ first.unmount();
2262
+
2263
+ const second = renderHook(() =>
2264
+ useAISDKRuntime(chat, { unstable_hostApprovalOwner: owner }),
2265
+ );
2266
+
2267
+ expect(
2268
+ second.result.current.thread.getState().messages.at(-1)?.status,
2269
+ ).toMatchObject({
2270
+ type: "incomplete",
2271
+ reason: "cancelled",
2272
+ });
2273
+
2274
+ act(() => {
2275
+ chat.status = "streaming";
2276
+ second.rerender();
2277
+ });
2278
+ await waitFor(() => {
2279
+ expect(
2280
+ second.result.current.thread.getState().messages.at(-1)?.status,
2281
+ ).toMatchObject({ type: "running" });
2282
+ });
2283
+
2284
+ act(() => {
2285
+ chat.status = "ready";
2286
+ second.rerender();
2287
+ });
2288
+ await waitFor(() => {
2289
+ expect(
2290
+ second.result.current.thread.getState().messages.at(-1)?.status,
2291
+ ).toMatchObject({ type: "complete", reason: "unknown" });
2292
+ });
2293
+ second.unmount();
2294
+
2295
+ const third = renderHook(() =>
2296
+ useAISDKRuntime(chat, { unstable_hostApprovalOwner: owner }),
2297
+ );
2298
+ expect(
2299
+ third.result.current.thread.getState().messages.at(-1)?.status,
2300
+ ).toMatchObject({ type: "complete", reason: "unknown" });
2301
+ third.unmount();
2302
+ });
2303
+
2304
+ it("keeps a cancellation when the runtime unmounts before rendering it", async () => {
2305
+ let resolveStop!: () => void;
2306
+ const chat = createChatHelpers([
2307
+ {
2308
+ id: "assistant-1",
2309
+ role: "assistant",
2310
+ parts: [{ type: "text", text: "partial", state: "streaming" }],
2311
+ },
2312
+ ]);
2313
+ chat.status = "streaming";
2314
+ chat.stop = vi.fn(
2315
+ () =>
2316
+ new Promise<void>((resolve) => {
2317
+ resolveStop = resolve;
2318
+ }),
2319
+ );
2320
+ const owner = {};
2321
+ const first = renderHook(() =>
2322
+ useAISDKRuntime(chat, { unstable_hostApprovalOwner: owner }),
2323
+ );
2324
+
2325
+ act(() => {
2326
+ first.result.current.thread.cancelRun();
2327
+ first.unmount();
2328
+ });
2329
+ expect(chat.stop).toHaveBeenCalledOnce();
2330
+
2331
+ chat.status = "ready";
2332
+ await act(async () => {
2333
+ resolveStop();
2334
+ await Promise.resolve();
2335
+ });
2336
+
2337
+ const second = renderHook(() =>
2338
+ useAISDKRuntime(chat, { unstable_hostApprovalOwner: owner }),
2339
+ );
2340
+ expect(
2341
+ second.result.current.thread.getState().messages.at(-1)?.status,
2342
+ ).toMatchObject({ type: "incomplete", reason: "cancelled" });
2343
+ second.unmount();
2344
+ });
2345
+ });