@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,993 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { resource, useResource, flushTapSync } from "@ulearning/ai-tap";
5
+ import { useState } from "@ulearning/ai-tap";
6
+ import type { AssistantRuntime } from "@ulearning/ai-core";
7
+ import type { ChatTransport, UIMessage } from "ai";
8
+ import {
9
+ RuntimeAdapter,
10
+ runtimeAdapterTransformScopes,
11
+ } from "@ulearning/ai-core/store";
12
+ import {
13
+ attachTransformScopes,
14
+ AuiConfig,
15
+ createAssistantClient,
16
+ } from "@ulearning/ai-store/client";
17
+ import {
18
+ createChat,
19
+ useChatThread,
20
+ type ChatThreadEnvironment,
21
+ } from "./useChatThread";
22
+ import { DynamicChatTransport } from "./DynamicChatTransport";
23
+ import { AssistantChatTransport } from "../transport/AssistantChatTransport";
24
+ import { AISDKThreads } from "./AISDKThreads";
25
+ import {
26
+ createResumableSessionStorage,
27
+ RESUMABLE_STREAM_ID_HEADER,
28
+ } from "../transport/resumable";
29
+ import type { UIMessageChunk } from "ai";
30
+ import {
31
+ createCancellableTransport,
32
+ nextTask,
33
+ } from "./__tests__/controlled-transport";
34
+
35
+ const createHost = (
36
+ env: Pick<ChatThreadEnvironment, "stopOnClientDestroy" | "chat">,
37
+ onRuntime?: (runtime: ReturnType<typeof useChatThread>) => void,
38
+ ) => {
39
+ const useHost = (options: Parameters<typeof useChatThread>[0]) => {
40
+ const [threadListItem] = useState(() => ({
41
+ initialize: async () => ({ remoteId: "main", externalId: undefined }),
42
+ }));
43
+ const runtime = useChatThread(options, {
44
+ id: "main",
45
+ isMainThread: true,
46
+ getThreadListItem: () => threadListItem,
47
+ ...env,
48
+ });
49
+ onRuntime?.(runtime);
50
+ return useResource(RuntimeAdapter(runtime));
51
+ };
52
+ attachTransformScopes(useHost, runtimeAdapterTransformScopes);
53
+ return resource(useHost);
54
+ };
55
+
56
+ const streamThenDestroy = async (
57
+ env: Pick<ChatThreadEnvironment, "stopOnClientDestroy">,
58
+ ) => {
59
+ const { transport, getCancelCount, close } = createCancellableTransport();
60
+ const Host = createHost(env);
61
+ const handle = createAssistantClient(
62
+ AuiConfig({ threads: Host({ transport }) }),
63
+ );
64
+ handle.subscribe(() => {});
65
+ const aui = handle.getClient();
66
+
67
+ try {
68
+ flushTapSync(() => aui.composer.setText("stop me"));
69
+ flushTapSync(() => aui.composer.send());
70
+ await vi.waitFor(() => {
71
+ expect(aui.thread.getState().isRunning).toBe(true);
72
+ });
73
+ } finally {
74
+ handle.destroy();
75
+ }
76
+ await nextTask();
77
+ const cancelCount = getCancelCount();
78
+ if (cancelCount === 0) close();
79
+ return cancelCount;
80
+ };
81
+
82
+ describe("useChatThread", () => {
83
+ it.each([
84
+ { adapters: undefined, threadId: "main" },
85
+ {
86
+ adapters: { threadList: { threadId: "caller-id" } },
87
+ threadId: "caller-id",
88
+ },
89
+ ])("uses $threadId as the runtime thread id", ({ adapters, threadId }) => {
90
+ let runtime: ReturnType<typeof useChatThread> | undefined;
91
+ const Host = createHost({}, (value) => {
92
+ runtime = value;
93
+ });
94
+ const handle = createAssistantClient(
95
+ AuiConfig({ threads: Host({ adapters }) }),
96
+ );
97
+ try {
98
+ handle.subscribe(() => {});
99
+ expect(runtime?.thread.getState().threadId).toBe(threadId);
100
+ } finally {
101
+ handle.destroy();
102
+ }
103
+ });
104
+
105
+ it.each(["send", "resume"])(
106
+ "ignores errors from a subsequent %s when a resume finishes",
107
+ async (nextRequest) => {
108
+ const storage = createResumableSessionStorage({
109
+ key: `resume-${nextRequest}-race`,
110
+ });
111
+ storage.setStreamId("stream-1", "main");
112
+ const error = new Error(`${nextRequest} offline`);
113
+ const onError = vi.fn();
114
+ const onResumeError = vi.fn();
115
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
116
+ const transport = {
117
+ getResumableAdapter: () => ({ storage }),
118
+ reconnectToStream: vi
119
+ .fn<ChatTransport<UIMessage>["reconnectToStream"]>()
120
+ .mockRejectedValue(error)
121
+ .mockImplementationOnce(
122
+ async () =>
123
+ new ReadableStream({
124
+ start(controller) {
125
+ controller.enqueue({ type: "start", messageId: "resumed" });
126
+ controller.close();
127
+ },
128
+ }),
129
+ ),
130
+ sendMessages: vi.fn(async () => {
131
+ throw error;
132
+ }),
133
+ } satisfies ChatTransport<UIMessage> & {
134
+ getResumableAdapter: () => { storage: typeof storage };
135
+ };
136
+ let sent = false;
137
+ const callbacks = {
138
+ onError,
139
+ onFinish: () => {
140
+ if (!sent) {
141
+ sent = true;
142
+ if (nextRequest === "resume") {
143
+ void chat.resumeStream();
144
+ } else {
145
+ void chat.sendMessage({
146
+ role: "user",
147
+ parts: [{ type: "text", text: "next" }],
148
+ });
149
+ }
150
+ }
151
+ },
152
+ };
153
+ const chat = createChat(
154
+ {
155
+ id: "main",
156
+ transport,
157
+ },
158
+ { current: callbacks },
159
+ );
160
+ const Host = createHost({ chat });
161
+ const handle = createAssistantClient(
162
+ AuiConfig({ threads: Host({ transport, onResumeError }) }),
163
+ );
164
+ handle.subscribe(() => {});
165
+ try {
166
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error));
167
+ await nextTask();
168
+ expect(onResumeError).not.toHaveBeenCalled();
169
+ expect(storage.getStreamId("main")).toBe("stream-1");
170
+ } finally {
171
+ handle.destroy();
172
+ storage.clear();
173
+ warn.mockRestore();
174
+ }
175
+ },
176
+ );
177
+
178
+ it.each([false, true])(
179
+ "reports SDK reconnect failures and preserves replacement checkpoints: %s",
180
+ async (replaceCheckpoint) => {
181
+ const storage = createResumableSessionStorage({
182
+ key: `automatic-resume-error-${replaceCheckpoint}`,
183
+ });
184
+ storage.setStreamId("failed-stream", "main");
185
+ const error = new Error("resume offline");
186
+ const onError = vi.fn();
187
+ const onResumeError = vi.fn(() => {
188
+ if (replaceCheckpoint) storage.setStreamId("replacement", "main");
189
+ });
190
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
191
+ let finishReplacement: (() => void) | undefined;
192
+ const fetch = vi
193
+ .fn<typeof globalThis.fetch>()
194
+ .mockRejectedValueOnce(error)
195
+ .mockImplementation(
196
+ () =>
197
+ new Promise<Response>((resolve) => {
198
+ finishReplacement = () =>
199
+ resolve(new Response(null, { status: 204 }));
200
+ }),
201
+ );
202
+ const Host = createHost({});
203
+ const handle = createAssistantClient(
204
+ AuiConfig({
205
+ threads: Host({
206
+ transport: new AssistantChatTransport({
207
+ fetch,
208
+ resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
209
+ }),
210
+ onError,
211
+ onResumeError,
212
+ }),
213
+ }),
214
+ );
215
+ handle.subscribe(() => {});
216
+ try {
217
+ await vi.waitFor(() => {
218
+ expect(onError).toHaveBeenCalledWith(error);
219
+ expect(onResumeError).toHaveBeenCalledOnce();
220
+ });
221
+ expect(onResumeError).toHaveBeenCalledWith(error);
222
+ expect(warn).toHaveBeenCalledWith(
223
+ "[assistant-ui] resumable: resume failed",
224
+ error,
225
+ );
226
+ expect(storage.getStreamId("main")).toBe(
227
+ replaceCheckpoint ? "replacement" : null,
228
+ );
229
+ } finally {
230
+ handle.destroy();
231
+ finishReplacement?.();
232
+ storage.clear();
233
+ warn.mockRestore();
234
+ }
235
+ },
236
+ );
237
+
238
+ it.each([false, true])(
239
+ "scopes branch checkpoint cleanup to the manual resume opt-in: %s",
240
+ async (canResume) => {
241
+ const storage = createResumableSessionStorage({ key: "resume-branch" });
242
+ storage.clear();
243
+ let stream!: ReadableStreamDefaultController<Uint8Array>;
244
+ const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
245
+ new Response(
246
+ new ReadableStream({
247
+ start: (controller) => {
248
+ stream = controller;
249
+ },
250
+ }),
251
+ {
252
+ headers: {
253
+ "content-type": "text/event-stream",
254
+ [RESUMABLE_STREAM_ID_HEADER]: "stream-1",
255
+ },
256
+ },
257
+ ),
258
+ );
259
+ const onBranchChange = vi.fn(() =>
260
+ expect(storage.getStreamId("main")).toBe(canResume ? null : "stream-1"),
261
+ );
262
+ const Host = createHost({});
263
+ const handle = createAssistantClient(
264
+ AuiConfig({
265
+ threads: Host({
266
+ canResume,
267
+ transport: new AssistantChatTransport({
268
+ fetch,
269
+ resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
270
+ }),
271
+ messageRepository: {
272
+ headId: "other",
273
+ messages: [
274
+ {
275
+ parentId: null,
276
+ message: {
277
+ id: "question",
278
+ role: "user",
279
+ parts: [{ type: "text", text: "Question" }],
280
+ },
281
+ },
282
+ {
283
+ parentId: "question",
284
+ message: {
285
+ id: "other",
286
+ role: "assistant",
287
+ parts: [{ type: "text", text: "Other branch" }],
288
+ },
289
+ },
290
+ ],
291
+ },
292
+ unstable_onBranchChange: onBranchChange,
293
+ }),
294
+ }),
295
+ );
296
+ handle.subscribe(() => {});
297
+ const aui = handle.getClient();
298
+ try {
299
+ await vi.waitFor(() =>
300
+ expect(aui.thread.getState().messages.at(-1)?.id).toBe("other"),
301
+ );
302
+ flushTapSync(() => aui.thread.message({ id: "other" }).reload());
303
+ for (const chunk of [
304
+ { type: "start", messageId: "answer" },
305
+ { type: "text-start", id: "text" },
306
+ { type: "text-delta", id: "text", delta: "Partial" },
307
+ ] satisfies UIMessageChunk[])
308
+ stream.enqueue(
309
+ new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`),
310
+ );
311
+ await vi.waitFor(() =>
312
+ expect(aui.thread.getState().messages.at(-1)?.id).toBe("answer"),
313
+ );
314
+ flushTapSync(() => aui.thread.cancelRun());
315
+ await vi.waitFor(() => {
316
+ expect(aui.thread.getState().isRunning).toBe(false);
317
+ expect(aui.thread.getState().canResume).toBe(canResume);
318
+ });
319
+ flushTapSync(() =>
320
+ aui.thread
321
+ .message({ id: "answer" })
322
+ .switchToBranch({ branchId: "answer" }),
323
+ );
324
+ expect(storage.getStreamId("main")).toBe("stream-1");
325
+ expect(onBranchChange).not.toHaveBeenCalled();
326
+ flushTapSync(() =>
327
+ aui.thread
328
+ .message({ id: "answer" })
329
+ .switchToBranch({ branchId: "other" }),
330
+ );
331
+ await vi.waitFor(() => {
332
+ expect(aui.thread.getState().canResume).toBe(false);
333
+ expect(aui.thread.getState().messages.at(-1)?.id).toBe("other");
334
+ });
335
+ expect(onBranchChange).toHaveBeenCalledWith({
336
+ headId: "other",
337
+ visibleMessageIds: ["question", "other"],
338
+ });
339
+ if (canResume) {
340
+ await aui.thread.resumeRun({ parentId: "other" });
341
+ } else {
342
+ await expect(
343
+ aui.thread.resumeRun({ parentId: "other" }),
344
+ ).rejects.toThrow("Runtime does not support resuming runs.");
345
+ }
346
+ expect(fetch).toHaveBeenCalledOnce();
347
+ expect(aui.thread.getState().messages.at(-1)?.parts[0]).toMatchObject({
348
+ type: "text",
349
+ text: "Other branch",
350
+ });
351
+ } finally {
352
+ handle.destroy();
353
+ storage.clear();
354
+ }
355
+ },
356
+ );
357
+
358
+ it.each(
359
+ ([204, 404, "network", "aborted"] as const).flatMap((result) =>
360
+ (["cancelled", "failed"] as const).map(
361
+ (interruption) => [result, interruption] as const,
362
+ ),
363
+ ),
364
+ )(
365
+ "handles reconnect result %s after a %s stream",
366
+ async (result, interruption) => {
367
+ const storage = createResumableSessionStorage({
368
+ key: `resume-${result}-${interruption}`,
369
+ });
370
+ storage.clear();
371
+ const onResumeError = vi.fn();
372
+ const onError = vi.fn();
373
+ const streamError = new Error("initial stream disconnected");
374
+ let initial!: ReadableStreamDefaultController<Uint8Array>;
375
+ const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValueOnce(
376
+ new Response(
377
+ new ReadableStream({
378
+ start: (controller) => {
379
+ initial = controller;
380
+ },
381
+ }),
382
+ {
383
+ headers: {
384
+ "content-type": "text/event-stream",
385
+ [RESUMABLE_STREAM_ID_HEADER]: "stream-1",
386
+ },
387
+ },
388
+ ),
389
+ );
390
+ if (result === "network")
391
+ fetch.mockRejectedValueOnce(new Error("offline"));
392
+ else if (result === "aborted") {
393
+ fetch.mockImplementationOnce(
394
+ (_input, init) =>
395
+ new Promise<Response>((_resolve, reject) => {
396
+ init?.signal?.addEventListener(
397
+ "abort",
398
+ () => reject(new DOMException("Stopped", "AbortError")),
399
+ { once: true },
400
+ );
401
+ }),
402
+ );
403
+ } else
404
+ fetch.mockResolvedValueOnce(new Response(null, { status: result }));
405
+ const Host = createHost({});
406
+ const handle = createAssistantClient(
407
+ AuiConfig({
408
+ threads: Host({
409
+ canResume: true,
410
+ transport: new AssistantChatTransport({
411
+ fetch,
412
+ resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
413
+ }),
414
+ onResumeError,
415
+ onError,
416
+ }),
417
+ }),
418
+ );
419
+ handle.subscribe(() => {});
420
+ const aui = handle.getClient();
421
+ try {
422
+ flushTapSync(() => aui.composer.setText("continue"));
423
+ flushTapSync(() => aui.composer.send());
424
+ initial.enqueue(
425
+ new TextEncoder().encode(
426
+ 'data: {"type":"start","messageId":"answer"}\n\n',
427
+ ),
428
+ );
429
+ await vi.waitFor(() => {
430
+ expect(aui.thread.getState().isRunning).toBe(true);
431
+ expect(aui.thread.getState().messages.at(-1)?.id).toBe("answer");
432
+ });
433
+ if (interruption === "cancelled") {
434
+ flushTapSync(() => aui.thread.cancelRun());
435
+ } else {
436
+ initial.error(streamError);
437
+ await vi.waitFor(() =>
438
+ expect(onError).toHaveBeenCalledWith(streamError),
439
+ );
440
+ }
441
+ await vi.waitFor(() =>
442
+ expect(aui.thread.getState().canResume).toBe(true),
443
+ );
444
+ const pending = Promise.resolve(
445
+ aui.thread.resumeRun({ parentId: "answer" }),
446
+ );
447
+ if (result === "aborted") {
448
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
449
+ flushTapSync(() => aui.thread.cancelRun());
450
+ }
451
+ const isResumeError = result === 404 || result === "network";
452
+ if (!isResumeError) {
453
+ await pending;
454
+ } else {
455
+ const resumeError: unknown = await pending.catch(
456
+ (error: unknown) => error,
457
+ );
458
+ expect(resumeError).toBeInstanceOf(Error);
459
+ expect(resumeError).not.toBe(streamError);
460
+ expect(onResumeError).toHaveBeenCalledWith(resumeError);
461
+ expect(onError).toHaveBeenLastCalledWith(resumeError);
462
+ }
463
+ const keepsCheckpoint = result === "aborted" || result === "network";
464
+ await vi.waitFor(() =>
465
+ expect(aui.thread.getState().canResume).toBe(keepsCheckpoint),
466
+ );
467
+ expect(storage.getStreamId("main")).toBe(
468
+ keepsCheckpoint ? "stream-1" : null,
469
+ );
470
+ expect(fetch).toHaveBeenCalledTimes(2);
471
+ expect(onResumeError).toHaveBeenCalledTimes(isResumeError ? 1 : 0);
472
+ expect(onError).toHaveBeenCalledTimes(
473
+ (interruption === "failed" ? 1 : 0) + (isResumeError ? 1 : 0),
474
+ );
475
+ expect(aui.thread.getState().messages).toHaveLength(2);
476
+ if (result === "network") {
477
+ fetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
478
+ await aui.thread.resumeRun({ parentId: "answer" });
479
+ expect(fetch).toHaveBeenCalledTimes(3);
480
+ expect(storage.getStreamId("main")).toBeNull();
481
+ }
482
+ } finally {
483
+ handle.destroy();
484
+ storage.clear();
485
+ }
486
+ },
487
+ );
488
+
489
+ it("does not advertise manual resume with canResume true without a pending stream id", async () => {
490
+ const storage = createResumableSessionStorage({
491
+ key: "resume-without-stream-id",
492
+ });
493
+ storage.clear();
494
+ const fetch = vi.fn<typeof globalThis.fetch>();
495
+ const Host = createHost({});
496
+ const handle = createAssistantClient(
497
+ AuiConfig({
498
+ threads: Host({
499
+ canResume: true,
500
+ transport: new AssistantChatTransport({
501
+ fetch,
502
+ resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
503
+ }),
504
+ }),
505
+ }),
506
+ );
507
+ handle.subscribe(() => {});
508
+
509
+ try {
510
+ expect(storage.getStreamId("main")).toBeNull();
511
+ await vi.waitFor(() =>
512
+ expect(handle.getClient().thread.getState().canResume).toBe(false),
513
+ );
514
+ expect(fetch).not.toHaveBeenCalled();
515
+ } finally {
516
+ handle.destroy();
517
+ storage.clear();
518
+ }
519
+ });
520
+
521
+ it("does not advertise manual resume from a stored stream id alone", async () => {
522
+ const storage = createResumableSessionStorage({
523
+ key: "resume-without-opt-in",
524
+ });
525
+ storage.clear();
526
+ let initial!: ReadableStreamDefaultController<Uint8Array>;
527
+ const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
528
+ new Response(
529
+ new ReadableStream({ start: (controller) => (initial = controller) }),
530
+ {
531
+ headers: {
532
+ "content-type": "text/event-stream",
533
+ [RESUMABLE_STREAM_ID_HEADER]: "stream-1",
534
+ },
535
+ },
536
+ ),
537
+ );
538
+ const Host = createHost({});
539
+ const handle = createAssistantClient(
540
+ AuiConfig({
541
+ threads: Host({
542
+ transport: new AssistantChatTransport({
543
+ fetch,
544
+ resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
545
+ }),
546
+ }),
547
+ }),
548
+ );
549
+ handle.subscribe(() => {});
550
+ const aui = handle.getClient();
551
+
552
+ try {
553
+ flushTapSync(() => aui.composer.setText("continue this response"));
554
+ flushTapSync(() => aui.composer.send());
555
+ for (const chunk of [
556
+ { type: "start", messageId: "answer" },
557
+ { type: "text-start", id: "text" },
558
+ { type: "text-delta", id: "text", delta: "Partial" },
559
+ ] satisfies UIMessageChunk[]) {
560
+ initial.enqueue(
561
+ new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`),
562
+ );
563
+ }
564
+ await vi.waitFor(() =>
565
+ expect(aui.thread.getState().messages.at(-1)?.parts[0]).toMatchObject({
566
+ type: "text",
567
+ text: "Partial",
568
+ }),
569
+ );
570
+ flushTapSync(() => aui.thread.cancelRun());
571
+ await vi.waitFor(() =>
572
+ expect(aui.thread.getState().isRunning).toBe(false),
573
+ );
574
+ expect(storage.getStreamId("main")).toBe("stream-1");
575
+ expect(aui.thread.getState().canResume).toBe(false);
576
+ await expect(
577
+ aui.thread.resumeRun({ parentId: "answer" }),
578
+ ).rejects.toThrow("Runtime does not support resuming runs.");
579
+ await nextTask();
580
+ expect(fetch).toHaveBeenCalledOnce();
581
+ } finally {
582
+ handle.destroy();
583
+ storage.clear();
584
+ }
585
+ });
586
+
587
+ it.each(
588
+ [false, true].flatMap((canResume) =>
589
+ ["owned", "threads"].flatMap((owner) =>
590
+ ["send", "edit", "reload"].map((action) => ({
591
+ canResume,
592
+ owner,
593
+ action,
594
+ })),
595
+ ),
596
+ ),
597
+ )(
598
+ "invalidates the old checkpoint before a failed $action only with canResume=$canResume ($owner)",
599
+ async ({ canResume, owner, action }) => {
600
+ const storage = createResumableSessionStorage({
601
+ key: `failed-${action}-${owner}-${canResume}`,
602
+ });
603
+ storage.clear();
604
+ let initial!: ReadableStreamDefaultController<Uint8Array>;
605
+ let rejectRequest!: (error: Error) => void;
606
+ const error = new Error("offline before response headers");
607
+ const onError = vi.fn();
608
+ const fetch = vi
609
+ .fn<typeof globalThis.fetch>()
610
+ .mockImplementation(
611
+ () =>
612
+ new Promise<Response>((_resolve, reject) => {
613
+ rejectRequest = reject;
614
+ }),
615
+ )
616
+ .mockResolvedValueOnce(
617
+ new Response(
618
+ new ReadableStream({
619
+ start: (controller) => (initial = controller),
620
+ }),
621
+ {
622
+ headers: {
623
+ "content-type": "text/event-stream",
624
+ [RESUMABLE_STREAM_ID_HEADER]: "old-stream",
625
+ },
626
+ },
627
+ ),
628
+ );
629
+ const options = {
630
+ canResume,
631
+ onError,
632
+ transport: new AssistantChatTransport({
633
+ fetch,
634
+ resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
635
+ }),
636
+ };
637
+ const Host = createHost({});
638
+ const handle = createAssistantClient(
639
+ AuiConfig({
640
+ threads: owner === "threads" ? AISDKThreads(options) : Host(options),
641
+ }),
642
+ );
643
+ handle.subscribe(() => {});
644
+ const aui = handle.getClient();
645
+ try {
646
+ flushTapSync(() => aui.composer.setText("first question"));
647
+ flushTapSync(() => aui.composer.send());
648
+ for (const chunk of [
649
+ { type: "start", messageId: "answer" },
650
+ { type: "text-start", id: "text" },
651
+ { type: "text-delta", id: "text", delta: "Partial" },
652
+ ] satisfies UIMessageChunk[])
653
+ initial.enqueue(
654
+ new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`),
655
+ );
656
+ await vi.waitFor(() =>
657
+ expect(aui.thread.getState().messages.at(-1)?.id).toBe("answer"),
658
+ );
659
+ flushTapSync(() => aui.thread.cancelRun());
660
+ await vi.waitFor(() => {
661
+ expect(aui.thread.getState().isRunning).toBe(false);
662
+ expect(aui.thread.getState().canResume).toBe(canResume);
663
+ });
664
+ expect(storage.getStreamId("main")).toBe("old-stream");
665
+
666
+ if (action === "send") {
667
+ flushTapSync(() => aui.composer.setText("follow-up"));
668
+ flushTapSync(() => aui.composer.send());
669
+ } else if (action === "edit") {
670
+ const edit = () => aui.thread.message({ index: 0 }).composer();
671
+ flushTapSync(() => edit().beginEdit());
672
+ flushTapSync(() => edit().setText("edited question"));
673
+ flushTapSync(() => edit().send());
674
+ } else {
675
+ flushTapSync(() => aui.thread.message({ id: "answer" }).reload());
676
+ }
677
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
678
+ expect(storage.getStreamId("main")).toBe(
679
+ canResume ? null : "old-stream",
680
+ );
681
+ rejectRequest(error);
682
+ await vi.waitFor(() => {
683
+ expect(onError).toHaveBeenCalledWith(error);
684
+ expect(aui.thread.getState().isRunning).toBe(false);
685
+ expect(aui.thread.getState().canResume).toBe(false);
686
+ });
687
+ if (canResume) {
688
+ const messages = aui.thread.getState().messages;
689
+ await aui.thread.resumeRun({ parentId: messages.at(-1)?.id ?? null });
690
+ expect(aui.thread.getState().messages).toEqual(messages);
691
+ expect(fetch).toHaveBeenCalledTimes(2);
692
+ }
693
+ } finally {
694
+ rejectRequest?.(error);
695
+ handle.destroy();
696
+ storage.clear();
697
+ }
698
+ },
699
+ );
700
+
701
+ it("allows replacing a resumable transport with one without checkpoint support", async () => {
702
+ const storage = createResumableSessionStorage({ key: "replace-resumable" });
703
+ storage.clear();
704
+ const onError = vi.fn();
705
+ let transport: ChatTransport<UIMessage> = new AssistantChatTransport({
706
+ resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
707
+ });
708
+ const listeners = new Set<() => void>();
709
+ const Host = createHost({});
710
+ const handle = createAssistantClient({
711
+ getConfig: () =>
712
+ AuiConfig({ threads: Host({ transport, canResume: true, onError }) }),
713
+ subscribe: (listener) => {
714
+ listeners.add(listener);
715
+ return () => listeners.delete(listener);
716
+ },
717
+ });
718
+ handle.subscribe(() => {});
719
+ const sendMessages = vi.fn(
720
+ async () =>
721
+ new ReadableStream<UIMessageChunk>({
722
+ start(controller) {
723
+ controller.enqueue({ type: "start", messageId: "answer" });
724
+ controller.enqueue({ type: "finish" });
725
+ controller.close();
726
+ },
727
+ }),
728
+ );
729
+ try {
730
+ transport = {
731
+ sendMessages,
732
+ reconnectToStream: vi.fn(async () => null),
733
+ };
734
+ flushTapSync(() => listeners.forEach((listener) => listener()));
735
+ await nextTask();
736
+ const aui = handle.getClient();
737
+ flushTapSync(() => aui.composer.setText("new transport"));
738
+ flushTapSync(() => aui.composer.send());
739
+ await vi.waitFor(() => {
740
+ expect(sendMessages).toHaveBeenCalledOnce();
741
+ expect(aui.thread.getState().isRunning).toBe(false);
742
+ });
743
+ expect(onError).not.toHaveBeenCalled();
744
+ expect(aui.thread.getState().canResume).toBe(false);
745
+ } finally {
746
+ handle.destroy();
747
+ storage.clear();
748
+ }
749
+ });
750
+
751
+ it("coalesces resume calls across chat updates and consumes the stopped response checkpoint on finish", async () => {
752
+ const storage = createResumableSessionStorage({ key: "composer-resume" });
753
+ storage.clear();
754
+ let initial!: ReadableStreamDefaultController<Uint8Array>;
755
+ let resumed!: ReadableStreamDefaultController<Uint8Array>;
756
+ const headers = {
757
+ "content-type": "text/event-stream",
758
+ [RESUMABLE_STREAM_ID_HEADER]: "stream-1",
759
+ };
760
+ const fetch = vi
761
+ .fn<typeof globalThis.fetch>()
762
+ .mockImplementation(async () => new Response(null, { status: 204 }))
763
+ .mockResolvedValueOnce(
764
+ new Response(
765
+ new ReadableStream({ start: (controller) => (initial = controller) }),
766
+ { headers },
767
+ ),
768
+ )
769
+ .mockResolvedValueOnce(
770
+ new Response(
771
+ new ReadableStream({ start: (controller) => (resumed = controller) }),
772
+ { headers },
773
+ ),
774
+ );
775
+ const transport = new AssistantChatTransport({
776
+ fetch,
777
+ resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
778
+ });
779
+ const emit = (
780
+ controller: ReadableStreamDefaultController<Uint8Array>,
781
+ ...chunks: UIMessageChunk[]
782
+ ) => {
783
+ for (const chunk of chunks) {
784
+ controller.enqueue(
785
+ new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`),
786
+ );
787
+ }
788
+ };
789
+ const Host = createHost({});
790
+ const handle = createAssistantClient(
791
+ AuiConfig({ threads: Host({ transport, canResume: true }) }),
792
+ );
793
+ handle.subscribe(() => {});
794
+ const aui = handle.getClient();
795
+
796
+ try {
797
+ expect(aui.thread.getState().canResume).toBe(false);
798
+ flushTapSync(() => aui.composer.setText("continue this response"));
799
+ flushTapSync(() => aui.composer.send());
800
+ emit(
801
+ initial,
802
+ { type: "start", messageId: "answer" },
803
+ { type: "text-start", id: "text" },
804
+ { type: "text-delta", id: "text", delta: "Partial" },
805
+ );
806
+ await vi.waitFor(() => {
807
+ expect(aui.thread.getState().messages.at(-1)?.parts[0]).toMatchObject({
808
+ type: "text",
809
+ text: "Partial",
810
+ });
811
+ expect(aui.thread.getState().isRunning).toBe(true);
812
+ expect(aui.thread.getState().canResume).toBe(false);
813
+ });
814
+ flushTapSync(() => aui.thread.cancelRun());
815
+ await vi.waitFor(() =>
816
+ expect(aui.thread.getState().canResume).toBe(true),
817
+ );
818
+ expect(storage.getStreamId("main")).toBe("stream-1");
819
+ expect(fetch).toHaveBeenCalledOnce();
820
+
821
+ const pending = aui.thread.resumeRun({ parentId: "answer" });
822
+ await vi.waitFor(() => {
823
+ expect(fetch).toHaveBeenCalledTimes(2);
824
+ expect(aui.thread.getState().isRunning).toBe(true);
825
+ });
826
+ await nextTask();
827
+ const duplicate = aui.thread.resumeRun({ parentId: "answer" });
828
+ const settled = Promise.allSettled([pending, duplicate]);
829
+ expect(fetch.mock.calls[1]?.[0]).toBe("/api/resume/stream-1");
830
+ emit(
831
+ resumed,
832
+ { type: "start", messageId: "answer" },
833
+ { type: "text-start", id: "text" },
834
+ { type: "text-delta", id: "text", delta: "Partial complete" },
835
+ { type: "text-end", id: "text" },
836
+ { type: "finish" },
837
+ );
838
+ resumed.close();
839
+ expect(await settled).toEqual([
840
+ { status: "fulfilled", value: undefined },
841
+ { status: "fulfilled", value: undefined },
842
+ ]);
843
+ expect(fetch).toHaveBeenCalledTimes(2);
844
+ await vi.waitFor(() => {
845
+ const state = aui.thread.getState();
846
+ expect(state.isRunning).toBe(false);
847
+ expect(state.canResume).toBe(false);
848
+ expect(state.messages).toHaveLength(2);
849
+ expect(state.messages.at(-1)?.id).toBe("answer");
850
+ expect(state.messages.at(-1)?.parts).toEqual([
851
+ expect.objectContaining({ type: "text", text: "Partial complete" }),
852
+ ]);
853
+ });
854
+ expect(storage.getStreamId("main")).toBeNull();
855
+ } finally {
856
+ handle.destroy();
857
+ storage.clear();
858
+ }
859
+ });
860
+
861
+ it("clears a dropped partial before replaying through the transport wrappers", async () => {
862
+ let dropped!: ReadableStreamDefaultController<UIMessageChunk>;
863
+ const chunks = (...values: UIMessageChunk[]) =>
864
+ new ReadableStream<UIMessageChunk>({
865
+ start(controller) {
866
+ for (const value of values) controller.enqueue(value);
867
+ controller.close();
868
+ },
869
+ });
870
+ const sourceTransport = {
871
+ resumeStreamIsReplay: true,
872
+ sendMessages: vi.fn<ChatTransport<UIMessage>["sendMessages"]>(
873
+ async () =>
874
+ new ReadableStream<UIMessageChunk>({
875
+ start(controller) {
876
+ dropped = controller;
877
+ controller.enqueue({ type: "start", messageId: "answer" });
878
+ controller.enqueue({ type: "text-start", id: "text" });
879
+ controller.enqueue({
880
+ type: "text-delta",
881
+ id: "text",
882
+ delta: "Partial",
883
+ });
884
+ },
885
+ }),
886
+ ),
887
+ reconnectToStream: vi.fn(async () =>
888
+ chunks(
889
+ { type: "start" },
890
+ { type: "text-start", id: "text" },
891
+ { type: "text-delta", id: "text", delta: "Partial complete" },
892
+ { type: "text-end", id: "text" },
893
+ { type: "finish" },
894
+ ),
895
+ ),
896
+ } satisfies ChatTransport<UIMessage>;
897
+ const dynamicTransport = new DynamicChatTransport(sourceTransport);
898
+ const owner = {};
899
+ dynamicTransport.registerThread("main", owner);
900
+ const proxy = dynamicTransport.createThreadProxy(owner, () => ({
901
+ runtime: {} as AssistantRuntime,
902
+ getThreadListItem: () => undefined,
903
+ }));
904
+ const chat = createChat({ id: "main", transport: proxy }, { current: {} });
905
+ const pending = chat.sendMessage({ text: "hello" });
906
+
907
+ await vi.waitFor(() => {
908
+ expect(chat.messages.at(-1)?.parts).toEqual([
909
+ expect.objectContaining({ type: "text", text: "Partial" }),
910
+ ]);
911
+ });
912
+ dropped.error(new TypeError("fetch failed"));
913
+ await pending;
914
+
915
+ await chat.resumeStream();
916
+
917
+ expect(chat.error).toBeUndefined();
918
+ expect(chat.messages).toHaveLength(2);
919
+ expect(chat.messages.at(-1)?.id).toBe("answer");
920
+ expect(chat.messages.at(-1)?.parts).toEqual([
921
+ { type: "text", text: "Partial complete", state: "done" },
922
+ ]);
923
+ });
924
+
925
+ it.each([undefined, "different-answer"])(
926
+ "AI SDK replay with messageId=%s appends instead of replacing a partial answer",
927
+ async (messageId) => {
928
+ const transport = {
929
+ sendMessages: vi.fn<ChatTransport<UIMessage>["sendMessages"]>(),
930
+ reconnectToStream: vi.fn(
931
+ async () =>
932
+ new ReadableStream<UIMessageChunk>({
933
+ start(controller) {
934
+ for (const chunk of [
935
+ {
936
+ type: "start",
937
+ ...(messageId !== undefined && { messageId }),
938
+ },
939
+ { type: "text-start", id: "text" },
940
+ { type: "text-delta", id: "text", delta: "Partial complete" },
941
+ { type: "text-end", id: "text" },
942
+ { type: "finish" },
943
+ ] satisfies UIMessageChunk[]) {
944
+ controller.enqueue(chunk);
945
+ }
946
+ controller.close();
947
+ },
948
+ }),
949
+ ),
950
+ } satisfies ChatTransport<UIMessage>;
951
+ const chat = createChat(
952
+ {
953
+ id: "main",
954
+ transport,
955
+ messages: [
956
+ {
957
+ id: "user",
958
+ role: "user",
959
+ parts: [{ type: "text", text: "hello" }],
960
+ },
961
+ {
962
+ id: "answer",
963
+ role: "assistant",
964
+ parts: [{ type: "text", text: "Partial" }],
965
+ },
966
+ ],
967
+ },
968
+ { current: {} },
969
+ );
970
+
971
+ await chat.resumeStream();
972
+
973
+ expect(chat.error).toBeUndefined();
974
+ expect(transport.sendMessages).not.toHaveBeenCalled();
975
+ expect(chat.messages).toHaveLength(3);
976
+ expect(chat.messages[1]?.parts).toEqual([
977
+ { type: "text", text: "Partial" },
978
+ ]);
979
+ expect(chat.messages.at(-1)?.id).not.toBe("answer");
980
+ expect(chat.messages.at(-1)?.parts).toEqual([
981
+ expect.objectContaining({ type: "text", text: "Partial complete" }),
982
+ ]);
983
+ },
984
+ );
985
+
986
+ it("stops an in-flight chat on client destroy when stopOnClientDestroy is omitted", async () => {
987
+ expect(await streamThenDestroy({})).toBe(1);
988
+ });
989
+
990
+ it("leaves an in-flight chat running on client destroy when stopOnClientDestroy is false", async () => {
991
+ expect(await streamThenDestroy({ stopOnClientDestroy: false })).toBe(0);
992
+ });
993
+ });