@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,811 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
5
+
6
+ import { flushTapSync } from "@ulearning/ai-tap";
7
+ import { AuiConfig, createAssistantClient } from "@ulearning/ai-store/client";
8
+ import { AISDKThreads } from "./AISDKThreads";
9
+ import { AssistantChatTransport } from "../transport/AssistantChatTransport";
10
+ import {
11
+ createCancellableTransport,
12
+ createControlledTransport,
13
+ } from "./__tests__/controlled-transport";
14
+
15
+ const waitFor = (assertion: () => void) =>
16
+ vi.waitFor(assertion, { interval: 10 });
17
+
18
+ const textReply = (text: string) =>
19
+ [
20
+ { type: "start" },
21
+ { type: "text-start", id: "t1" },
22
+ { type: "text-delta", id: "t1", delta: text },
23
+ { type: "text-end", id: "t1" },
24
+ { type: "finish" },
25
+ ] as never[];
26
+
27
+ const threadText = (aui: ReturnType<typeof createAssistantClient>) =>
28
+ aui
29
+ .getClient()
30
+ .thread.getState()
31
+ .messages.map((m) =>
32
+ m.content.map((part) => (part.type === "text" ? part.text : "")).join(""),
33
+ );
34
+
35
+ const createLiveHandle = (
36
+ getOptions: () => Parameters<typeof AISDKThreads>[0],
37
+ ) => {
38
+ const listeners = new Set<() => void>();
39
+ const handle = createAssistantClient({
40
+ getConfig: () => AuiConfig({ threads: AISDKThreads(getOptions()) }),
41
+ subscribe: (listener) => {
42
+ listeners.add(listener);
43
+ return () => listeners.delete(listener);
44
+ },
45
+ });
46
+ handle.subscribe(() => {});
47
+ return {
48
+ handle,
49
+ rerender: () =>
50
+ flushTapSync(() => listeners.forEach((listener) => listener())),
51
+ };
52
+ };
53
+
54
+ const chatCallbacks = () => ({
55
+ onToolCall: vi.fn(),
56
+ onData: vi.fn(),
57
+ onFinish: vi.fn(),
58
+ onError: vi.fn(),
59
+ sendAutomaticallyWhen: vi.fn(() => false),
60
+ });
61
+
62
+ describe("AISDKThreads", () => {
63
+ const startTool = async (
64
+ execute: (
65
+ args: unknown,
66
+ context: {
67
+ abortSignal: AbortSignal;
68
+ human: (payload: unknown) => Promise<unknown>;
69
+ },
70
+ ) => Promise<unknown>,
71
+ ) => {
72
+ const { transport, emit, close } = createControlledTransport();
73
+ const sendMessages = vi.spyOn(transport, "sendMessages");
74
+ const handle = createAssistantClient(
75
+ AuiConfig({
76
+ threads: AISDKThreads({
77
+ transport,
78
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
79
+ }),
80
+ }),
81
+ );
82
+ handle.subscribe(() => {});
83
+ const aui = handle.getClient();
84
+ flushTapSync(() =>
85
+ aui.modelContext.register({
86
+ getModelContext: () => ({
87
+ tools: {
88
+ lookup: {
89
+ parameters: { type: "object", properties: {} },
90
+ execute,
91
+ },
92
+ },
93
+ }),
94
+ }),
95
+ );
96
+ flushTapSync(() => aui.composer.setText("look up"));
97
+ flushTapSync(() => aui.composer.send());
98
+ await waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(1));
99
+ emit(
100
+ { type: "start", messageId: "assistant-1" },
101
+ {
102
+ type: "tool-input-available",
103
+ toolCallId: "call-1",
104
+ toolName: "lookup",
105
+ input: {},
106
+ },
107
+ { type: "finish" },
108
+ );
109
+ close();
110
+ return { handle, aui, sendMessages };
111
+ };
112
+
113
+ it.each([
114
+ ["pending", (_signal: AbortSignal) => new Promise<string>(() => {})],
115
+ [
116
+ "rejecting with AbortError",
117
+ (signal: AbortSignal) =>
118
+ new Promise<string>((_resolve, reject) => {
119
+ signal.addEventListener(
120
+ "abort",
121
+ () => reject(new DOMException("aborted", "AbortError")),
122
+ { once: true },
123
+ );
124
+ }),
125
+ ],
126
+ ])(
127
+ "keeps a stopped frontend tool result from starting another request when $0",
128
+ async (_settlement, createExecution) => {
129
+ let signal: AbortSignal | undefined;
130
+ const execute = vi.fn(
131
+ (_args: unknown, context: { abortSignal: AbortSignal }) => {
132
+ signal = context.abortSignal;
133
+ return createExecution(signal);
134
+ },
135
+ );
136
+ const { handle, aui, sendMessages } = await startTool(execute);
137
+ try {
138
+ await waitFor(() => expect(execute).toHaveBeenCalledOnce());
139
+ await waitFor(() => expect(aui.thread.getState().isRunning).toBe(true));
140
+
141
+ flushTapSync(() => aui.thread.cancelRun());
142
+
143
+ await waitFor(() => expect(signal?.aborted).toBe(true));
144
+ await waitFor(() => {
145
+ const toolCall = aui.thread
146
+ .getState()
147
+ .messages.filter((message) => message.role === "assistant")
148
+ .flatMap((message) => message.content)
149
+ .find(
150
+ (part) =>
151
+ part.type === "tool-call" && part.toolCallId === "call-1",
152
+ );
153
+ expect(toolCall?.type === "tool-call" && toolCall.isError).toBe(true);
154
+ });
155
+ await new Promise((resolve) => setTimeout(resolve, 20));
156
+ expect(sendMessages).toHaveBeenCalledTimes(1);
157
+ } finally {
158
+ handle.destroy();
159
+ }
160
+ },
161
+ );
162
+
163
+ it("still auto-sends after a successful frontend tool result", async () => {
164
+ const execute = vi.fn(async () => "done");
165
+ const { handle, sendMessages } = await startTool(execute);
166
+ try {
167
+ await waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(2));
168
+ } finally {
169
+ handle.destroy();
170
+ }
171
+ });
172
+
173
+ it("emits the selected thread id when its composer sends", async () => {
174
+ const { transport } = createControlledTransport();
175
+ const handle = createAssistantClient(
176
+ AuiConfig({ threads: AISDKThreads({ transport }) }),
177
+ );
178
+ try {
179
+ handle.subscribe(() => {});
180
+ const aui = handle.getClient();
181
+ const sent = vi.fn();
182
+ aui.on({ scope: "thread", event: "composer.send" }, sent);
183
+
184
+ flushTapSync(() => aui.composer.setText("hello"));
185
+ flushTapSync(() => aui.composer.send());
186
+
187
+ await waitFor(() => {
188
+ expect(sent).toHaveBeenCalledWith(
189
+ expect.objectContaining({
190
+ threadId: aui.threads.getState().mainThreadId,
191
+ }),
192
+ );
193
+ });
194
+ } finally {
195
+ handle.destroy();
196
+ }
197
+ });
198
+
199
+ it("runs one chat per thread and keeps histories isolated across switches", async () => {
200
+ const { transport, emit, close } = createControlledTransport();
201
+ const handle = createAssistantClient(
202
+ AuiConfig({ threads: AISDKThreads({ transport }) }),
203
+ );
204
+ handle.subscribe(() => {});
205
+ const aui = handle.getClient();
206
+
207
+ expect(aui.threads.getState().threadIds).toEqual(["main"]);
208
+
209
+ flushTapSync(() => aui.composer.setText("first question"));
210
+ flushTapSync(() => aui.composer.send());
211
+ await waitFor(() => {
212
+ expect(
213
+ handle.getClient().thread.getState().messages.length,
214
+ ).toBeGreaterThan(0);
215
+ });
216
+ emit(...textReply("first answer"));
217
+ close();
218
+ await waitFor(() => {
219
+ expect(threadText(handle as never)).toEqual([
220
+ "first question",
221
+ "first answer",
222
+ ]);
223
+ expect(handle.getClient().thread.getState().isRunning).toBe(false);
224
+ });
225
+
226
+ flushTapSync(() => aui.threads.switchToNewThread());
227
+ const state = handle.getClient().threads.getState();
228
+ expect(state.threadIds).toHaveLength(2);
229
+ expect(state.mainThreadId).not.toBe("main");
230
+ expect(handle.getClient().thread.getState().messages).toHaveLength(0);
231
+
232
+ flushTapSync(() => handle.getClient().threads.switchToThread("main"));
233
+ expect(handle.getClient().threads.getState().mainThreadId).toBe("main");
234
+ expect(threadText(handle as never)).toEqual([
235
+ "first question",
236
+ "first answer",
237
+ ]);
238
+
239
+ handle.destroy();
240
+ });
241
+
242
+ it("keeps a switched-away thread streaming in the background", async () => {
243
+ const { transport, emit, close } = createControlledTransport();
244
+ const handle = createAssistantClient(
245
+ AuiConfig({ threads: AISDKThreads({ transport: () => transport }) }),
246
+ );
247
+ handle.subscribe(() => {});
248
+ const aui = handle.getClient();
249
+
250
+ flushTapSync(() => aui.composer.setText("stream me"));
251
+ flushTapSync(() => aui.composer.send());
252
+ await waitFor(() => {
253
+ expect(
254
+ handle.getClient().thread.getState().messages.length,
255
+ ).toBeGreaterThan(0);
256
+ });
257
+ emit(
258
+ { type: "start" },
259
+ { type: "text-start", id: "t1" },
260
+ { type: "text-delta", id: "t1", delta: "partial" },
261
+ );
262
+
263
+ flushTapSync(() => aui.threads.switchToNewThread());
264
+ expect(handle.getClient().thread.getState().messages).toHaveLength(0);
265
+
266
+ emit(
267
+ { type: "text-delta", id: "t1", delta: " and finished" },
268
+ { type: "text-end", id: "t1" },
269
+ { type: "finish" },
270
+ );
271
+ close();
272
+
273
+ flushTapSync(() => handle.getClient().threads.switchToThread("main"));
274
+ await waitFor(() => {
275
+ expect(threadText(handle as never)).toEqual([
276
+ "stream me",
277
+ "partial and finished",
278
+ ]);
279
+ expect(handle.getClient().thread.getState().isRunning).toBe(false);
280
+ });
281
+
282
+ handle.destroy();
283
+ });
284
+
285
+ describe("a frontend tool running across a thread switch", () => {
286
+ const switchAwayAndBack = (
287
+ handle: ReturnType<typeof createAssistantClient>,
288
+ ) => {
289
+ flushTapSync(() => handle.getClient().threads.switchToNewThread());
290
+ flushTapSync(() => handle.getClient().threads.switchToThread("main"));
291
+ };
292
+
293
+ it("executes a tool call streamed while switched away once after switching back", async () => {
294
+ const execute = vi.fn(() => new Promise<string>(() => {}));
295
+ const { transport, emit, close } = createControlledTransport();
296
+ const sendMessages = vi.spyOn(transport, "sendMessages");
297
+ const onToolCall = vi.fn();
298
+ const handle = createAssistantClient(
299
+ AuiConfig({ threads: AISDKThreads({ transport, onToolCall }) }),
300
+ );
301
+ handle.subscribe(() => {});
302
+ try {
303
+ flushTapSync(() =>
304
+ handle.getClient().modelContext.register({
305
+ getModelContext: () => ({
306
+ tools: {
307
+ lookup: {
308
+ parameters: { type: "object", properties: {} },
309
+ execute,
310
+ },
311
+ },
312
+ }),
313
+ }),
314
+ );
315
+ flushTapSync(() => handle.getClient().composer.setText("look up"));
316
+ flushTapSync(() => handle.getClient().composer.send());
317
+ await waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(1));
318
+
319
+ emit({ type: "start", messageId: "assistant-1" });
320
+ await waitFor(() =>
321
+ expect(
322
+ handle
323
+ .getClient()
324
+ .thread.getState()
325
+ .messages.some((message) => message.role === "assistant"),
326
+ ).toBe(true),
327
+ );
328
+ flushTapSync(() => handle.getClient().threads.switchToNewThread());
329
+ emit(
330
+ {
331
+ type: "tool-input-available",
332
+ toolCallId: "call-after-switch",
333
+ toolName: "lookup",
334
+ input: {},
335
+ },
336
+ { type: "finish" },
337
+ );
338
+ await waitFor(() => expect(onToolCall).toHaveBeenCalledOnce());
339
+ expect(execute).not.toHaveBeenCalled();
340
+
341
+ flushTapSync(() => handle.getClient().threads.switchToThread("main"));
342
+ close();
343
+ await waitFor(() => expect(execute).toHaveBeenCalledOnce());
344
+ expect(handle.getClient().thread.getState().isRunning).toBe(true);
345
+ } finally {
346
+ handle.destroy();
347
+ }
348
+ });
349
+
350
+ it("keeps the thread running after switching back, and Stop aborts it", async () => {
351
+ let signal: AbortSignal | undefined;
352
+ const execute = vi.fn(
353
+ (_args: unknown, { abortSignal }: { abortSignal: AbortSignal }) =>
354
+ new Promise<string>(() => {
355
+ signal = abortSignal;
356
+ }),
357
+ );
358
+ const { handle } = await startTool(execute);
359
+ try {
360
+ await waitFor(() => expect(execute).toHaveBeenCalledOnce());
361
+
362
+ switchAwayAndBack(handle);
363
+ expect(handle.getClient().thread.getState().isRunning).toBe(true);
364
+ flushTapSync(() => handle.getClient().thread.cancelRun());
365
+ expect(signal?.aborted).toBe(true);
366
+ } finally {
367
+ handle.destroy();
368
+ }
369
+ });
370
+
371
+ it("still continues the turn with its result when nobody stops it", async () => {
372
+ let finishTool!: (value: string) => void;
373
+ const execute = vi.fn(
374
+ () =>
375
+ new Promise<string>((resolve) => {
376
+ finishTool = resolve;
377
+ }),
378
+ );
379
+ const { handle, sendMessages } = await startTool(execute);
380
+ try {
381
+ await waitFor(() => expect(execute).toHaveBeenCalledOnce());
382
+
383
+ switchAwayAndBack(handle);
384
+ finishTool("done");
385
+ await waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(2));
386
+ } finally {
387
+ handle.destroy();
388
+ }
389
+ });
390
+
391
+ it("continues the turn when it settles while the thread is in the background", async () => {
392
+ let finishTool!: (value: string) => void;
393
+ const execute = vi.fn(
394
+ () =>
395
+ new Promise<string>((resolve) => {
396
+ finishTool = resolve;
397
+ }),
398
+ );
399
+ const { handle, sendMessages } = await startTool(execute);
400
+ try {
401
+ await waitFor(() => expect(execute).toHaveBeenCalledOnce());
402
+
403
+ flushTapSync(() => handle.getClient().threads.switchToNewThread());
404
+ finishTool("done");
405
+ await waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(2));
406
+ } finally {
407
+ handle.destroy();
408
+ }
409
+ });
410
+
411
+ it("stays resumable when it waits on human input", async () => {
412
+ const execute = vi.fn(
413
+ async (
414
+ _args: unknown,
415
+ { human }: { human: (p: unknown) => unknown },
416
+ ) => ({ answer: await human({ question: "proceed?" }) }),
417
+ );
418
+ const { handle, sendMessages } = await startTool(execute);
419
+ try {
420
+ await waitFor(() => expect(execute).toHaveBeenCalledOnce());
421
+
422
+ switchAwayAndBack(handle);
423
+ expect(() =>
424
+ handle
425
+ .getClient()
426
+ .thread.message({ index: 1 })
427
+ .part({ toolCallId: "call-1" })
428
+ .resumeToolCall("yes"),
429
+ ).not.toThrow();
430
+ await waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(2));
431
+ } finally {
432
+ handle.destroy();
433
+ }
434
+ });
435
+ });
436
+
437
+ it("stops all in-flight chats when its client is destroyed", async () => {
438
+ const chats: ReturnType<typeof createCancellableTransport>[] = [];
439
+ const handle = createAssistantClient(
440
+ AuiConfig({
441
+ threads: AISDKThreads({
442
+ transport: () => {
443
+ const chat = createCancellableTransport();
444
+ chats.push(chat);
445
+ return chat.transport;
446
+ },
447
+ }),
448
+ }),
449
+ );
450
+ handle.subscribe(() => {});
451
+ const aui = handle.getClient();
452
+
453
+ try {
454
+ flushTapSync(() => aui.composer.setText("first"));
455
+ flushTapSync(() => aui.composer.send());
456
+ await waitFor(() => {
457
+ expect(aui.thread.getState().isRunning).toBe(true);
458
+ });
459
+
460
+ flushTapSync(() => aui.threads.switchToNewThread());
461
+ flushTapSync(() => handle.getClient().composer.setText("second"));
462
+ flushTapSync(() => handle.getClient().composer.send());
463
+ await waitFor(() => {
464
+ expect(chats).toHaveLength(2);
465
+ expect(handle.getClient().thread.getState().isRunning).toBe(true);
466
+ });
467
+ } finally {
468
+ handle.destroy();
469
+ }
470
+
471
+ await waitFor(() => {
472
+ expect(chats.map((chat) => chat.getCancelCount())).toEqual([1, 1]);
473
+ });
474
+ });
475
+
476
+ it("wires model context and per-thread transports onto the wire", async () => {
477
+ const bodies: unknown[] = [];
478
+ const fetchStub = vi.fn(async (_url: unknown, init?: RequestInit) => {
479
+ bodies.push(JSON.parse(String(init?.body)));
480
+ return new Response(
481
+ new ReadableStream({
482
+ start(c) {
483
+ c.enqueue(
484
+ new TextEncoder().encode(
485
+ 'data: {"type":"start"}\n\ndata: [DONE]\n\n',
486
+ ),
487
+ );
488
+ c.close();
489
+ },
490
+ }),
491
+ { headers: { "Content-Type": "text/event-stream" } },
492
+ );
493
+ });
494
+ vi.stubGlobal("fetch", fetchStub);
495
+ try {
496
+ const transports: AssistantChatTransport<never>[] = [];
497
+ const handle = createAssistantClient(
498
+ AuiConfig({
499
+ threads: AISDKThreads({
500
+ transport: () => {
501
+ const transport = new AssistantChatTransport();
502
+ transports.push(transport as AssistantChatTransport<never>);
503
+ return transport;
504
+ },
505
+ }),
506
+ }),
507
+ );
508
+ handle.subscribe(() => {});
509
+ const aui = handle.getClient();
510
+
511
+ flushTapSync(() =>
512
+ aui.modelContext.register({
513
+ getModelContext: () => ({ system: "wired system prompt" }),
514
+ }),
515
+ );
516
+ flushTapSync(() => aui.composer.setText("hello"));
517
+ flushTapSync(() => aui.composer.send());
518
+ await waitFor(() => expect(fetchStub).toHaveBeenCalledTimes(1));
519
+ expect(bodies[0]).toMatchObject({ system: "wired system prompt" });
520
+
521
+ flushTapSync(() => aui.threads.switchToNewThread());
522
+ await waitFor(() => expect(transports.length).toBe(2));
523
+ expect(transports[0]).not.toBe(transports[1]);
524
+
525
+ handle.destroy();
526
+ } finally {
527
+ vi.unstubAllGlobals();
528
+ }
529
+ });
530
+
531
+ it("bridges model context on the default transport path", async () => {
532
+ const bodies: unknown[] = [];
533
+ const fetchStub = vi.fn(async (_url: unknown, init?: RequestInit) => {
534
+ bodies.push(JSON.parse(String(init?.body)));
535
+ return new Response(
536
+ new ReadableStream({
537
+ start(c) {
538
+ c.enqueue(
539
+ new TextEncoder().encode(
540
+ 'data: {"type":"start"}\n\ndata: [DONE]\n\n',
541
+ ),
542
+ );
543
+ c.close();
544
+ },
545
+ }),
546
+ { headers: { "Content-Type": "text/event-stream" } },
547
+ );
548
+ });
549
+ vi.stubGlobal("fetch", fetchStub);
550
+ try {
551
+ const handle = createAssistantClient(
552
+ AuiConfig({ threads: AISDKThreads() }),
553
+ );
554
+ handle.subscribe(() => {});
555
+ const aui = handle.getClient();
556
+
557
+ flushTapSync(() =>
558
+ aui.modelContext.register({
559
+ getModelContext: () => ({ system: "default path system" }),
560
+ }),
561
+ );
562
+ flushTapSync(() => aui.composer.setText("hello"));
563
+ flushTapSync(() => aui.composer.send());
564
+ await waitFor(() => expect(fetchStub).toHaveBeenCalledTimes(1));
565
+ expect(bodies[0]).toMatchObject({ system: "default path system" });
566
+
567
+ handle.destroy();
568
+ } finally {
569
+ vi.unstubAllGlobals();
570
+ }
571
+ });
572
+
573
+ it("forwards ChatInit callbacks to each thread's chat from the latest render", async () => {
574
+ const { transport, emit, close } = createControlledTransport();
575
+ const sendMessages = vi.spyOn(transport, "sendMessages");
576
+ const mounted = chatCallbacks();
577
+ const latest = chatCallbacks();
578
+ latest.sendAutomaticallyWhen.mockReturnValueOnce(true);
579
+ let callbacks = mounted;
580
+ const { handle, rerender } = createLiveHandle(() => ({
581
+ transport: () => transport,
582
+ ...callbacks,
583
+ }));
584
+ const aui = handle.getClient();
585
+
586
+ callbacks = latest;
587
+ rerender();
588
+
589
+ flushTapSync(() => aui.composer.setText("hi"));
590
+ flushTapSync(() => aui.composer.send());
591
+ await waitFor(() => {
592
+ expect(
593
+ handle.getClient().thread.getState().messages.length,
594
+ ).toBeGreaterThan(0);
595
+ });
596
+ emit(
597
+ { type: "start" },
598
+ {
599
+ type: "tool-input-available",
600
+ toolCallId: "call-1",
601
+ toolName: "lookup",
602
+ input: {},
603
+ },
604
+ { type: "data-note", data: "note" },
605
+ { type: "finish" },
606
+ );
607
+ close();
608
+ await waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(2));
609
+ emit({ type: "start" }, { type: "error", errorText: "boom" });
610
+ close();
611
+ await waitFor(() => expect(latest.onFinish).toHaveBeenCalledTimes(2));
612
+
613
+ expect(latest.onToolCall).toHaveBeenCalledExactlyOnceWith({
614
+ toolCall: expect.objectContaining({ toolCallId: "call-1" }),
615
+ });
616
+ expect(latest.onData).toHaveBeenCalledExactlyOnceWith(
617
+ expect.objectContaining({ type: "data-note", data: "note" }),
618
+ );
619
+ expect(latest.onError).toHaveBeenCalledExactlyOnceWith(
620
+ expect.objectContaining({ message: "boom" }),
621
+ );
622
+ expect(latest.sendAutomaticallyWhen).toHaveBeenCalledOnce();
623
+ for (const callback of Object.values(mounted)) {
624
+ expect(callback).not.toHaveBeenCalled();
625
+ }
626
+
627
+ handle.destroy();
628
+ });
629
+
630
+ it("forwards the latest callbacks to a switched-away thread still streaming in the background", async () => {
631
+ const { transport, emit, close } = createControlledTransport();
632
+ const onFinishA = vi.fn();
633
+ const onFinishB = vi.fn();
634
+ let onFinish = onFinishA;
635
+ const { handle, rerender } = createLiveHandle(() => ({
636
+ transport: () => transport,
637
+ onFinish,
638
+ }));
639
+ const aui = handle.getClient();
640
+
641
+ flushTapSync(() => aui.composer.setText("stream me"));
642
+ flushTapSync(() => aui.composer.send());
643
+ await waitFor(() => {
644
+ expect(
645
+ handle.getClient().thread.getState().messages.length,
646
+ ).toBeGreaterThan(0);
647
+ });
648
+ emit(
649
+ { type: "start" },
650
+ { type: "text-start", id: "t1" },
651
+ { type: "text-delta", id: "t1", delta: "partial" },
652
+ );
653
+
654
+ flushTapSync(() => aui.threads.switchToNewThread());
655
+ onFinish = onFinishB;
656
+ rerender();
657
+
658
+ emit({ type: "text-end", id: "t1" }, { type: "finish" });
659
+ close();
660
+ await waitFor(() => expect(onFinishB).toHaveBeenCalledTimes(1));
661
+ expect(onFinishA).not.toHaveBeenCalled();
662
+
663
+ handle.destroy();
664
+ });
665
+
666
+ it("posts each thread's own id as the chat id", async () => {
667
+ const bodies: unknown[] = [];
668
+ const fetchStub = vi.fn(async (_url: unknown, init?: RequestInit) => {
669
+ bodies.push(JSON.parse(String(init?.body)));
670
+ return new Response(
671
+ new ReadableStream({
672
+ start(c) {
673
+ c.enqueue(
674
+ new TextEncoder().encode(
675
+ 'data: {"type":"start"}\n\ndata: [DONE]\n\n',
676
+ ),
677
+ );
678
+ c.close();
679
+ },
680
+ }),
681
+ { headers: { "Content-Type": "text/event-stream" } },
682
+ );
683
+ });
684
+ vi.stubGlobal("fetch", fetchStub);
685
+ try {
686
+ const handle = createAssistantClient(
687
+ AuiConfig({ threads: AISDKThreads() }),
688
+ );
689
+ handle.subscribe(() => {});
690
+ const aui = handle.getClient();
691
+
692
+ flushTapSync(() => aui.composer.setText("hello"));
693
+ flushTapSync(() => aui.composer.send());
694
+ await waitFor(() => expect(fetchStub).toHaveBeenCalledTimes(1));
695
+ expect(bodies[0]).toMatchObject({ id: "main" });
696
+
697
+ flushTapSync(() => aui.threads.switchToNewThread());
698
+ const newThreadId = handle.getClient().threads.getState().mainThreadId;
699
+ flushTapSync(() => handle.getClient().composer.setText("hi again"));
700
+ flushTapSync(() => handle.getClient().composer.send());
701
+ await waitFor(() => expect(fetchStub).toHaveBeenCalledTimes(2));
702
+ expect(bodies[1]).toMatchObject({ id: newThreadId });
703
+
704
+ handle.destroy();
705
+ } finally {
706
+ vi.unstubAllGlobals();
707
+ }
708
+ });
709
+ });
710
+
711
+ describe("AISDKThreads branch isolation", () => {
712
+ const completeRound = async (
713
+ handle: ReturnType<typeof createAssistantClient>,
714
+ emit: (...chunks: never[]) => void,
715
+ close: () => void,
716
+ question: string,
717
+ answer: string,
718
+ ) => {
719
+ const aui = handle.getClient();
720
+ flushTapSync(() => aui.composer.setText(question));
721
+ flushTapSync(() => aui.composer.send());
722
+ await waitFor(() => {
723
+ expect(aui.thread.getState().messages.length).toBeGreaterThan(0);
724
+ });
725
+ emit(...(textReply(answer) as never[]));
726
+ close();
727
+ await waitFor(() => {
728
+ expect(handle.getClient().thread.getState().isRunning).toBe(false);
729
+ });
730
+ };
731
+
732
+ it("keeps switching between populated threads free of cross-thread branches", async () => {
733
+ const { transport, emit, close } = createControlledTransport();
734
+ const handle = createAssistantClient(
735
+ AuiConfig({ threads: AISDKThreads({ transport }) }),
736
+ );
737
+ handle.subscribe(() => {});
738
+ const aui = handle.getClient();
739
+
740
+ await completeRound(
741
+ handle,
742
+ emit as never,
743
+ close,
744
+ "thread a question",
745
+ "thread a answer",
746
+ );
747
+
748
+ flushTapSync(() => aui.threads.switchToNewThread());
749
+ await completeRound(
750
+ handle,
751
+ emit as never,
752
+ close,
753
+ "thread b question",
754
+ "thread b answer",
755
+ );
756
+
757
+ flushTapSync(() => aui.threads.switchToThread("main"));
758
+ await waitFor(() => {
759
+ expect(threadText(handle as never)).toEqual([
760
+ "thread a question",
761
+ "thread a answer",
762
+ ]);
763
+ });
764
+ expect(aui.thread.message({ index: 0 }).getState().branchCount).toBe(1);
765
+ expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(1);
766
+
767
+ handle.destroy();
768
+ });
769
+
770
+ it("preserves intra-thread branches across a switch through an empty thread", async () => {
771
+ const { transport, emit, close } = createControlledTransport();
772
+ const handle = createAssistantClient(
773
+ AuiConfig({ threads: AISDKThreads({ transport }) }),
774
+ );
775
+ handle.subscribe(() => {});
776
+ const aui = handle.getClient();
777
+
778
+ await completeRound(
779
+ handle,
780
+ emit as never,
781
+ close,
782
+ "branchy question",
783
+ "first answer",
784
+ );
785
+
786
+ flushTapSync(() => aui.thread.message({ index: 1 }).reload());
787
+ await waitFor(() => {
788
+ expect(handle.getClient().thread.getState().isRunning).toBe(true);
789
+ });
790
+ emit(...(textReply("second answer") as never[]));
791
+ close();
792
+ await waitFor(() => {
793
+ expect(handle.getClient().thread.getState().isRunning).toBe(false);
794
+ expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
795
+ });
796
+
797
+ flushTapSync(() => aui.threads.switchToNewThread());
798
+ expect(handle.getClient().thread.getState().messages).toHaveLength(0);
799
+
800
+ flushTapSync(() => handle.getClient().threads.switchToThread("main"));
801
+ await waitFor(() => {
802
+ expect(threadText(handle as never)).toEqual([
803
+ "branchy question",
804
+ "second answer",
805
+ ]);
806
+ });
807
+ expect(aui.thread.message({ index: 1 }).getState().branchCount).toBe(2);
808
+
809
+ handle.destroy();
810
+ });
811
+ });