@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,319 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "./__tests__/tap";
4
+ import {
5
+ getExternalStoreMessages,
6
+ type RealtimeVoiceAdapter,
7
+ type ThreadHistoryAdapter,
8
+ type ThreadMessage,
9
+ } from "@ulearning/ai-core";
10
+ import type { UIMessage } from "ai";
11
+ import { describe, expect, it, vi } from "vitest";
12
+
13
+ import { useAISDKRuntime } from "./useAISDKRuntime";
14
+
15
+ const createChatHelpers = (initialMessages: UIMessage[] = []) => {
16
+ let currentMessages: UIMessage[] = initialMessages;
17
+ const chatHelpers: any = {
18
+ id: "chat-1",
19
+ status: "ready",
20
+ error: null,
21
+ messages: currentMessages,
22
+ setMessages: vi.fn(
23
+ (next: UIMessage[] | ((current: UIMessage[]) => UIMessage[])) => {
24
+ currentMessages =
25
+ typeof next === "function" ? next(currentMessages) : [...next];
26
+ chatHelpers.messages = currentMessages;
27
+ return currentMessages;
28
+ },
29
+ ),
30
+ sendMessage: vi.fn().mockResolvedValue(undefined),
31
+ regenerate: vi.fn().mockResolvedValue(undefined),
32
+ addToolResult: vi.fn(),
33
+ addToolOutput: vi.fn(),
34
+ stop: vi.fn(),
35
+ };
36
+ return chatHelpers;
37
+ };
38
+
39
+ const createVoiceAdapter = ({
40
+ sendText,
41
+ }: { sendText?: RealtimeVoiceAdapter.Session["sendText"] } = {}) => {
42
+ let transcriptCallback:
43
+ | ((transcript: RealtimeVoiceAdapter.TranscriptItem) => void)
44
+ | undefined;
45
+ const session: RealtimeVoiceAdapter.Session = {
46
+ status: { type: "running" },
47
+ isMuted: false,
48
+ disconnect: vi.fn(),
49
+ mute: vi.fn(),
50
+ unmute: vi.fn(),
51
+ ...(sendText && { sendText }),
52
+ onStatusChange: () => () => {},
53
+ onTranscript: (callback) => {
54
+ transcriptCallback = callback;
55
+ return () => {
56
+ transcriptCallback = undefined;
57
+ };
58
+ },
59
+ onModeChange: () => () => {},
60
+ onVolumeChange: () => () => {},
61
+ };
62
+ return {
63
+ adapter: { connect: () => session } satisfies RealtimeVoiceAdapter,
64
+ emitTranscript: (transcript: RealtimeVoiceAdapter.TranscriptItem) =>
65
+ transcriptCallback?.(transcript),
66
+ };
67
+ };
68
+
69
+ const createHistoryAdapter = () => {
70
+ const append = vi.fn(async () => {});
71
+ const adapter: ThreadHistoryAdapter = {
72
+ load: vi.fn(),
73
+ append: vi.fn(),
74
+ withFormat: vi.fn().mockReturnValue({
75
+ load: vi.fn().mockResolvedValue({ headId: null, messages: [] }),
76
+ append,
77
+ }),
78
+ };
79
+ return { adapter, append };
80
+ };
81
+
82
+ const textOf = (message: ThreadMessage) =>
83
+ message.content
84
+ .map((part) => (part.type === "text" ? part.text : ""))
85
+ .join("");
86
+
87
+ const renderVoiceRuntime = async (
88
+ chat: ReturnType<typeof createChatHelpers>,
89
+ adapters: { voice: RealtimeVoiceAdapter; history?: ThreadHistoryAdapter },
90
+ ) => {
91
+ const rendered = renderHook(() => useAISDKRuntime(chat, { adapters }));
92
+ await waitFor(() => {
93
+ expect(rendered.result.current.thread.getState().capabilities.voice).toBe(
94
+ true,
95
+ );
96
+ });
97
+ return rendered;
98
+ };
99
+
100
+ describe("useAISDKRuntime voice transcripts", () => {
101
+ it("persists finalized transcripts through the useChat messages", async () => {
102
+ const chat = createChatHelpers();
103
+ const voice = createVoiceAdapter();
104
+ const { result, rerender } = await renderVoiceRuntime(chat, {
105
+ voice: voice.adapter,
106
+ });
107
+
108
+ act(() => {
109
+ result.current.thread.connectVoice();
110
+ voice.emitTranscript({
111
+ role: "assistant",
112
+ text: "Spoken reply",
113
+ isFinal: true,
114
+ });
115
+ });
116
+
117
+ const transcript = chat.messages[0];
118
+ expect(transcript).toEqual({
119
+ id: expect.any(String),
120
+ role: "assistant",
121
+ parts: [{ type: "text", text: "Spoken reply" }],
122
+ metadata: { modality: "voice" },
123
+ });
124
+
125
+ rerender();
126
+
127
+ await waitFor(() => {
128
+ const messages = result.current.thread
129
+ .getState()
130
+ .messages.filter((message) => message.id === transcript.id);
131
+ expect(messages).toHaveLength(1);
132
+ expect(messages[0]?.metadata.modality).toBe("voice");
133
+ expect(getExternalStoreMessages(messages[0]!)).toEqual([transcript]);
134
+ });
135
+ });
136
+
137
+ it("keeps a transcript spoken after a typed reply as its own message", async () => {
138
+ const chat = createChatHelpers([
139
+ {
140
+ id: "typed-user",
141
+ role: "user",
142
+ parts: [{ type: "text", text: "Hi" }],
143
+ },
144
+ {
145
+ id: "typed-assistant",
146
+ role: "assistant",
147
+ parts: [{ type: "text", text: "Typed reply" }],
148
+ },
149
+ ]);
150
+ const voice = createVoiceAdapter();
151
+ const { result, rerender } = await renderVoiceRuntime(chat, {
152
+ voice: voice.adapter,
153
+ });
154
+
155
+ act(() => {
156
+ result.current.thread.connectVoice();
157
+ voice.emitTranscript({
158
+ role: "assistant",
159
+ text: "Spoken reply",
160
+ isFinal: true,
161
+ });
162
+ });
163
+ const transcript = chat.messages[2];
164
+ rerender();
165
+
166
+ await waitFor(() => {
167
+ const spoken = result.current.thread.getState().messages.at(-1)!;
168
+ expect(getExternalStoreMessages(spoken)).toEqual([transcript]);
169
+ });
170
+ const messages = result.current.thread.getState().messages;
171
+ expect(messages.map((message) => message.id)).toEqual([
172
+ "typed-user",
173
+ "typed-assistant",
174
+ transcript.id,
175
+ ]);
176
+ expect(textOf(messages[1]!)).toBe("Typed reply");
177
+ expect(messages[1]?.metadata.modality).toBeUndefined();
178
+ expect(textOf(messages[2]!)).toBe("Spoken reply");
179
+ expect(messages[2]?.metadata.modality).toBe("voice");
180
+ });
181
+
182
+ it("keeps consecutive assistant transcripts as separate messages", async () => {
183
+ const chat = createChatHelpers();
184
+ const voice = createVoiceAdapter();
185
+ const { result, rerender } = await renderVoiceRuntime(chat, {
186
+ voice: voice.adapter,
187
+ });
188
+
189
+ act(() => {
190
+ result.current.thread.connectVoice();
191
+ voice.emitTranscript({ role: "assistant", text: "First", isFinal: true });
192
+ voice.emitTranscript({
193
+ role: "assistant",
194
+ text: "Second",
195
+ isFinal: true,
196
+ });
197
+ });
198
+ const [first, second] = chat.messages;
199
+ rerender();
200
+
201
+ await waitFor(() => {
202
+ const messages = result.current.thread.getState().messages;
203
+ expect(
204
+ messages.map((message) => getExternalStoreMessages(message)),
205
+ ).toEqual([[first], [second]]);
206
+ });
207
+ expect(result.current.thread.getState().messages.map(textOf)).toEqual([
208
+ "First",
209
+ "Second",
210
+ ]);
211
+ });
212
+
213
+ it("keeps the transcript in the thread after the session disconnects", async () => {
214
+ const chat = createChatHelpers();
215
+ const voice = createVoiceAdapter();
216
+ const { result, rerender } = await renderVoiceRuntime(chat, {
217
+ voice: voice.adapter,
218
+ });
219
+
220
+ act(() => {
221
+ result.current.thread.connectVoice();
222
+ voice.emitTranscript({
223
+ role: "assistant",
224
+ text: "Spoken reply",
225
+ isFinal: true,
226
+ });
227
+ });
228
+ const transcript = chat.messages[0];
229
+ rerender();
230
+ await waitFor(() => {
231
+ const messages = result.current.thread.getState().messages;
232
+ expect(getExternalStoreMessages(messages[0]!)).toEqual([transcript]);
233
+ });
234
+
235
+ act(() => {
236
+ result.current.thread.disconnectVoice();
237
+ });
238
+ rerender();
239
+
240
+ const messages = result.current.thread.getState().messages;
241
+ expect(messages).toHaveLength(1);
242
+ expect(messages[0]?.id).toBe(transcript.id);
243
+ expect(messages[0]?.metadata.modality).toBe("voice");
244
+ expect(result.current.thread.getState().voice).toBeUndefined();
245
+ });
246
+
247
+ it("appends a finalized transcript to the history adapter without a text run", async () => {
248
+ const chat = createChatHelpers();
249
+ const voice = createVoiceAdapter();
250
+ const history = createHistoryAdapter();
251
+ const { result, rerender } = await renderVoiceRuntime(chat, {
252
+ voice: voice.adapter,
253
+ history: history.adapter,
254
+ });
255
+
256
+ act(() => {
257
+ result.current.thread.connectVoice();
258
+ voice.emitTranscript({
259
+ role: "assistant",
260
+ text: "Spoken reply",
261
+ isFinal: true,
262
+ });
263
+ });
264
+ const transcript = chat.messages[0];
265
+ rerender();
266
+
267
+ await waitFor(() => expect(history.append).toHaveBeenCalledTimes(1));
268
+ expect(history.append).toHaveBeenCalledWith({
269
+ parentId: null,
270
+ message: transcript,
271
+ });
272
+ });
273
+
274
+ it("persists a message typed into the session as a typed turn", async () => {
275
+ const chat = createChatHelpers();
276
+ const sendText = vi.fn(async (_text: string) => {});
277
+ const voice = createVoiceAdapter({ sendText });
278
+ const history = createHistoryAdapter();
279
+ const { result, rerender } = await renderVoiceRuntime(chat, {
280
+ voice: voice.adapter,
281
+ history: history.adapter,
282
+ });
283
+
284
+ act(() => {
285
+ result.current.thread.connectVoice();
286
+ });
287
+ await act(async () => {
288
+ await result.current.thread.append({
289
+ role: "user",
290
+ content: [{ type: "text", text: "Typed" }],
291
+ });
292
+ });
293
+
294
+ expect(sendText).toHaveBeenCalledExactlyOnceWith("Typed");
295
+ expect(chat.sendMessage).not.toHaveBeenCalled();
296
+ const typed = chat.messages[0];
297
+ expect(typed).toEqual({
298
+ id: expect.any(String),
299
+ role: "user",
300
+ parts: [{ type: "text", text: "Typed" }],
301
+ metadata: {},
302
+ });
303
+ rerender();
304
+
305
+ await waitFor(() => {
306
+ const messages = result.current.thread
307
+ .getState()
308
+ .messages.filter((message) => message.id === typed.id);
309
+ expect(messages).toHaveLength(1);
310
+ expect(messages[0]?.metadata.modality).toBeUndefined();
311
+ expect(textOf(messages[0]!)).toBe("Typed");
312
+ });
313
+ await waitFor(() => expect(history.append).toHaveBeenCalledTimes(1));
314
+ expect(history.append).toHaveBeenCalledWith({
315
+ parentId: null,
316
+ message: typed,
317
+ });
318
+ });
319
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createTapRoot, useState } from "@ulearning/ai-tap";
3
+ import { Chat } from "@ai-sdk/vue";
4
+ import { useChatSnapshot } from "./useChatSnapshot";
5
+ import { createControlledTransport } from "./__tests__/controlled-transport";
6
+
7
+ describe("useChat hosted in a tap root", () => {
8
+ it("streams a full round trip without React", async () => {
9
+ const { transport, emit, close } = createControlledTransport();
10
+ const root = createTapRoot(function UseChatOnTap() {
11
+ const [chat] = useState(() => new Chat({ transport }));
12
+ return useChatSnapshot(chat, undefined);
13
+ });
14
+
15
+ try {
16
+ expect(root.getValue().status).toBe("ready");
17
+ expect(root.getValue().messages).toHaveLength(0);
18
+
19
+ const sendPromise = root.getValue().sendMessage({ text: "hi" });
20
+ await vi.waitFor(() => {
21
+ expect(root.getValue().status).toBe("submitted");
22
+ expect(root.getValue().messages).toHaveLength(1);
23
+ });
24
+
25
+ emit(
26
+ { type: "start" },
27
+ { type: "text-start", id: "t1" },
28
+ { type: "text-delta", id: "t1", delta: "hello " },
29
+ );
30
+ await vi.waitFor(() => {
31
+ expect(root.getValue().status).toBe("streaming");
32
+ const parts = root.getValue().messages.at(-1)?.parts;
33
+ expect(parts).toContainEqual(
34
+ expect.objectContaining({ type: "text", text: "hello " }),
35
+ );
36
+ });
37
+
38
+ emit(
39
+ { type: "text-delta", id: "t1", delta: "world" },
40
+ { type: "text-end", id: "t1" },
41
+ { type: "finish" },
42
+ );
43
+ close();
44
+ await sendPromise;
45
+
46
+ await vi.waitFor(() => {
47
+ expect(root.getValue().status).toBe("ready");
48
+ const { messages } = root.getValue();
49
+ expect(messages).toHaveLength(2);
50
+ expect(messages[0]!.role).toBe("user");
51
+ expect(messages[1]!.role).toBe("assistant");
52
+ expect(messages[1]!.parts).toContainEqual(
53
+ expect.objectContaining({ type: "text", text: "hello world" }),
54
+ );
55
+ });
56
+ } finally {
57
+ root.unmount();
58
+ }
59
+ });
60
+ });
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, expect, it, vi } from "vitest";
4
+ import { defineComponent, h, type Component, type VNode } from "vue";
5
+ import { AssistantRuntimeProvider } from "@ulearning/ai-core/vue";
6
+ import { useChatRuntime } from "./useChatRuntime";
7
+ import { act, render, waitFor } from "./__tests__/vue";
8
+ import {
9
+ createCancellableTransport,
10
+ createStreamHarness,
11
+ } from "./__tests__/controlled-transport";
12
+
13
+ afterEach(() => vi.restoreAllMocks());
14
+
15
+ function nativeHmr() {
16
+ const runtime: unknown = Reflect.get(globalThis, "__VUE_HMR_RUNTIME__");
17
+ if (typeof runtime !== "object" || runtime === null)
18
+ throw new Error("The real Vue development HMR runtime is required.");
19
+ const createRecord: unknown = Reflect.get(runtime, "createRecord");
20
+ const rerender: unknown = Reflect.get(runtime, "rerender");
21
+ if (typeof createRecord !== "function" || typeof rerender !== "function")
22
+ throw new Error(
23
+ "Vue's real HMR createRecord/rerender operations are required.",
24
+ );
25
+ return {
26
+ createRecord: (id: string, component: Component): unknown =>
27
+ Reflect.apply(createRecord, runtime, [id, component]),
28
+ rerender: (id: string, next: () => VNode): void => {
29
+ Reflect.apply(rerender, runtime, [id, next]);
30
+ },
31
+ };
32
+ }
33
+
34
+ it("keeps a running thread across a Fast Refresh of its host and aborts it on unmount", async () => {
35
+ const { transport, getCancelCount } = createCancellableTransport();
36
+ const { Probe, send, isRunning, client } = createStreamHarness();
37
+ const runtimes: ReturnType<typeof useChatRuntime>[] = [];
38
+ let rendered: string | undefined;
39
+ const createRender = (name: string) => () => {
40
+ rendered = name;
41
+ const runtime = runtimes.at(-1);
42
+ if (!runtime) throw new Error("The actual host has not completed setup.");
43
+ return h(
44
+ AssistantRuntimeProvider,
45
+ { runtime },
46
+ { default: () => h(Probe) },
47
+ );
48
+ };
49
+ const Host = defineComponent({
50
+ name: "ChatRuntimeRefreshHost",
51
+ setup() {
52
+ runtimes.push(useChatRuntime({ transport }));
53
+ },
54
+ render: createRender("before"),
55
+ });
56
+ const id = "ai-sdk-useChatRuntime-native-render-refresh";
57
+ // This is the actual compiler-emitted component ID read by Vue's HMR runtime.
58
+ Reflect.set(Host, "__hmrId", id);
59
+ const hmr = nativeHmr();
60
+ expect(hmr.createRecord(id, Host)).toBe(true);
61
+
62
+ const view = render(h(Host));
63
+ await act(async () => send());
64
+ await waitFor(() => expect(isRunning()).toBe(true));
65
+ const runtime = runtimes.at(-1);
66
+ const threadId = client().threads.getState().mainThreadId;
67
+ const messages = client().thread.getState().messages;
68
+ const error = vi.spyOn(console, "error");
69
+
70
+ await act(() => hmr.rerender(id, createRender("after")));
71
+
72
+ expect(rendered).toBe("after");
73
+ expect(runtimes.at(-1)).toBe(runtime);
74
+ expect(runtimes).toHaveLength(1);
75
+ expect(isRunning()).toBe(true);
76
+ expect(getCancelCount()).toBe(0);
77
+ expect(client().threads.getState().mainThreadId).toBe(threadId);
78
+ expect(client().thread.getState().messages).toEqual(messages);
79
+ expect(error).not.toHaveBeenCalled();
80
+
81
+ view.unmount();
82
+ await waitFor(() => expect(getCancelCount()).toBe(1));
83
+ });