@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,302 @@
1
+ // @vitest-environment jsdom
2
+ import {
3
+ createApp,
4
+ defineComponent,
5
+ h,
6
+ KeepAlive,
7
+ nextTick,
8
+ shallowReactive,
9
+ shallowRef,
10
+ type Component,
11
+ type VNode,
12
+ } from "vue";
13
+ import type { SuggestionAdapter } from "@ulearning/ai-core";
14
+ import { afterEach, expect, it, vi } from "vitest";
15
+ import type { AISDKChatHelpers } from "../aiSDKExtras";
16
+ import { useAISDKRuntime } from "./useAISDKRuntime";
17
+
18
+ const cleanups: (() => void)[] = [];
19
+ afterEach(() => {
20
+ for (const cleanup of cleanups.splice(0).reverse()) cleanup();
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ function invokeNativeHmr(method: "createRecord" | "rerender", args: unknown[]) {
25
+ const runtime: unknown = Reflect.get(globalThis, "__VUE_HMR_RUNTIME__");
26
+ if (typeof runtime !== "object" || runtime === null)
27
+ throw new Error("The real native Vue HMR runtime is required");
28
+ const call: unknown = Reflect.get(runtime, method);
29
+ if (typeof call !== "function")
30
+ throw new Error(`Missing native HMR ${method}`);
31
+ return Reflect.apply(call, runtime, args);
32
+ }
33
+
34
+ let hostId = 0;
35
+ function createHost(setup: () => void, phase?: (value: string) => void) {
36
+ const revision = shallowRef(0);
37
+ const id = `ai-runtime-native-refresh-${++hostId}`;
38
+ const Before = Object.assign(
39
+ defineComponent({
40
+ setup() {
41
+ setup();
42
+ return {};
43
+ },
44
+ render() {
45
+ void revision.value;
46
+ phase?.("before");
47
+ return null;
48
+ },
49
+ }),
50
+ { __hmrId: id },
51
+ );
52
+ expect(invokeNativeHmr("createRecord", [id, Before])).toBe(true);
53
+ return {
54
+ Before,
55
+ rerender: async () => {
56
+ revision.value++;
57
+ await nextTick();
58
+ },
59
+ refresh: async () => {
60
+ invokeNativeHmr("rerender", [
61
+ id,
62
+ () => {
63
+ void revision.value;
64
+ phase?.("after");
65
+ return null;
66
+ },
67
+ ]);
68
+ await nextTick();
69
+ await Promise.resolve();
70
+ },
71
+ };
72
+ }
73
+ function render(component: Component) {
74
+ const container = document.createElement("div");
75
+ const app = createApp(component);
76
+ app.mount(container);
77
+ let active = true;
78
+ const unmount = () => {
79
+ if (active) {
80
+ active = false;
81
+ app.unmount();
82
+ }
83
+ };
84
+ cleanups.push(unmount);
85
+ return { unmount };
86
+ }
87
+ async function act(callback: () => void | Promise<void>) {
88
+ await callback();
89
+ await nextTick();
90
+ await Promise.resolve();
91
+ }
92
+ const waitFor = vi.waitFor;
93
+
94
+ type MutableChatFixture = {
95
+ -readonly [K in keyof AISDKChatHelpers]: AISDKChatHelpers[K];
96
+ };
97
+ const createChat = (): MutableChatFixture =>
98
+ shallowReactive<MutableChatFixture>({
99
+ id: "chat-1",
100
+ status: "submitted",
101
+ error: undefined,
102
+ messages: [
103
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
104
+ ],
105
+ setMessages: vi.fn(),
106
+ sendMessage: vi.fn(async () => {}),
107
+ regenerate: vi.fn(async () => {}),
108
+ addToolResult: vi.fn(async () => {}),
109
+ addToolOutput: vi.fn(async () => {}),
110
+ addToolApprovalResponse: vi.fn(async () => {}),
111
+ stop: vi.fn(async () => {}),
112
+ resumeStream: vi.fn(async () => {}),
113
+ clearError: vi.fn(),
114
+ });
115
+ const settleChat = (chat: ReturnType<typeof createChat>) => {
116
+ chat.status = "ready";
117
+ chat.messages = [
118
+ ...chat.messages,
119
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "hello" }] },
120
+ ];
121
+ };
122
+ const makeGeneration = () => {
123
+ let resolve!: (value: readonly { prompt: string }[]) => void;
124
+ const generate = vi.fn(
125
+ (_input: Parameters<SuggestionAdapter["generate"]>[0]) =>
126
+ new Promise<readonly { prompt: string }[]>((done) => {
127
+ resolve = done;
128
+ }),
129
+ );
130
+ return {
131
+ generate,
132
+ finish: (value: readonly { prompt: string }[]) => resolve(value),
133
+ };
134
+ };
135
+
136
+ it("keeps a message createdAt across Fast Refresh", async () => {
137
+ vi.useFakeTimers();
138
+ vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
139
+ const chat = createChat();
140
+ let runtime!: ReturnType<typeof useAISDKRuntime>;
141
+ let rendered = "";
142
+ const host = createHost(
143
+ () => {
144
+ runtime = useAISDKRuntime(() => chat);
145
+ },
146
+ (value) => {
147
+ rendered = value;
148
+ },
149
+ );
150
+ const view = render(host.Before);
151
+ const createdAt = runtime.thread.getState().messages[0]!.createdAt.getTime();
152
+ vi.setSystemTime(new Date("2026-01-01T00:01:00.000Z"));
153
+ await host.refresh();
154
+ expect(rendered).toBe("after");
155
+ expect(runtime.thread.getState().messages[0]!.createdAt.getTime()).toBe(
156
+ createdAt,
157
+ );
158
+ view.unmount();
159
+ });
160
+
161
+ it("keeps earlier createdAt values when streaming timing changes", async () => {
162
+ vi.useFakeTimers();
163
+ const firstSeen = new Date("2026-01-01T00:00:00.000Z");
164
+ const laterSeen = new Date("2026-01-01T00:01:00.000Z");
165
+ vi.setSystemTime(firstSeen);
166
+ const chat = createChat();
167
+ chat.status = "ready";
168
+ let runtime!: ReturnType<typeof useAISDKRuntime>;
169
+ const host = createHost(() => {
170
+ runtime = useAISDKRuntime(() => chat);
171
+ });
172
+ const view = render(host.Before);
173
+ const firstCreatedAt = runtime.thread
174
+ .getState()
175
+ .messages[0]!.createdAt.getTime();
176
+ vi.setSystemTime(laterSeen);
177
+ chat.status = "streaming";
178
+ chat.messages = [
179
+ ...chat.messages,
180
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "hello" }] },
181
+ ];
182
+ await host.rerender();
183
+ const laterCreatedAt = runtime.thread
184
+ .getState()
185
+ .messages[1]!.createdAt.getTime();
186
+ vi.setSystemTime(new Date("2026-01-01T00:02:00.000Z"));
187
+ chat.status = "ready";
188
+ await host.rerender();
189
+ expect(firstCreatedAt).toBe(firstSeen.getTime());
190
+ expect(laterCreatedAt).toBe(laterSeen.getTime());
191
+ expect(runtime.thread.getState().messages[1]!.metadata.timing).toBeDefined();
192
+ expect(runtime.thread.getState().messages[0]!.createdAt.getTime()).toBe(
193
+ firstCreatedAt,
194
+ );
195
+ expect(runtime.thread.getState().messages[1]!.createdAt.getTime()).toBe(
196
+ laterCreatedAt,
197
+ );
198
+ view.unmount();
199
+ });
200
+
201
+ it("stamps a message id afresh when the runtime switches chats", async () => {
202
+ vi.useFakeTimers();
203
+ vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
204
+ const chat = shallowRef(createChat());
205
+ let runtime!: ReturnType<typeof useAISDKRuntime>;
206
+ const host = createHost(() => {
207
+ runtime = useAISDKRuntime(() => chat.value);
208
+ });
209
+ const view = render(host.Before);
210
+ const firstChatCreatedAt = runtime.thread
211
+ .getState()
212
+ .messages[0]!.createdAt.getTime();
213
+ vi.setSystemTime(new Date("2026-01-01T00:01:00.000Z"));
214
+ chat.value = { ...createChat(), id: "chat-2" };
215
+ await host.rerender();
216
+ expect(runtime.thread.getState().messages[0]!.createdAt.getTime()).not.toBe(
217
+ firstChatCreatedAt,
218
+ );
219
+ view.unmount();
220
+ });
221
+
222
+ it("completes AI SDK suggestion generation after Fast Refresh", async () => {
223
+ const chat = createChat();
224
+ const { generate, finish } = makeGeneration();
225
+ let runtime!: ReturnType<typeof useAISDKRuntime>;
226
+ let rendered = "";
227
+ const host = createHost(
228
+ () => {
229
+ runtime = useAISDKRuntime(() => chat, {
230
+ adapters: { suggestion: { generate } },
231
+ });
232
+ },
233
+ (value) => {
234
+ rendered = value;
235
+ },
236
+ );
237
+ const view = render(host.Before);
238
+ settleChat(chat);
239
+ await host.rerender();
240
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));
241
+ const signal = generate.mock.calls[0]![0].signal!;
242
+ await host.refresh();
243
+ expect(rendered).toBe("after");
244
+ expect(signal.aborted).toBe(false);
245
+ await act(async () => finish([{ prompt: "next" }]));
246
+ await waitFor(() =>
247
+ expect(runtime.thread.getState().suggestions).toEqual([{ prompt: "next" }]),
248
+ );
249
+ view.unmount();
250
+ });
251
+
252
+ it("aborts pending AI SDK suggestion generation on unmount", async () => {
253
+ const chat = createChat();
254
+ const { generate, finish } = makeGeneration();
255
+ let runtime!: ReturnType<typeof useAISDKRuntime>;
256
+ const host = createHost(() => {
257
+ runtime = useAISDKRuntime(() => chat, {
258
+ adapters: { suggestion: { generate } },
259
+ });
260
+ });
261
+ const view = render(host.Before);
262
+ settleChat(chat);
263
+ await host.rerender();
264
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));
265
+ const signal = generate.mock.calls[0]![0].signal!;
266
+ view.unmount();
267
+ await act(async () => {});
268
+ expect(signal.aborted).toBe(true);
269
+ await act(async () => finish([{ prompt: "stale" }]));
270
+ expect(runtime.thread.getState().suggestions).toEqual([]);
271
+ expect(generate).toHaveBeenCalledTimes(1);
272
+ });
273
+
274
+ it("aborts pending AI SDK suggestion generation on native deactivation and drops late results", async () => {
275
+ const chat = createChat();
276
+ const { generate, finish } = makeGeneration();
277
+ let runtime!: ReturnType<typeof useAISDKRuntime>;
278
+ const host = createHost(() => {
279
+ runtime = useAISDKRuntime(() => chat, {
280
+ adapters: { suggestion: { generate } },
281
+ });
282
+ });
283
+ const mode = shallowRef<"visible" | "hidden">("visible");
284
+ const Parent = defineComponent(
285
+ () => (): VNode =>
286
+ h(KeepAlive, null, {
287
+ default: () => (mode.value === "visible" ? h(host.Before) : null),
288
+ }),
289
+ );
290
+ const view = render(Parent);
291
+ settleChat(chat);
292
+ await host.rerender();
293
+ await waitFor(() => expect(generate).toHaveBeenCalledTimes(1));
294
+ const signal = generate.mock.calls[0]![0].signal!;
295
+ mode.value = "hidden";
296
+ await act(async () => {});
297
+ expect(signal.aborted).toBe(true);
298
+ await act(async () => finish([{ prompt: "stale" }]));
299
+ expect(runtime.thread.getState().suggestions).toEqual([]);
300
+ expect(generate).toHaveBeenCalledTimes(1);
301
+ view.unmount();
302
+ });
@@ -0,0 +1,157 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook } from "./__tests__/tap";
4
+ import type { UIMessage } from "ai";
5
+ import type { ExternalStoreAdapter } from "@ulearning/ai-core";
6
+ import { describe, expect, it, vi } from "vitest";
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ adapter: undefined as ExternalStoreAdapter | undefined,
10
+ conversions: vi.fn<(messageId: string) => void>(),
11
+ }));
12
+
13
+ vi.mock("@ulearning/ai-core/vue", async (importOriginal) => {
14
+ const original =
15
+ await importOriginal<typeof import("@ulearning/ai-core/vue")>();
16
+ return {
17
+ ...original,
18
+ useExternalStoreRuntime: vi.fn((adapter: ExternalStoreAdapter) => {
19
+ mocks.adapter = adapter;
20
+ return {};
21
+ }),
22
+ useRuntimeAdapters: vi.fn(() => ({})),
23
+ };
24
+ });
25
+
26
+ vi.mock("../converters/convertMessage", async (importOriginal) => {
27
+ const original =
28
+ await importOriginal<typeof import("../converters/convertMessage")>();
29
+ return {
30
+ ...original,
31
+ convertAISDKMessage: (
32
+ ...args: Parameters<typeof original.convertAISDKMessage>
33
+ ) => {
34
+ mocks.conversions(args[0].id);
35
+ return original.convertAISDKMessage(...args);
36
+ },
37
+ };
38
+ });
39
+
40
+ import { useAISDKRuntime } from "./useAISDKRuntime";
41
+
42
+ const MESSAGE_COUNT = 1_000;
43
+ const messages: UIMessage[] = Array.from({ length: MESSAGE_COUNT }, (_, i) =>
44
+ i % 2 === 0
45
+ ? { id: `m${i}`, role: "user", parts: [{ type: "text", text: `q${i}` }] }
46
+ : {
47
+ id: `m${i}`,
48
+ role: "assistant",
49
+ parts: [
50
+ {
51
+ type: "tool-search",
52
+ toolCallId: `call-m${i}`,
53
+ state:
54
+ i === MESSAGE_COUNT - 1 ? "input-available" : "output-available",
55
+ input: { query: `q${i}` },
56
+ ...(i !== MESSAGE_COUNT - 1 && { output: { hits: i } }),
57
+ } as UIMessage["parts"][number],
58
+ { type: "text", text: `a${i}` },
59
+ ],
60
+ },
61
+ );
62
+ const LAST_ID = `m${MESSAGE_COUNT - 1}`;
63
+ const MIDDLE_ID = `m${MESSAGE_COUNT / 2 + 1}`;
64
+
65
+ const createChatHelpers = (status: string, error?: Error) => ({
66
+ id: "chat-1",
67
+ status,
68
+ error,
69
+ messages,
70
+ setMessages: vi.fn(),
71
+ sendMessage: vi.fn(),
72
+ regenerate: vi.fn(),
73
+ addToolOutput: vi.fn().mockResolvedValue(undefined),
74
+ addToolApprovalResponse: vi.fn(),
75
+ stop: vi.fn(),
76
+ });
77
+
78
+ type ChatState = { status: string; error?: Error };
79
+
80
+ const renderRuntime = () => {
81
+ const initialProps: ChatState = { status: "ready" };
82
+ const view = renderHook(
83
+ ({ status, error }: ChatState) =>
84
+ useAISDKRuntime(createChatHelpers(status, error) as never),
85
+ { initialProps },
86
+ );
87
+ expect(mocks.conversions).toHaveBeenCalledTimes(MESSAGE_COUNT);
88
+ return view;
89
+ };
90
+
91
+ type Step = [
92
+ action: (view: ReturnType<typeof renderRuntime>) => unknown,
93
+ converts: readonly string[],
94
+ ];
95
+
96
+ const interrupt = {
97
+ type: "interrupt" as const,
98
+ payload: { type: "human" as const, payload: { question: "which?" } },
99
+ };
100
+
101
+ const cases: Record<string, Step[]> = {
102
+ "a tool status moves between messages": [
103
+ [
104
+ () => mocks.adapter!.setToolStatuses!({ [`call-${LAST_ID}`]: interrupt }),
105
+ [LAST_ID],
106
+ ],
107
+ [
108
+ () =>
109
+ mocks.adapter!.setToolStatuses!({ [`call-${MIDDLE_ID}`]: interrupt }),
110
+ [MIDDLE_ID, LAST_ID],
111
+ ],
112
+ ],
113
+ "a tool result records an artifact": [
114
+ [
115
+ () =>
116
+ mocks.adapter!.onAddToolResult!({
117
+ messageId: MIDDLE_ID,
118
+ toolCallId: `call-${MIDDLE_ID}`,
119
+ toolName: "search",
120
+ result: { hits: 1 },
121
+ isError: false,
122
+ artifact: { chart: "bar" },
123
+ }),
124
+ [MIDDLE_ID],
125
+ ],
126
+ ],
127
+ "a run starts and ends": [
128
+ [({ rerender }) => rerender({ status: "streaming" }), [LAST_ID]],
129
+ // The run end clears the optimistic flag. This run produces no output, so
130
+ // its timing stays as it was and nothing else re-converts.
131
+ [({ rerender }) => rerender({ status: "ready" }), [LAST_ID]],
132
+ ],
133
+ "the chat errors": [
134
+ [
135
+ ({ rerender }) => rerender({ status: "error", error: new Error("boom") }),
136
+ [],
137
+ ],
138
+ ],
139
+ };
140
+
141
+ describe("useAISDKRuntime metadata cache", () => {
142
+ it.each(Object.entries(cases))(
143
+ "re-converts only the affected messages when %s",
144
+ async (_, steps) => {
145
+ const view = renderRuntime();
146
+ for (const [action, converts] of steps) {
147
+ mocks.conversions.mockClear();
148
+ await act(async () => {
149
+ await action(view);
150
+ });
151
+ expect(
152
+ mocks.conversions.mock.calls.map(([messageId]) => messageId),
153
+ ).toEqual(converts);
154
+ }
155
+ },
156
+ );
157
+ });