@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,707 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ act,
5
+ cleanup,
6
+ renderHook,
7
+ useTestChat,
8
+ waitFor,
9
+ } from "./__tests__/tap";
10
+ import { render } from "./__tests__/vue";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import { defineComponent, h, onMounted } from "vue";
13
+ import { Chat } from "@ai-sdk/vue";
14
+ import { useRuntimeResource } from "@ulearning/ai-core/vue/internal";
15
+ import { useChatSnapshot } from "./useChatSnapshot";
16
+ const screen = {
17
+ getByTestId(id: string) {
18
+ const element = document.querySelector(`[data-testid="${id}"]`);
19
+ if (!element) throw new Error(`Missing test element: ${id}`);
20
+ return element;
21
+ },
22
+ };
23
+ import {
24
+ lastAssistantMessageIsCompleteWithToolCalls,
25
+ type ChatTransport,
26
+ type UIMessage,
27
+ type UIMessageChunk,
28
+ } from "ai";
29
+ import type { AppendMessage, AssistantRuntime } from "@ulearning/ai-core";
30
+ import { AssistantRuntimeProvider } from "@ulearning/ai-core/vue";
31
+ import { useAuiState } from "@ulearning/ai-store";
32
+ import { useAISDKRuntime, type AISDKRuntimeAdapter } from "./useAISDKRuntime";
33
+ import { useAISDKError } from "../hooks";
34
+ import { toCreateMessage } from "../converters/toCreateMessage";
35
+
36
+ const textOf = (message: UIMessage | undefined) =>
37
+ message?.parts
38
+ .map((part) => (part.type === "text" ? part.text : ""))
39
+ .join("") ?? "";
40
+
41
+ const createTransport = () => {
42
+ const events: string[] = [];
43
+ const requests: {
44
+ prompt: string;
45
+ messages: string[];
46
+ rawMessages: UIMessage[];
47
+ signal: AbortSignal | undefined;
48
+ stream: (text: string, messageId?: string) => void;
49
+ emit: (...chunks: UIMessageChunk[]) => void;
50
+ finish: () => void;
51
+ }[] = [];
52
+ let held: Promise<void> | undefined;
53
+ let release = () => {};
54
+ const transport: ChatTransport<UIMessage> = {
55
+ async sendMessages({ messages, abortSignal }) {
56
+ const prompt = textOf(messages.findLast((m) => m.role === "user"));
57
+ events.push(`send ${prompt}`);
58
+ let controller!: ReadableStreamDefaultController<UIMessageChunk>;
59
+ const stream = new ReadableStream<UIMessageChunk>({
60
+ start: (c) => {
61
+ controller = c;
62
+ },
63
+ });
64
+ requests.push({
65
+ prompt,
66
+ messages: messages.map(textOf).filter(Boolean),
67
+ rawMessages: messages,
68
+ signal: abortSignal,
69
+ stream: (text, messageId) => {
70
+ controller.enqueue({
71
+ type: "start",
72
+ ...(messageId && { messageId }),
73
+ });
74
+ controller.enqueue({ type: "text-start", id: "t" });
75
+ controller.enqueue({ type: "text-delta", id: "t", delta: text });
76
+ },
77
+ emit: (...chunks) => {
78
+ for (const chunk of chunks) controller.enqueue(chunk);
79
+ },
80
+ finish: () => {
81
+ controller.enqueue({ type: "text-end", id: "t" });
82
+ controller.enqueue({ type: "finish" });
83
+ controller.close();
84
+ },
85
+ });
86
+ await held;
87
+ return stream;
88
+ },
89
+ reconnectToStream: async () => null,
90
+ };
91
+ return {
92
+ transport,
93
+ events,
94
+ requests,
95
+ hold: () => {
96
+ held = new Promise((resolve) => {
97
+ release = () => {
98
+ held = undefined;
99
+ resolve();
100
+ };
101
+ });
102
+ },
103
+ release: () => release(),
104
+ };
105
+ };
106
+
107
+ const QueuedPrompts = defineComponent({
108
+ setup() {
109
+ const prompts = useAuiState((s) =>
110
+ s.composer.queue.map((item) => item.prompt).join(","),
111
+ );
112
+ return () => h("output", { "data-testid": "queued" }, prompts.value);
113
+ },
114
+ });
115
+ const QueueError = defineComponent({
116
+ setup() {
117
+ const error = useAISDKError();
118
+ return () =>
119
+ h("output", { "data-testid": "queue-error" }, error.value?.message);
120
+ },
121
+ });
122
+
123
+ const setup = (
124
+ options: AISDKRuntimeAdapter = {
125
+ unstable_enableMessageQueue: true,
126
+ },
127
+ ) => {
128
+ const harness = createTransport();
129
+ const { result, rerender } = renderHook(
130
+ (runtimeOptions) => {
131
+ const chat = useTestChat({
132
+ transport: harness.transport,
133
+ onFinish: ({ message, isAbort }) => {
134
+ harness.events.push(
135
+ `finish ${isAbort ? "aborted" : textOf(message)}`,
136
+ );
137
+ },
138
+ });
139
+ return useAISDKRuntime(chat, runtimeOptions);
140
+ },
141
+ { initialProps: options },
142
+ );
143
+ const runtime = () => result.current as AssistantRuntime;
144
+ render(
145
+ h(
146
+ AssistantRuntimeProvider,
147
+ { runtime: runtime() },
148
+ { default: () => [h(QueuedPrompts), h(QueueError)] },
149
+ ),
150
+ );
151
+ const send = (text: string, steer?: boolean) =>
152
+ act(() => {
153
+ runtime().thread.composer.setText(text);
154
+ runtime().thread.composer.send(steer === undefined ? {} : { steer });
155
+ });
156
+ const queued = () => screen.getByTestId("queued").textContent;
157
+ const isRunning = () => runtime().thread.getState().isRunning;
158
+ return { ...harness, runtime, send, queued, isRunning, setOptions: rerender };
159
+ };
160
+
161
+ afterEach(() => {
162
+ cleanup();
163
+ });
164
+
165
+ describe("useAISDKRuntime unstable_enableMessageQueue", () => {
166
+ it("does not let a disabled queue's error hide a later direct-send error", async () => {
167
+ let fail = true;
168
+ function createMessage<UI_MESSAGE extends UIMessage>(
169
+ message: AppendMessage,
170
+ ) {
171
+ if (fail) throw new Error("queue conversion failed");
172
+ return toCreateMessage<UI_MESSAGE>(message);
173
+ }
174
+ const options = {
175
+ unstable_enableMessageQueue: true,
176
+ toCreateMessage: createMessage,
177
+ };
178
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
179
+ try {
180
+ const { send, requests, setOptions } = setup(options);
181
+ await send("first");
182
+ await waitFor(() =>
183
+ expect(screen.getByTestId("queue-error").textContent).toBe(
184
+ "queue conversion failed",
185
+ ),
186
+ );
187
+ await act(async () =>
188
+ setOptions({ ...options, unstable_enableMessageQueue: false }),
189
+ );
190
+ expect(screen.getByTestId("queue-error").textContent).toBe("");
191
+
192
+ fail = false;
193
+ await send("second");
194
+ await waitFor(() => expect(requests).toHaveLength(1));
195
+ act(() =>
196
+ requests[0]!.emit({
197
+ type: "error",
198
+ errorText: "direct request failed",
199
+ }),
200
+ );
201
+ await waitFor(() =>
202
+ expect(screen.getByTestId("queue-error").textContent).toBe(
203
+ "direct request failed",
204
+ ),
205
+ );
206
+ await act(async () => setOptions(options));
207
+ expect(screen.getByTestId("queue-error").textContent).toBe(
208
+ "direct request failed",
209
+ );
210
+ } finally {
211
+ logged.mockRestore();
212
+ }
213
+ });
214
+
215
+ it("does not mark an earlier answer failed when a queued prompt fails before append", async () => {
216
+ let fail = false;
217
+ function createMessage<UI_MESSAGE extends UIMessage>(
218
+ message: AppendMessage,
219
+ ) {
220
+ if (fail) throw new Error("conversion failed");
221
+ return toCreateMessage<UI_MESSAGE>(message);
222
+ }
223
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
224
+ try {
225
+ const { send, requests, runtime, isRunning, queued } = setup({
226
+ unstable_enableMessageQueue: true,
227
+ toCreateMessage: createMessage,
228
+ });
229
+ await send("first");
230
+ await waitFor(() => expect(requests).toHaveLength(1));
231
+ await act(async () => {
232
+ requests[0]!.stream("answer");
233
+ requests[0]!.finish();
234
+ });
235
+ await waitFor(() => expect(isRunning()).toBe(false));
236
+ const status = runtime().thread.getState().messages[1]!.status;
237
+ fail = true;
238
+ await send("second");
239
+ await waitFor(() =>
240
+ expect(screen.getByTestId("queue-error").textContent).toBe(
241
+ "conversion failed",
242
+ ),
243
+ );
244
+ expect(queued()).toBe("second");
245
+ expect(runtime().thread.getState().messages).toHaveLength(2);
246
+ expect(runtime().thread.getState().messages[1]!.status).toEqual(status);
247
+ } finally {
248
+ logged.mockRestore();
249
+ }
250
+ });
251
+
252
+ it("retains a failed prompt and exposes the error until an explicit retry", async () => {
253
+ const error = new Error("message conversion failed");
254
+ let fail = true;
255
+ function createMessage<UI_MESSAGE extends UIMessage>(
256
+ message: AppendMessage,
257
+ ) {
258
+ if (fail) throw error;
259
+ return toCreateMessage<UI_MESSAGE>(message);
260
+ }
261
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
262
+ try {
263
+ const { send, requests, queued, isRunning } = setup({
264
+ unstable_enableMessageQueue: true,
265
+ toCreateMessage: createMessage,
266
+ });
267
+ await send("first");
268
+ await waitFor(() =>
269
+ expect(screen.getByTestId("queue-error").textContent).toBe(
270
+ error.message,
271
+ ),
272
+ );
273
+ expect(queued()).toBe("first");
274
+ expect(requests).toHaveLength(0);
275
+
276
+ fail = false;
277
+ await send("second", false);
278
+ await waitFor(() => expect(requests).toHaveLength(1));
279
+ expect(requests[0]!.prompt).toBe("first");
280
+ expect(queued()).toBe("second");
281
+ expect(screen.getByTestId("queue-error").textContent).toBe("");
282
+ await act(async () => {
283
+ requests[0]!.stream("one");
284
+ requests[0]!.finish();
285
+ });
286
+ await waitFor(() => expect(requests).toHaveLength(2));
287
+ expect(requests[1]!.prompt).toBe("second");
288
+ await act(async () => {
289
+ requests[1]!.stream("two");
290
+ requests[1]!.finish();
291
+ });
292
+ await waitFor(() => expect(isRunning()).toBe(false));
293
+ expect(logged).toHaveBeenCalled();
294
+ } finally {
295
+ logged.mockRestore();
296
+ }
297
+ });
298
+
299
+ it("holds sends made during a run and sends them one request at a time", async () => {
300
+ const { requests, events, send, queued, isRunning, runtime } = setup();
301
+
302
+ await send("first");
303
+ await waitFor(() => expect(requests).toHaveLength(1));
304
+ expect(runtime().thread.getState().capabilities.queue).toBe(true);
305
+
306
+ await send("second", false);
307
+ await send("third", false);
308
+ await waitFor(() => expect(queued()).toBe("second,third"));
309
+ expect(requests).toHaveLength(1);
310
+
311
+ await act(async () => {
312
+ requests[0]!.stream("one");
313
+ requests[0]!.finish();
314
+ });
315
+ await waitFor(() => expect(requests).toHaveLength(2));
316
+ expect(requests[1]!.prompt).toBe("second");
317
+ await waitFor(() => expect(queued()).toBe("third"));
318
+
319
+ await act(async () => {
320
+ requests[1]!.stream("two");
321
+ requests[1]!.finish();
322
+ });
323
+ await waitFor(() => expect(requests).toHaveLength(3));
324
+ expect(requests[2]!.prompt).toBe("third");
325
+
326
+ await act(async () => {
327
+ requests[2]!.stream("three");
328
+ requests[2]!.finish();
329
+ });
330
+ await waitFor(() => expect(isRunning()).toBe(false));
331
+ expect(queued()).toBe("");
332
+ expect(events).toEqual([
333
+ "send first",
334
+ "finish one",
335
+ "send second",
336
+ "finish two",
337
+ "send third",
338
+ "finish three",
339
+ ]);
340
+ });
341
+
342
+ it("steering waits for the running request, then goes before follow-ups", async () => {
343
+ const { requests, events, hold, release, send, queued, isRunning } =
344
+ setup();
345
+
346
+ hold();
347
+ await send("first");
348
+ await waitFor(() => expect(requests).toHaveLength(1));
349
+ await waitFor(() => expect(isRunning()).toBe(true));
350
+
351
+ await send("third", false);
352
+ await send("second", true);
353
+ expect(requests[0]!.signal?.aborted).toBe(false);
354
+ expect(queued()).toBe("second,third");
355
+ expect(requests).toHaveLength(1);
356
+
357
+ await act(async () => {
358
+ release();
359
+ });
360
+ expect(requests).toHaveLength(1);
361
+ await act(async () => {
362
+ requests[0]!.stream("one");
363
+ requests[0]!.finish();
364
+ });
365
+ await waitFor(() => expect(requests).toHaveLength(2));
366
+ expect(requests[1]!.prompt).toBe("second");
367
+ expect(requests[1]!.messages).toEqual(["first", "one", "second"]);
368
+
369
+ await act(async () => {
370
+ requests[1]!.stream("two");
371
+ requests[1]!.finish();
372
+ });
373
+ await waitFor(() => expect(requests).toHaveLength(3));
374
+ expect(requests[2]!.prompt).toBe("third");
375
+ await act(async () => {
376
+ requests[2]!.stream("three");
377
+ requests[2]!.finish();
378
+ });
379
+ await waitFor(() => expect(isRunning()).toBe(false));
380
+ expect(events).toEqual([
381
+ "send first",
382
+ "finish one",
383
+ "send second",
384
+ "finish two",
385
+ "send third",
386
+ "finish three",
387
+ ]);
388
+ });
389
+
390
+ it("keeps queued messages when the run is cancelled and sends them on the next send", async () => {
391
+ const { requests, send, queued, isRunning, runtime } = setup();
392
+
393
+ await send("first");
394
+ await waitFor(() => expect(requests).toHaveLength(1));
395
+ await act(async () => {
396
+ requests[0]!.stream("partial");
397
+ });
398
+ await waitFor(() =>
399
+ expect(
400
+ runtime().thread.getState().messages.at(-1)?.content,
401
+ ).toContainEqual(
402
+ expect.objectContaining({ type: "text", text: "partial" }),
403
+ ),
404
+ );
405
+ await send("second", false);
406
+ await waitFor(() => expect(queued()).toBe("second"));
407
+
408
+ act(() => {
409
+ runtime().thread.cancelRun();
410
+ });
411
+ await waitFor(() => expect(isRunning()).toBe(false));
412
+ await act(async () => {
413
+ await new Promise((resolve) => setTimeout(resolve, 0));
414
+ });
415
+ expect(requests).toHaveLength(1);
416
+ expect(queued()).toBe("second");
417
+
418
+ await send("third", false);
419
+ await waitFor(() => expect(requests).toHaveLength(2));
420
+ expect(requests[1]!.prompt).toBe("second");
421
+ await waitFor(() => expect(queued()).toBe("third"));
422
+ });
423
+
424
+ const cancelWithQueued = async (harness: ReturnType<typeof setup>) => {
425
+ const { requests, send, queued, isRunning, runtime } = harness;
426
+ await send("first");
427
+ await waitFor(() => expect(requests).toHaveLength(1));
428
+ await act(async () => {
429
+ requests[0]!.stream("partial");
430
+ });
431
+ await waitFor(() =>
432
+ expect(
433
+ runtime().thread.getState().messages.at(-1)?.content,
434
+ ).toContainEqual(
435
+ expect.objectContaining({ type: "text", text: "partial" }),
436
+ ),
437
+ );
438
+ await send("second", false);
439
+ await waitFor(() => expect(queued()).toBe("second"));
440
+ act(() => {
441
+ runtime().thread.cancelRun();
442
+ });
443
+ await waitFor(() => expect(isRunning()).toBe(false));
444
+ expect(queued()).toBe("second");
445
+ };
446
+
447
+ it("drops queued messages when a message is edited", async () => {
448
+ const harness = setup();
449
+ const { requests, queued, isRunning, runtime } = harness;
450
+ await cancelWithQueued(harness);
451
+
452
+ act(() => {
453
+ const composer = runtime().thread.getMessageByIndex(0).composer;
454
+ composer.beginEdit();
455
+ composer.setText("edited");
456
+ composer.send();
457
+ });
458
+ await waitFor(() => expect(requests).toHaveLength(2));
459
+ expect(requests[1]!.prompt).toBe("edited");
460
+ expect(queued()).toBe("");
461
+
462
+ await act(async () => {
463
+ requests[1]!.stream("two");
464
+ requests[1]!.finish();
465
+ });
466
+ await waitFor(() => expect(isRunning()).toBe(false));
467
+ await act(async () => {
468
+ await new Promise((resolve) => setTimeout(resolve, 0));
469
+ });
470
+ expect(requests).toHaveLength(2);
471
+ });
472
+
473
+ it("drops queued messages when a response is reloaded", async () => {
474
+ const harness = setup();
475
+ const { requests, queued, isRunning, runtime } = harness;
476
+ await cancelWithQueued(harness);
477
+
478
+ act(() => {
479
+ runtime().thread.getMessageByIndex(1).reload();
480
+ });
481
+ await waitFor(() => expect(requests).toHaveLength(2));
482
+ expect(requests[1]!.prompt).toBe("first");
483
+ expect(queued()).toBe("");
484
+
485
+ await act(async () => {
486
+ requests[1]!.stream("again");
487
+ requests[1]!.finish();
488
+ });
489
+ await waitFor(() => expect(isRunning()).toBe(false));
490
+ await act(async () => {
491
+ await new Promise((resolve) => setTimeout(resolve, 0));
492
+ });
493
+ expect(requests).toHaveLength(2);
494
+ });
495
+
496
+ it("keeps a cancelled steer queued alongside the restored unanswered draft", async () => {
497
+ const { requests, hold, release, send, queued, isRunning, runtime } =
498
+ setup();
499
+
500
+ hold();
501
+ await send("first");
502
+ await waitFor(() => expect(requests).toHaveLength(1));
503
+ await waitFor(() => expect(isRunning()).toBe(true));
504
+ await send("second", true);
505
+
506
+ act(() => {
507
+ runtime().thread.cancelRun();
508
+ });
509
+ await act(async () => {
510
+ release();
511
+ });
512
+ await waitFor(() => expect(queued()).toBe("second"));
513
+ expect(requests).toHaveLength(1);
514
+ await waitFor(() => expect(isRunning()).toBe(false));
515
+ expect(runtime().thread.getState().messages).toEqual([]);
516
+ expect(runtime().thread.composer.getState().text).toBe("first");
517
+ expect(screen.getByTestId("queue-error").textContent).toBe("");
518
+
519
+ await send("third");
520
+ await waitFor(() => expect(requests).toHaveLength(2));
521
+ expect(requests[1]!.prompt).toBe("second");
522
+ expect(queued()).toBe("third");
523
+ await act(async () => {
524
+ requests[1]!.stream("two");
525
+ requests[1]!.finish();
526
+ });
527
+ await waitFor(() => expect(requests).toHaveLength(3));
528
+ expect(requests[2]!.prompt).toBe("third");
529
+ expect(queued()).toBe("");
530
+ });
531
+
532
+ it("lets a client tool finish and sends its result before the steered message", async () => {
533
+ const harness = createTransport();
534
+ let shouldContinue = true;
535
+ let finishTool!: (result: string) => void;
536
+ const execute = vi.fn(
537
+ (_args: unknown, _context: { abortSignal: AbortSignal }) =>
538
+ new Promise<string>((resolve) => {
539
+ finishTool = resolve;
540
+ }),
541
+ );
542
+ const { result } = renderHook(() =>
543
+ useAISDKRuntime(
544
+ useTestChat({
545
+ transport: harness.transport,
546
+ sendAutomaticallyWhen: (options) => {
547
+ if (!shouldContinue) return false;
548
+ const complete =
549
+ lastAssistantMessageIsCompleteWithToolCalls(options);
550
+ if (complete) shouldContinue = false;
551
+ return complete;
552
+ },
553
+ }),
554
+ { unstable_enableMessageQueue: true },
555
+ ),
556
+ );
557
+ result.current.registerModelContextProvider({
558
+ getModelContext: () => ({
559
+ tools: {
560
+ weather: {
561
+ parameters: { type: "object", properties: {} },
562
+ execute,
563
+ },
564
+ },
565
+ }),
566
+ });
567
+
568
+ act(() => {
569
+ result.current.thread.composer.setText("first");
570
+ result.current.thread.composer.send();
571
+ });
572
+ await waitFor(() => expect(harness.requests).toHaveLength(1));
573
+ await act(async () => {
574
+ harness.requests[0]!.emit(
575
+ { type: "start" },
576
+ {
577
+ type: "tool-input-available",
578
+ toolCallId: "tool-1",
579
+ toolName: "weather",
580
+ input: {},
581
+ },
582
+ );
583
+ harness.requests[0]!.finish();
584
+ });
585
+ await waitFor(() => expect(execute).toHaveBeenCalledOnce());
586
+ expect(result.current.thread.getState().isRunning).toBe(true);
587
+
588
+ act(() => {
589
+ result.current.thread.composer.setText("second");
590
+ result.current.thread.composer.send({ steer: true });
591
+ });
592
+ expect(harness.requests).toHaveLength(1);
593
+ expect(execute.mock.calls[0]![1].abortSignal.aborted).toBe(false);
594
+
595
+ await act(async () => {
596
+ finishTool("sunny");
597
+ });
598
+ await waitFor(() => expect(harness.requests).toHaveLength(2));
599
+ expect(harness.requests[1]!.prompt).toBe("first");
600
+ expect(harness.requests[1]!.messages).not.toContain("second");
601
+ expect(JSON.stringify(harness.requests[1]!.rawMessages)).toContain("sunny");
602
+ expect(JSON.stringify(harness.requests[1]!.rawMessages)).toContain(
603
+ "tool-1",
604
+ );
605
+ expect(execute.mock.calls[0]![1].abortSignal.aborted).toBe(false);
606
+
607
+ await act(async () => {
608
+ harness.requests[1]!.stream("continued", "continuation");
609
+ harness.requests[1]!.finish();
610
+ });
611
+ await waitFor(() => expect(harness.requests).toHaveLength(3));
612
+ expect(harness.requests[2]!.prompt).toBe("second");
613
+ expect(execute.mock.calls[0]![1].abortSignal.aborted).toBe(false);
614
+ await act(async () => {
615
+ harness.requests[2]!.stream("two");
616
+ harness.requests[2]!.finish();
617
+ });
618
+ await waitFor(() =>
619
+ expect(result.current.thread.getState().isRunning).toBe(false),
620
+ );
621
+ });
622
+
623
+ it("holds queued messages while sending is disabled", async () => {
624
+ const harness = createTransport();
625
+ const { result, rerender } = renderHook(
626
+ ({ isSendDisabled }: { isSendDisabled: boolean }) =>
627
+ useAISDKRuntime(useTestChat({ transport: harness.transport }), {
628
+ unstable_enableMessageQueue: true,
629
+ isSendDisabled,
630
+ }),
631
+ { initialProps: { isSendDisabled: false } },
632
+ );
633
+
634
+ act(() => {
635
+ result.current.thread.append("first");
636
+ });
637
+ await waitFor(() => expect(harness.requests).toHaveLength(1));
638
+ act(() => {
639
+ result.current.thread.composer.setText("second");
640
+ result.current.thread.composer.send({ steer: false });
641
+ });
642
+ rerender({ isSendDisabled: true });
643
+
644
+ await act(async () => {
645
+ harness.requests[0]!.stream("one");
646
+ harness.requests[0]!.finish();
647
+ });
648
+ await waitFor(() =>
649
+ expect(result.current.thread.getState().isRunning).toBe(false),
650
+ );
651
+ expect(harness.requests).toHaveLength(1);
652
+
653
+ rerender({ isSendDisabled: false });
654
+ await waitFor(() => expect(harness.requests).toHaveLength(2));
655
+ expect(harness.requests[1]!.prompt).toBe("second");
656
+ });
657
+
658
+ it("holds a send made before its first effect when it mounts over a running chat", async () => {
659
+ const harness = createTransport();
660
+ const chat = new Chat({ transport: harness.transport });
661
+ void chat.sendMessage({ text: "first" });
662
+ await waitFor(() => expect(harness.requests).toHaveLength(1));
663
+ expect(["submitted", "streaming"]).toContain(chat.status);
664
+
665
+ const SendOnMount = defineComponent<{ runtime: AssistantRuntime }>(
666
+ (props) => {
667
+ onMounted(() => {
668
+ props.runtime.thread.composer.setText("second");
669
+ props.runtime.thread.composer.send({ steer: false });
670
+ });
671
+ return () => null;
672
+ },
673
+ { props: ["runtime"] },
674
+ );
675
+ const Host = defineComponent({
676
+ setup() {
677
+ const runtime = useRuntimeResource(() =>
678
+ useAISDKRuntime(useChatSnapshot(chat, undefined), {
679
+ unstable_enableMessageQueue: true,
680
+ }),
681
+ );
682
+ return () => h(SendOnMount, { runtime });
683
+ },
684
+ });
685
+ const root = render(h(Host));
686
+ try {
687
+ await new Promise((resolve) => setTimeout(resolve, 50));
688
+ expect(harness.requests).toHaveLength(1);
689
+
690
+ harness.requests[0]!.stream("one");
691
+ harness.requests[0]!.finish();
692
+ await waitFor(() => expect(harness.requests).toHaveLength(2));
693
+ expect(harness.requests[1]!.prompt).toBe("second");
694
+ } finally {
695
+ root.unmount();
696
+ }
697
+ });
698
+
699
+ it("exposes no queue when the option is omitted", async () => {
700
+ const { requests, runtime, send, queued } = setup({});
701
+
702
+ expect(runtime().thread.getState().capabilities.queue).toBe(false);
703
+ await send("first");
704
+ await waitFor(() => expect(requests).toHaveLength(1));
705
+ expect(queued()).toBe("");
706
+ });
707
+ });