@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,661 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { useInsertionEffect, useTapHost } from "@ulearning/ai-tap";
4
+ import { useRuntimeResource } from "@ulearning/ai-core/vue/internal";
5
+ import { DynamicChatTransport } from "./DynamicChatTransport";
6
+ import { useChatThread } from "./useChatThread";
7
+ import { getEventListeners } from "node:events";
8
+ import { act, render, waitFor } from "./__tests__/vue";
9
+ import { screen } from "@testing-library/dom";
10
+ import {
11
+ AssistantRuntimeProvider,
12
+ RuntimeAdapterProvider,
13
+ } from "@ulearning/ai-core/vue";
14
+ import { AuiConfig, AuiProvider, useAuiState } from "@ulearning/ai-store";
15
+ import { useAssistantClientDestroySignal } from "@ulearning/ai-store/internal";
16
+ import type { AssistantRuntime } from "@ulearning/ai-core";
17
+ import { AISDKChat } from "./AISDKChat";
18
+ import type { ChatTransport, UIMessage } from "ai";
19
+ import {
20
+ defineComponent,
21
+ h,
22
+ KeepAlive,
23
+ nextTick,
24
+ onMounted,
25
+ shallowRef,
26
+ Suspense,
27
+ type VNodeChild,
28
+ } from "vue";
29
+ import { useIsomorphicLayoutEffect } from "@ulearning/ai-store/internal";
30
+ import { describe, expect, it, vi } from "vitest";
31
+ import { AssistantChatTransport } from "../transport/AssistantChatTransport";
32
+ import {
33
+ createCancellableTransport,
34
+ createStreamHarness,
35
+ nextTask,
36
+ } from "./__tests__/controlled-transport";
37
+ import { useChatRuntime } from "./useChatRuntime";
38
+ import { useThreadTokenUsage } from "../usage";
39
+
40
+ const messages: UIMessage[] = [
41
+ {
42
+ id: "initial-user-message",
43
+ role: "user",
44
+ parts: [{ type: "text", text: "Hello from the server" }],
45
+ },
46
+ ];
47
+
48
+ const MessageProbe = defineComponent({
49
+ setup() {
50
+ const count = useAuiState((state) => state.thread.messages.length);
51
+ const text = useAuiState(
52
+ (state) =>
53
+ state.thread.messages[0]?.parts
54
+ .map((part) => (part.type === "text" ? part.text : ""))
55
+ .join("") ?? "",
56
+ );
57
+ return () => [
58
+ h("output", { "data-testid": "message-count" }, count.value),
59
+ h("output", { "data-testid": "message-text" }, text.value),
60
+ ];
61
+ },
62
+ });
63
+
64
+ const TestApp = defineComponent({
65
+ setup() {
66
+ const transport = new AssistantChatTransport({ api: "/api/chat" });
67
+ const runtime = useChatRuntime({ messages, transport });
68
+ return () =>
69
+ h(
70
+ AssistantRuntimeProvider,
71
+ { runtime },
72
+ { default: () => h(MessageProbe) },
73
+ );
74
+ },
75
+ });
76
+
77
+ describe("useChatRuntime integration", () => {
78
+ it("exposes seeded messages through the mounted thread scope", async () => {
79
+ render(h(TestApp));
80
+
81
+ await waitFor(() => {
82
+ expect(screen.getByTestId("message-count").textContent).toBe("1");
83
+ expect(screen.getByTestId("message-text").textContent).toBe(
84
+ "Hello from the server",
85
+ );
86
+ });
87
+ });
88
+
89
+ it("aborts a deleted thread's stream while the host stays mounted", async () => {
90
+ const { transport, getCancelCount } = createCancellableTransport();
91
+ const { Probe, send, isRunning, client } = createStreamHarness();
92
+
93
+ const view = render(h(StreamingApp, { transport, probe: h(Probe) }));
94
+
95
+ await act(async () => send());
96
+ await waitFor(() => expect(isRunning()).toBe(true));
97
+
98
+ // Wait for the delete after the action has published its state change.
99
+ let deletion: unknown;
100
+ await act(async () => {
101
+ deletion = client().threadListItem.delete();
102
+ });
103
+ await deletion;
104
+
105
+ await waitFor(() => expect(getCancelCount()).toBe(1));
106
+ view.unmount();
107
+ });
108
+
109
+ it("aborts the in-flight transport after a real unmount", async () => {
110
+ const { transport, getCancelCount } = createCancellableTransport();
111
+ const { Probe, send, isRunning } = createStreamHarness();
112
+
113
+ const view = render(h(StreamingApp, { transport, probe: h(Probe) }));
114
+
115
+ await act(async () => send());
116
+ await waitFor(() => expect(isRunning()).toBe(true));
117
+ // Retained-host cycles are covered below; a mounted host must not cancel.
118
+ expect(getCancelCount()).toBe(0);
119
+
120
+ view.unmount();
121
+ await waitFor(() => expect(getCancelCount()).toBe(1));
122
+ });
123
+
124
+ // Real KeepAlive deactivation retains the client and transport.
125
+ it("keeps streaming while hidden and aborts when the hidden host unmounts", async () => {
126
+ const { transport, getCancelCount } = createCancellableTransport();
127
+ const { Probe, send, isRunning } = createStreamHarness();
128
+
129
+ let setMode: ((mode: "visible" | "hidden") => void) | undefined;
130
+ const Shell = defineComponent({
131
+ setup() {
132
+ const mode = shallowRef<"visible" | "hidden">("visible");
133
+ setMode = (value) => {
134
+ mode.value = value;
135
+ };
136
+ return () =>
137
+ h(KeepAlive, null, {
138
+ default: () =>
139
+ mode.value === "visible"
140
+ ? h(StreamingApp, { transport, probe: h(Probe) })
141
+ : null,
142
+ });
143
+ },
144
+ });
145
+ const view = render(h(Shell));
146
+
147
+ await act(async () => send());
148
+ await waitFor(() => expect(isRunning()).toBe(true));
149
+
150
+ await act(async () => setMode?.("hidden"));
151
+ await act(nextTask);
152
+ expect(getCancelCount()).toBe(0);
153
+ expect(isRunning()).toBe(true);
154
+
155
+ await act(async () => setMode?.("visible"));
156
+ await act(nextTask);
157
+ expect(getCancelCount()).toBe(0);
158
+ expect(isRunning()).toBe(true);
159
+
160
+ await act(async () => setMode?.("hidden"));
161
+ view.unmount();
162
+ await waitFor(() => expect(getCancelCount()).toBe(1));
163
+ });
164
+
165
+ it("aborts a nested runtime's stream when the provider above it unmounts", async () => {
166
+ const outer = createCancellableTransport();
167
+ const { transport, getCancelCount } = createCancellableTransport();
168
+ let nested: AssistantRuntime | undefined;
169
+
170
+ // The nested native owner must release its own transport independently.
171
+ const NestedChat = defineComponent({
172
+ setup() {
173
+ nested = useChatRuntime({ transport });
174
+ return () => null;
175
+ },
176
+ });
177
+ const view = render(
178
+ h(
179
+ AuiProvider,
180
+ {
181
+ config: AuiConfig({
182
+ threads: AISDKChat({ transport: outer.transport }),
183
+ }),
184
+ },
185
+ { default: () => h(NestedChat) },
186
+ ),
187
+ );
188
+
189
+ await waitFor(() => expect(nested).toBeDefined());
190
+ await act(async () => {
191
+ await nested!.thread.append("keep streaming");
192
+ });
193
+ await waitFor(() => expect(nested!.thread.getState().isRunning).toBe(true));
194
+
195
+ view.unmount();
196
+ await waitFor(() => expect(getCancelCount()).toBe(1));
197
+ });
198
+
199
+ it("aborts a nested runtime when only its own component unmounts", async () => {
200
+ const outer = createCancellableTransport();
201
+ const { transport, getCancelCount } = createCancellableTransport();
202
+ let nested: AssistantRuntime | undefined;
203
+ let providerSignal: AbortSignal | undefined;
204
+ let setVisible: ((visible: boolean) => void) | undefined;
205
+
206
+ const NestedChat = defineComponent({
207
+ setup() {
208
+ providerSignal = useAssistantClientDestroySignal();
209
+ nested = useChatRuntime({ transport });
210
+ return () => null;
211
+ },
212
+ });
213
+ const Shell = defineComponent({
214
+ setup() {
215
+ const visible = shallowRef(true);
216
+ setVisible = (value) => {
217
+ visible.value = value;
218
+ };
219
+ const config = AuiConfig({
220
+ threads: AISDKChat({ transport: outer.transport }),
221
+ });
222
+ return () =>
223
+ h(
224
+ AuiProvider,
225
+ { config },
226
+ { default: () => (visible.value ? h(NestedChat) : null) },
227
+ );
228
+ },
229
+ });
230
+ const listeners = () => getEventListeners(providerSignal!, "abort").length;
231
+
232
+ const view = render(h(Shell));
233
+ await waitFor(() => expect(nested).toBeDefined());
234
+ const mounted = listeners();
235
+
236
+ for (const cycle of [1, 2]) {
237
+ await act(async () => {
238
+ await nested!.thread.append(`stream ${cycle}`);
239
+ });
240
+ await waitFor(() =>
241
+ expect(nested!.thread.getState().isRunning).toBe(true),
242
+ );
243
+
244
+ await act(async () => setVisible?.(false));
245
+ await waitFor(() => expect(getCancelCount()).toBe(cycle));
246
+ expect(listeners()).toBe(mounted - 1);
247
+
248
+ nested = undefined;
249
+ await act(async () => setVisible?.(true));
250
+ await waitFor(() => expect(nested).toBeDefined());
251
+ expect(listeners()).toBe(mounted);
252
+ }
253
+
254
+ view.unmount();
255
+ await act(nextTask);
256
+ expect(getCancelCount()).toBe(2);
257
+ });
258
+ });
259
+
260
+ const StreamingApp = defineComponent(
261
+ (props: { transport: ChatTransport<UIMessage>; probe: VNodeChild }) => {
262
+ const runtime = useChatRuntime(() => ({ transport: props.transport }));
263
+ return () =>
264
+ h(AssistantRuntimeProvider, { runtime }, { default: () => props.probe });
265
+ },
266
+ { props: ["transport", "probe"] },
267
+ );
268
+
269
+ const UsageProbe = defineComponent({
270
+ setup() {
271
+ const usage = useThreadTokenUsage();
272
+ return () =>
273
+ h(
274
+ "output",
275
+ { "data-testid": "total-tokens" },
276
+ usage.value?.totalTokens ?? "none",
277
+ );
278
+ },
279
+ });
280
+
281
+ const UsageApp = defineComponent({
282
+ setup() {
283
+ const transport = new AssistantChatTransport({ api: "/api/chat" });
284
+ const runtime = useChatRuntime({
285
+ messages: [
286
+ ...messages,
287
+ {
288
+ id: "assistant-with-usage",
289
+ role: "assistant",
290
+ parts: [{ type: "text", text: "Hi" }],
291
+ metadata: { usage: { inputTokens: 40, outputTokens: 2 } },
292
+ },
293
+ ],
294
+ transport,
295
+ });
296
+ return () =>
297
+ h(
298
+ AssistantRuntimeProvider,
299
+ { runtime },
300
+ { default: () => h(UsageProbe) },
301
+ );
302
+ },
303
+ });
304
+
305
+ describe("useThreadTokenUsage through useChatRuntime", () => {
306
+ it("reads usage from the message metadata a server attached", async () => {
307
+ render(h(UsageApp));
308
+
309
+ await waitFor(() => {
310
+ expect(screen.getByTestId("total-tokens").textContent).toBe("42");
311
+ });
312
+ });
313
+ });
314
+
315
+ describe("replacement transports", () => {
316
+ it("does not loop when an inline transport recreates resumable storage", async () => {
317
+ const fetch = vi.fn(
318
+ async () =>
319
+ new Response(
320
+ new ReadableStream({ start: (controller) => controller.close() }),
321
+ { headers: { "content-type": "text/event-stream" } },
322
+ ),
323
+ );
324
+ let renders = 0;
325
+ let sent = false;
326
+ const SendOnLayout = defineComponent(
327
+ (props: { runtime: AssistantRuntime }) => {
328
+ useIsomorphicLayoutEffect(
329
+ () => {
330
+ if (sent) return;
331
+ sent = true;
332
+ void props.runtime.thread.append("hello");
333
+ },
334
+ () => [props.runtime],
335
+ );
336
+ return () => null;
337
+ },
338
+ { props: ["runtime"] },
339
+ );
340
+ const NestedChat = defineComponent({
341
+ setup() {
342
+ const runtime = useChatRuntime(() => {
343
+ renders += 1;
344
+ return {
345
+ transport: new AssistantChatTransport({
346
+ fetch,
347
+ resumable: {
348
+ storage: {
349
+ getStreamId: () => null,
350
+ setStreamId: vi.fn(),
351
+ clear: vi.fn(),
352
+ },
353
+ resumeApi: "/api/chat/resume",
354
+ },
355
+ }),
356
+ };
357
+ });
358
+ return () => h(SendOnLayout, { runtime });
359
+ },
360
+ });
361
+ render(
362
+ h(
363
+ AuiProvider,
364
+ {
365
+ config: AuiConfig({
366
+ threads: AISDKChat({ transport: new AssistantChatTransport() }),
367
+ }),
368
+ },
369
+ { default: () => h(NestedChat) },
370
+ ),
371
+ );
372
+ await waitFor(() => expect(fetch).toHaveBeenCalledOnce());
373
+ expect(renders).toBeLessThan(10);
374
+ });
375
+
376
+ it("routes sends through a replacement transport", async () => {
377
+ const createEmptyStream = () =>
378
+ new ReadableStream({ start: (controller) => controller.close() });
379
+ const sendA = vi.fn(async () => createEmptyStream());
380
+ const sendB = vi.fn(async () => createEmptyStream());
381
+ const transportA: ChatTransport<UIMessage> = {
382
+ sendMessages: sendA,
383
+ reconnectToStream: vi.fn(),
384
+ };
385
+ const transportB: ChatTransport<UIMessage> = {
386
+ sendMessages: sendB,
387
+ reconnectToStream: vi.fn(),
388
+ };
389
+ const SendOnLayout = defineComponent(
390
+ (props: { runtime: AssistantRuntime }) => {
391
+ useIsomorphicLayoutEffect(
392
+ () => {
393
+ void props.runtime.thread.append({
394
+ role: "user",
395
+ content: [{ type: "text", text: "hello" }],
396
+ });
397
+ },
398
+ () => [props.runtime],
399
+ );
400
+ return () => null;
401
+ },
402
+ { props: ["runtime"] },
403
+ );
404
+ const App = defineComponent(
405
+ (props: { transport: ChatTransport<UIMessage>; send?: boolean }) => {
406
+ const runtime = useChatRuntime(() => ({ transport: props.transport }));
407
+ return () =>
408
+ h(
409
+ AssistantRuntimeProvider,
410
+ { runtime },
411
+ {
412
+ default: () => (props.send ? h(SendOnLayout, { runtime }) : null),
413
+ },
414
+ );
415
+ },
416
+ { props: ["transport", "send"] },
417
+ );
418
+ const view = render(h(App, { transport: transportA }));
419
+ await view.rerender(h(App, { transport: transportB, send: true }));
420
+ await waitFor(() => expect(sendB).toHaveBeenCalledOnce());
421
+ expect(sendA).not.toHaveBeenCalled();
422
+ });
423
+
424
+ it("routes sends triggered during the initial layout commit", async () => {
425
+ const send = vi.fn(
426
+ async () =>
427
+ new ReadableStream({ start: (controller) => controller.close() }),
428
+ );
429
+ const transport: ChatTransport<UIMessage> = {
430
+ sendMessages: send,
431
+ reconnectToStream: vi.fn(),
432
+ };
433
+ let resolveLayout!: () => void;
434
+ const layoutCommitted = new Promise<void>((resolve) => {
435
+ resolveLayout = resolve;
436
+ });
437
+ const SendOnLayout = defineComponent(
438
+ (props: { runtime: AssistantRuntime }) => {
439
+ onMounted(() => {
440
+ void props.runtime.thread.append({
441
+ role: "user",
442
+ content: [{ type: "text", text: "hello" }],
443
+ });
444
+ resolveLayout();
445
+ });
446
+ return () => null;
447
+ },
448
+ { props: ["runtime"] },
449
+ );
450
+ const App = defineComponent({
451
+ setup() {
452
+ const runtime = useChatRuntime({ transport });
453
+ return () =>
454
+ h(
455
+ AssistantRuntimeProvider,
456
+ { runtime },
457
+ { default: () => h(SendOnLayout, { runtime }) },
458
+ );
459
+ },
460
+ });
461
+ const view = render(h(App));
462
+ try {
463
+ await layoutCommitted;
464
+ await waitFor(() => expect(send).toHaveBeenCalledOnce());
465
+ } finally {
466
+ view.unmount();
467
+ }
468
+ });
469
+
470
+ it("keeps AssistantChatTransport wired through native retained-scope effect replay", async () => {
471
+ const sourceTransport = new AssistantChatTransport<UIMessage>({
472
+ api: "/api/chat",
473
+ });
474
+ const transport = new DynamicChatTransport(sourceTransport);
475
+ const wiredDuringEffectSetup: boolean[] = [];
476
+ const Probe = defineComponent(
477
+ (props: { effects: () => void }) => {
478
+ useIsomorphicLayoutEffect(() => {
479
+ props.effects();
480
+ try {
481
+ wiredDuringEffectSetup.push(
482
+ transport.getCurrentTransport("strict-mode-thread") !==
483
+ sourceTransport,
484
+ );
485
+ } catch {
486
+ wiredDuringEffectSetup.push(false);
487
+ }
488
+ }, []);
489
+ return () => null;
490
+ },
491
+ { props: ["effects"] },
492
+ );
493
+ const App = defineComponent({
494
+ setup() {
495
+ const host = useTapHost(function ChatThreadResource() {
496
+ return useChatThread(
497
+ { transport },
498
+ {
499
+ id: "strict-mode-thread",
500
+ isMainThread: true,
501
+ getThreadListItem: () => undefined,
502
+ },
503
+ );
504
+ });
505
+ return () => h(Probe, { effects: host.effects });
506
+ },
507
+ });
508
+ const visible = shallowRef(true);
509
+ render(
510
+ h(
511
+ defineComponent({
512
+ setup: () => () =>
513
+ h(KeepAlive, null, {
514
+ default: () => (visible.value ? h(App) : null),
515
+ }),
516
+ }),
517
+ ),
518
+ );
519
+ visible.value = false;
520
+ await nextTick();
521
+ visible.value = true;
522
+ await nextTick();
523
+ expect(wiredDuringEffectSetup).toEqual([true, true]);
524
+ });
525
+
526
+ it("does not publish transport contexts from suspended renders", () => {
527
+ const sourceTransport = new AssistantChatTransport<UIMessage>({
528
+ api: "/api/chat",
529
+ });
530
+ const transport = new DynamicChatTransport(sourceTransport);
531
+ const pending = new Promise<never>(() => {});
532
+ const Blocker = defineComponent({
533
+ async setup() {
534
+ await pending;
535
+ return () => null;
536
+ },
537
+ });
538
+ const SuspendedThread = defineComponent({
539
+ setup() {
540
+ useTapHost(function ChatThreadResource() {
541
+ return useChatThread(
542
+ { transport },
543
+ {
544
+ id: "suspended-thread",
545
+ isMainThread: true,
546
+ getThreadListItem: () => undefined,
547
+ },
548
+ );
549
+ });
550
+ return () => h(Blocker);
551
+ },
552
+ });
553
+ render(
554
+ h(Suspense, null, {
555
+ default: () => h(SuspendedThread),
556
+ fallback: () => null,
557
+ }),
558
+ );
559
+
560
+ expect(transport.getCurrentTransport("suspended-thread")).toBe(
561
+ sourceTransport,
562
+ );
563
+ });
564
+
565
+ it("routes runtime sends through a wired clone with the latest thread item", async () => {
566
+ const bodies: Array<{ id: string; system: string }> = [];
567
+ const completeRequests: (() => void)[] = [];
568
+ const sourceTransport = new AssistantChatTransport<UIMessage>({
569
+ fetch: vi.fn(async (_input, init) => {
570
+ bodies.push(JSON.parse(String(init?.body)));
571
+ return new Response(
572
+ new ReadableStream({
573
+ start(controller) {
574
+ completeRequests.push(() => controller.close());
575
+ },
576
+ }),
577
+ { headers: { "content-type": "text/event-stream" } },
578
+ );
579
+ }),
580
+ });
581
+ const transport = new DynamicChatTransport(sourceTransport);
582
+ const remoteId = shallowRef("remote-a");
583
+ let publishedRemoteId: string | undefined;
584
+ let runtime!: AssistantRuntime;
585
+ const Probe = defineComponent({
586
+ setup() {
587
+ runtime = useRuntimeResource(() => {
588
+ const committedRemoteId = remoteId.value;
589
+ const current = useChatThread(
590
+ { transport },
591
+ {
592
+ id: "stable-thread",
593
+ isMainThread: true,
594
+ getThreadListItem: () => ({
595
+ initialize: async () => ({
596
+ remoteId: committedRemoteId,
597
+ externalId: undefined,
598
+ }),
599
+ }),
600
+ },
601
+ );
602
+ useInsertionEffect(() => {
603
+ publishedRemoteId = committedRemoteId;
604
+ });
605
+ return current;
606
+ });
607
+ return () => null;
608
+ },
609
+ });
610
+ render(
611
+ h(
612
+ RuntimeAdapterProvider,
613
+ {
614
+ adapters: {
615
+ modelContext: {
616
+ getModelContext: () => ({ system: "system prompt" }),
617
+ },
618
+ },
619
+ },
620
+ { default: () => h(Probe) },
621
+ ),
622
+ );
623
+ const result = {
624
+ get current() {
625
+ return runtime;
626
+ },
627
+ };
628
+ const rerender = async (props: { remoteId: string }) => {
629
+ await act(() => {
630
+ remoteId.value = props.remoteId;
631
+ });
632
+ await waitFor(() => expect(publishedRemoteId).toBe(props.remoteId));
633
+ };
634
+ const send = async () => {
635
+ const index = bodies.length;
636
+ await act(async () => {
637
+ await result.current.thread.append({
638
+ role: "user",
639
+ content: [{ type: "text", text: "hello" }],
640
+ });
641
+ });
642
+ await waitFor(() => {
643
+ expect(bodies).toHaveLength(index + 1);
644
+ expect(result.current.thread.getState().isRunning).toBe(true);
645
+ });
646
+ completeRequests[index]!();
647
+ await waitFor(() =>
648
+ expect(result.current.thread.getState().isRunning).toBe(false),
649
+ );
650
+ };
651
+
652
+ await send();
653
+ await rerender({ remoteId: "remote-b" });
654
+ await send();
655
+
656
+ expect(bodies).toEqual([
657
+ expect.objectContaining({ id: "remote-a", system: "system prompt" }),
658
+ expect.objectContaining({ id: "remote-b", system: "system prompt" }),
659
+ ]);
660
+ });
661
+ });