@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,580 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "./__tests__/tap";
4
+ import {
5
+ bindExternalStoreMessage,
6
+ type ExternalStoreAdapter,
7
+ type ThreadAssistantMessage,
8
+ type ThreadMessage,
9
+ } from "@ulearning/ai-core";
10
+ import { beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ const mocks = vi.hoisted(() => ({
13
+ adapter: undefined as ExternalStoreAdapter | undefined,
14
+ persistToolApprovalResponses: vi.fn(),
15
+ threadMessages: [] as ThreadMessage[],
16
+ }));
17
+
18
+ vi.mock("@ulearning/ai-core/vue", async (importOriginal) => {
19
+ const original =
20
+ await importOriginal<typeof import("@ulearning/ai-core/vue")>();
21
+ return {
22
+ ...original,
23
+ useExternalStoreRuntime: vi.fn((adapter: ExternalStoreAdapter) => {
24
+ mocks.adapter = adapter;
25
+ return {
26
+ thread: {
27
+ getState: () => ({ messages: mocks.threadMessages }),
28
+ },
29
+ } as never;
30
+ }),
31
+ useRuntimeAdapters: vi.fn(() => ({})),
32
+ };
33
+ });
34
+
35
+ vi.mock("./useExternalHistory", async (importOriginal) => {
36
+ const original =
37
+ await importOriginal<typeof import("./useExternalHistory")>();
38
+ return {
39
+ ...original,
40
+ useExternalHistory: vi.fn(() => ({
41
+ isLoading: false,
42
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
43
+ markRunCancelled: vi.fn(),
44
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
45
+ persistToolApprovalResponses: mocks.persistToolApprovalResponses,
46
+ })),
47
+ };
48
+ });
49
+
50
+ import { useAISDKRuntime, type AISDKRuntimeAdapter } from "./useAISDKRuntime";
51
+ import { useExternalHistory } from "./useExternalHistory";
52
+
53
+ describe("useAISDKRuntime tool approvals", () => {
54
+ beforeEach(() => {
55
+ mocks.adapter = undefined;
56
+ mocks.threadMessages = [];
57
+ mocks.persistToolApprovalResponses.mockReset().mockResolvedValue(undefined);
58
+ vi.mocked(useExternalHistory)
59
+ .mockReset()
60
+ .mockImplementation(() => ({
61
+ isLoading: false,
62
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
63
+ markRunCancelled: vi.fn(),
64
+ persistToolInteractions: vi.fn().mockResolvedValue(undefined),
65
+ persistToolApprovalResponses: mocks.persistToolApprovalResponses,
66
+ }));
67
+ });
68
+
69
+ it("forwards the AI SDK approval promise to the external-store adapter", () => {
70
+ const approvalPromise = Promise.resolve();
71
+ const addToolApprovalResponse = vi.fn(() => approvalPromise);
72
+ const chat = {
73
+ id: "chat-1",
74
+ status: "ready",
75
+ error: undefined,
76
+ messages: [],
77
+ setMessages: vi.fn(),
78
+ sendMessage: vi.fn(),
79
+ regenerate: vi.fn(),
80
+ addToolOutput: vi.fn(),
81
+ addToolApprovalResponse,
82
+ stop: vi.fn(),
83
+ };
84
+
85
+ renderHook(() => useAISDKRuntime(chat as never));
86
+
87
+ const result = mocks.adapter?.onRespondToToolApproval?.({
88
+ approvalId: "approval-1",
89
+ approved: true,
90
+ });
91
+
92
+ expect(result).toBe(approvalPromise);
93
+ expect(addToolApprovalResponse).toHaveBeenCalledWith({
94
+ id: "approval-1",
95
+ approved: true,
96
+ options: { metadata: undefined },
97
+ });
98
+ });
99
+
100
+ const setupPendingApproval = (
101
+ onRespondToToolApproval: NonNullable<AISDKRuntimeAdapter>["onRespondToToolApproval"],
102
+ ) => {
103
+ const messages = [
104
+ {
105
+ id: "message-1",
106
+ role: "assistant",
107
+ parts: [
108
+ {
109
+ type: "tool-deploy",
110
+ toolCallId: "tool-1",
111
+ state: "approval-requested",
112
+ input: {},
113
+ approval: { id: "approval-1" },
114
+ },
115
+ ],
116
+ },
117
+ ];
118
+ const setMessages = vi.fn();
119
+ const sendMessage = vi.fn();
120
+ const addToolApprovalResponse = vi.fn();
121
+ const chat = {
122
+ id: "chat-1",
123
+ status: "ready",
124
+ error: undefined,
125
+ messages,
126
+ setMessages,
127
+ sendMessage,
128
+ regenerate: vi.fn(),
129
+ addToolOutput: vi.fn(),
130
+ addToolApprovalResponse,
131
+ stop: vi.fn(),
132
+ };
133
+
134
+ renderHook(() =>
135
+ useAISDKRuntime(chat as never, { onRespondToToolApproval }),
136
+ );
137
+
138
+ return {
139
+ respond: (response: {
140
+ approvalId: string;
141
+ approved: boolean;
142
+ optionId?: string;
143
+ text?: string;
144
+ reason?: string;
145
+ }) => mocks.adapter?.onRespondToToolApproval?.(response),
146
+ setMessages,
147
+ addToolApprovalResponse,
148
+ sendMessage,
149
+ messages,
150
+ getApproval: () =>
151
+ mocks.adapter?.messages?.[0]?.content.find(
152
+ (part) => part.type === "tool-call",
153
+ )?.approval,
154
+ getToolCall: () =>
155
+ mocks.adapter?.messages?.[0]?.content.find(
156
+ (part) => part.type === "tool-call",
157
+ ),
158
+ };
159
+ };
160
+
161
+ it("stores a host answer after the handler resolves without starting a run", async () => {
162
+ const onRespondToToolApproval = vi.fn(async () => {});
163
+ const {
164
+ respond,
165
+ setMessages,
166
+ addToolApprovalResponse,
167
+ sendMessage,
168
+ messages,
169
+ getApproval,
170
+ } = setupPendingApproval(onRespondToToolApproval);
171
+
172
+ const response = {
173
+ approvalId: "approval-1",
174
+ approved: true,
175
+ optionId: "allow-session",
176
+ text: "Only for this environment",
177
+ reason: "Approved by operator",
178
+ };
179
+ await act(async () => {
180
+ await respond(response);
181
+ });
182
+
183
+ expect(onRespondToToolApproval).toHaveBeenCalledWith(response, {
184
+ toolCallId: "tool-1",
185
+ toolName: "deploy",
186
+ respondViaAISDK: expect.any(Function),
187
+ });
188
+ expect(addToolApprovalResponse).not.toHaveBeenCalled();
189
+ expect(setMessages).not.toHaveBeenCalled();
190
+ expect(sendMessage).not.toHaveBeenCalled();
191
+ expect(mocks.persistToolApprovalResponses).toHaveBeenCalledExactlyOnceWith(
192
+ "message-1",
193
+ );
194
+ expect(messages[0]).not.toHaveProperty(
195
+ "metadata.__aui_toolApprovalResponses",
196
+ );
197
+ expect(getApproval()).toEqual({
198
+ id: "approval-1",
199
+ approved: true,
200
+ reason: "Approved by operator",
201
+ optionId: "allow-session",
202
+ text: "Only for this environment",
203
+ });
204
+ });
205
+
206
+ it("finds the pending approval past malformed parts", async () => {
207
+ const onRespondToToolApproval = vi.fn(async () => {});
208
+ const { respond, messages } = setupPendingApproval(onRespondToToolApproval);
209
+ (messages[0]!.parts as unknown[]).unshift(null, { text: "no type" });
210
+
211
+ await act(async () => {
212
+ await respond({ approvalId: "approval-1", approved: true });
213
+ });
214
+
215
+ expect(onRespondToToolApproval).toHaveBeenCalledOnce();
216
+ });
217
+
218
+ it("does not store a request the handler hands back through the AI SDK", async () => {
219
+ const { respond, addToolApprovalResponse } = setupPendingApproval(
220
+ (_response, { respondViaAISDK }) => respondViaAISDK(),
221
+ );
222
+
223
+ await act(async () => {
224
+ await respond({
225
+ approvalId: "approval-1",
226
+ approved: false,
227
+ optionId: "reject-once",
228
+ reason: "Not now",
229
+ });
230
+ });
231
+
232
+ expect(addToolApprovalResponse).toHaveBeenCalledWith({
233
+ id: "approval-1",
234
+ approved: false,
235
+ reason: "Not now",
236
+ options: { metadata: undefined },
237
+ });
238
+ expect(mocks.persistToolApprovalResponses).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it("does not store a host answer when the handler rejects", async () => {
242
+ const { respond, messages, getApproval } = setupPendingApproval(
243
+ async () => {
244
+ throw new Error("resume failed");
245
+ },
246
+ );
247
+
248
+ await expect(
249
+ act(async () => {
250
+ await respond({ approvalId: "approval-1", approved: true });
251
+ }),
252
+ ).rejects.toThrow("resume failed");
253
+
254
+ expect(mocks.persistToolApprovalResponses).not.toHaveBeenCalled();
255
+ expect(getApproval()).toEqual({ id: "approval-1" });
256
+ expect(messages[0]).not.toHaveProperty(
257
+ "metadata.__aui_toolApprovalResponses",
258
+ );
259
+ });
260
+
261
+ it("reopens a request when a handed-back AI SDK response fails inside the handler", async () => {
262
+ const { respond, addToolApprovalResponse, getApproval } =
263
+ setupPendingApproval(async (_response, { respondViaAISDK }) => {
264
+ await respondViaAISDK().catch(() => {});
265
+ });
266
+ addToolApprovalResponse.mockRejectedValueOnce(new Error("offline"));
267
+
268
+ await act(async () => {
269
+ await respond({ approvalId: "approval-1", approved: true });
270
+ });
271
+
272
+ expect(getApproval()).toEqual({ id: "approval-1" });
273
+ await act(async () => {
274
+ await respond({ approvalId: "approval-1", approved: true });
275
+ });
276
+ expect(addToolApprovalResponse).toHaveBeenCalledTimes(2);
277
+ });
278
+
279
+ it("keeps a host answer when the runtime switches chats and back", async () => {
280
+ const chatWith = (id: string, approvalId: string) => ({
281
+ id,
282
+ status: "ready",
283
+ error: undefined,
284
+ messages: [
285
+ {
286
+ id: `message-${id}`,
287
+ role: "assistant",
288
+ parts: [
289
+ {
290
+ type: "tool-deploy",
291
+ toolCallId: `tool-${id}`,
292
+ state: "approval-requested",
293
+ input: {},
294
+ approval: { id: approvalId },
295
+ },
296
+ ],
297
+ },
298
+ ],
299
+ setMessages: vi.fn(),
300
+ sendMessage: vi.fn(),
301
+ regenerate: vi.fn(),
302
+ addToolOutput: vi.fn(),
303
+ addToolApprovalResponse: vi.fn(),
304
+ stop: vi.fn(),
305
+ });
306
+ const chatA = chatWith("chat-a", "approval-a");
307
+ const chatB = chatWith("chat-b", "approval-b");
308
+ const onRespondToToolApproval = vi.fn(async () => {});
309
+ const { rerender } = renderHook(
310
+ ({ chat }: { chat: typeof chatA }) =>
311
+ useAISDKRuntime(chat as never, { onRespondToToolApproval }),
312
+ { initialProps: { chat: chatA } },
313
+ );
314
+ const respond = (approvalId: string) =>
315
+ act(async () => {
316
+ await mocks.adapter?.onRespondToToolApproval?.({
317
+ approvalId,
318
+ approved: true,
319
+ });
320
+ });
321
+ const getApproval = () =>
322
+ mocks.adapter?.messages?.[0]?.content.find(
323
+ (part) => part.type === "tool-call",
324
+ )?.approval;
325
+
326
+ await respond("approval-a");
327
+ rerender({ chat: chatB });
328
+ await respond("approval-b");
329
+ rerender({ chat: chatA });
330
+
331
+ expect(getApproval()).toEqual({ id: "approval-a", approved: true });
332
+ await expect(respond("approval-a")).rejects.toThrow(
333
+ "Tool approval approval-a is not waiting for a response.",
334
+ );
335
+ expect(onRespondToToolApproval).toHaveBeenCalledTimes(2);
336
+ });
337
+
338
+ it("restores a host answer and refuses a second response", async () => {
339
+ const onRespondToToolApproval = vi.fn(async () => {});
340
+ const { respond, getApproval, messages } = setupPendingApproval(
341
+ onRespondToToolApproval,
342
+ );
343
+ const historyCall = vi.mocked(useExternalHistory).mock.calls.at(-1)!;
344
+ const toolApprovalResponses = historyCall[9] as Map<
345
+ string,
346
+ { approvalId: string; approved: boolean; reason?: string }
347
+ >;
348
+ const onToolApprovalResponsesRestored = historyCall[10] as () => void;
349
+
350
+ await act(async () => {
351
+ toolApprovalResponses.set("approval-1", {
352
+ approvalId: "approval-1",
353
+ approved: true,
354
+ reason: "Approved by operator",
355
+ });
356
+ onToolApprovalResponsesRestored();
357
+ });
358
+
359
+ await waitFor(() =>
360
+ expect(getApproval()).toEqual({
361
+ id: "approval-1",
362
+ approved: true,
363
+ reason: "Approved by operator",
364
+ }),
365
+ );
366
+ await expect(
367
+ respond({ approvalId: "approval-1", approved: true }),
368
+ ).rejects.toThrow(
369
+ "Tool approval approval-1 is not waiting for a response.",
370
+ );
371
+ expect(onRespondToToolApproval).not.toHaveBeenCalled();
372
+ expect(messages[0]).not.toHaveProperty(
373
+ "metadata.__aui_toolApprovalResponses",
374
+ );
375
+ });
376
+
377
+ it("reopens a restored approval when history clears its response map", async () => {
378
+ const onRespondToToolApproval = vi.fn(async () => {});
379
+ const { respond, getApproval } = setupPendingApproval(
380
+ onRespondToToolApproval,
381
+ );
382
+ const historyCall = vi.mocked(useExternalHistory).mock.calls.at(-1)!;
383
+ const toolApprovalResponses = historyCall[9] as Map<
384
+ string,
385
+ { approvalId: string; approved: boolean }
386
+ >;
387
+ const onToolApprovalResponsesRestored = historyCall[10] as () => void;
388
+
389
+ await act(async () => {
390
+ toolApprovalResponses.set("approval-1", {
391
+ approvalId: "approval-1",
392
+ approved: true,
393
+ });
394
+ onToolApprovalResponsesRestored();
395
+ });
396
+ await waitFor(() =>
397
+ expect(getApproval()).toEqual({ id: "approval-1", approved: true }),
398
+ );
399
+
400
+ await act(async () => {
401
+ toolApprovalResponses.clear();
402
+ onToolApprovalResponsesRestored();
403
+ });
404
+ await waitFor(() => expect(getApproval()).toEqual({ id: "approval-1" }));
405
+
406
+ await act(async () => {
407
+ await respond({ approvalId: "approval-1", approved: true });
408
+ });
409
+ expect(onRespondToToolApproval).toHaveBeenCalledOnce();
410
+ });
411
+
412
+ it("clears deleted tool sidecars and its host approval reservation", async () => {
413
+ const onRespondToToolApproval = vi.fn(async () => {});
414
+ const { getApproval, getToolCall, messages, respond } =
415
+ setupPendingApproval(onRespondToToolApproval);
416
+ const threadMessage: ThreadAssistantMessage = {
417
+ id: "message-1",
418
+ role: "assistant",
419
+ content: [
420
+ {
421
+ type: "tool-call",
422
+ toolCallId: "tool-1",
423
+ toolName: "deploy",
424
+ args: {},
425
+ argsText: "{}",
426
+ result: undefined,
427
+ isError: false,
428
+ approval: { id: "approval-1" },
429
+ },
430
+ ],
431
+ createdAt: new Date(),
432
+ status: { type: "requires-action", reason: "tool-calls" },
433
+ metadata: {
434
+ unstable_state: null,
435
+ unstable_annotations: [],
436
+ unstable_data: [],
437
+ steps: [],
438
+ custom: {},
439
+ },
440
+ };
441
+ bindExternalStoreMessage(threadMessage, messages[0]!);
442
+ mocks.threadMessages = [threadMessage];
443
+
444
+ await act(async () => {
445
+ await respond({ approvalId: "approval-1", approved: true });
446
+ await mocks.adapter?.onAddToolResult?.({
447
+ messageId: "message-1",
448
+ toolCallId: "tool-1",
449
+ toolName: "deploy",
450
+ result: "deployed",
451
+ artifact: { preview: "deployment complete" },
452
+ isError: false,
453
+ });
454
+ await mocks.adapter?.unstable_onRecordToolInteraction?.({
455
+ messageId: "message-1",
456
+ toolCallId: "tool-1",
457
+ interaction: {
458
+ type: "action",
459
+ occurredAt: 1,
460
+ payload: { copied: true },
461
+ },
462
+ });
463
+ });
464
+ expect(getToolCall()).toMatchObject({
465
+ artifact: { preview: "deployment complete" },
466
+ unstable_interactions: {
467
+ entries: [{ type: "action", occurredAt: 1, payload: { copied: true } }],
468
+ },
469
+ approval: { id: "approval-1", approved: true },
470
+ });
471
+
472
+ await act(async () => {
473
+ await mocks.adapter?.onDelete?.("message-1");
474
+ });
475
+ expect(getToolCall()).not.toHaveProperty("artifact");
476
+ expect(getToolCall()).not.toHaveProperty("unstable_interactions");
477
+ expect(getApproval()).toEqual({ id: "approval-1" });
478
+
479
+ await act(async () => {
480
+ await respond({ approvalId: "approval-1", approved: true });
481
+ });
482
+ expect(onRespondToToolApproval).toHaveBeenCalledTimes(2);
483
+ });
484
+
485
+ it("answers through the host past an earlier approval-requested part without an approval", async () => {
486
+ const onRespondToToolApproval = vi.fn(async () => {});
487
+ const { respond, messages } = setupPendingApproval(onRespondToToolApproval);
488
+ messages.unshift({
489
+ id: "message-0",
490
+ role: "assistant",
491
+ parts: [
492
+ {
493
+ type: "tool-legacy",
494
+ toolCallId: "tool-0",
495
+ state: "approval-requested",
496
+ input: {},
497
+ } as (typeof messages)[number]["parts"][number],
498
+ ],
499
+ });
500
+
501
+ await act(async () => {
502
+ await respond({ approvalId: "approval-1", approved: true });
503
+ });
504
+
505
+ expect(onRespondToToolApproval).toHaveBeenCalledOnce();
506
+ expect(onRespondToToolApproval).toHaveBeenCalledWith(
507
+ expect.objectContaining({ approvalId: "approval-1" }),
508
+ expect.objectContaining({ toolCallId: "tool-1" }),
509
+ );
510
+ });
511
+
512
+ it("rejects an approval that is not waiting for a response", async () => {
513
+ const onRespondToToolApproval = vi.fn();
514
+ const { respond } = setupPendingApproval(onRespondToToolApproval);
515
+
516
+ await expect(
517
+ respond({ approvalId: "approval-2", approved: true }),
518
+ ).rejects.toThrow(
519
+ "Tool approval approval-2 is not waiting for a response.",
520
+ );
521
+ expect(onRespondToToolApproval).not.toHaveBeenCalled();
522
+ });
523
+
524
+ it("updates the rendered approval shape with the response channel", () => {
525
+ const onRespondToToolApproval = vi.fn();
526
+ const chat = {
527
+ id: "chat-1",
528
+ status: "ready",
529
+ error: undefined,
530
+ messages: [
531
+ {
532
+ id: "message-1",
533
+ role: "assistant",
534
+ parts: [
535
+ {
536
+ type: "tool-deploy",
537
+ toolCallId: "tool-1",
538
+ state: "approval-requested",
539
+ input: {},
540
+ approval: {
541
+ id: "approval-1",
542
+ display: "select",
543
+ options: [{ id: "allow-session", kind: "allow-once" }],
544
+ },
545
+ },
546
+ ],
547
+ },
548
+ ],
549
+ setMessages: vi.fn(),
550
+ sendMessage: vi.fn(),
551
+ regenerate: vi.fn(),
552
+ addToolOutput: vi.fn(),
553
+ addToolApprovalResponse: vi.fn(),
554
+ stop: vi.fn(),
555
+ };
556
+
557
+ const { rerender } = renderHook(
558
+ ({ useCustomHandler }: { useCustomHandler: boolean }) =>
559
+ useAISDKRuntime(chat as never, {
560
+ ...(useCustomHandler && { onRespondToToolApproval }),
561
+ }),
562
+ { initialProps: { useCustomHandler: false } },
563
+ );
564
+
565
+ const getApproval = () =>
566
+ mocks.adapter?.messages?.[0]?.content.find(
567
+ (part) => part.type === "tool-call",
568
+ )?.approval;
569
+
570
+ expect(getApproval()).toEqual({ id: "approval-1" });
571
+
572
+ rerender({ useCustomHandler: true });
573
+
574
+ expect(getApproval()).toEqual({
575
+ id: "approval-1",
576
+ display: "select",
577
+ options: [{ id: "allow-session", kind: "allow-once" }],
578
+ });
579
+ });
580
+ });
@@ -0,0 +1,122 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderHook } from "./__tests__/tap";
4
+ import type { ExternalStoreAdapter } from "@ulearning/ai-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("@ulearning/ai-core/vue", async (importOriginal) => {
12
+ const original =
13
+ await importOriginal<typeof import("@ulearning/ai-core/vue")>();
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
+ describe("useAISDKRuntime pending tool completion", () => {
39
+ const setup = (toolPart: Record<string, unknown>) => {
40
+ const messages = [
41
+ {
42
+ id: "m1",
43
+ role: "assistant",
44
+ parts: [toolPart],
45
+ },
46
+ ];
47
+ let updated: any[] | undefined;
48
+ const setMessages = vi.fn(
49
+ (updater: (m: typeof messages) => typeof messages) => {
50
+ updated = typeof updater === "function" ? updater(messages) : updater;
51
+ },
52
+ );
53
+ const chat = {
54
+ id: "chat-1",
55
+ status: "ready",
56
+ error: undefined,
57
+ messages,
58
+ setMessages,
59
+ sendMessage: vi.fn().mockResolvedValue(undefined),
60
+ regenerate: vi.fn(),
61
+ addToolOutput: vi.fn(),
62
+ addToolApprovalResponse: vi.fn(),
63
+ stop: vi.fn(),
64
+ };
65
+
66
+ renderHook(() => useAISDKRuntime(chat as never));
67
+
68
+ return {
69
+ send: () =>
70
+ mocks.adapter!.onNew!({
71
+ role: "user",
72
+ parentId: "m1",
73
+ sourceId: null,
74
+ runConfig: {},
75
+ content: [{ type: "text", text: "next" }],
76
+ attachments: [],
77
+ createdAt: new Date(0),
78
+ metadata: { custom: {} },
79
+ } as never),
80
+ getUpdated: () => updated,
81
+ };
82
+ };
83
+
84
+ it("preserves a denied tool call and its approval record on the next send", async () => {
85
+ const { send, getUpdated } = setup({
86
+ type: "tool-search",
87
+ toolCallId: "call_1",
88
+ state: "output-denied",
89
+ input: { query: "x" },
90
+ approval: { id: "appr_1", approved: false, reason: "too dangerous" },
91
+ });
92
+
93
+ await send();
94
+
95
+ const updated = getUpdated();
96
+ const part = updated?.at(-1)?.parts?.[0];
97
+ expect(part?.state).toBe("output-denied");
98
+ expect(part?.approval).toEqual({
99
+ id: "appr_1",
100
+ approved: false,
101
+ reason: "too dangerous",
102
+ });
103
+ expect(part?.errorText).toBeUndefined();
104
+ });
105
+
106
+ it("still cancels a genuinely pending tool call on the next send", async () => {
107
+ const { send, getUpdated } = setup({
108
+ type: "tool-search",
109
+ toolCallId: "call_1",
110
+ state: "input-available",
111
+ input: { query: "x" },
112
+ });
113
+
114
+ await send();
115
+
116
+ const part = getUpdated()?.at(-1)?.parts?.[0];
117
+ expect(part?.state).toBe("output-error");
118
+ expect(part?.errorText).toBe(
119
+ "User cancelled tool call by sending a new message.",
120
+ );
121
+ });
122
+ });