@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,194 @@
1
+ "use client";
2
+
3
+ import { resource, useResource } from "@ulearning/ai-tap";
4
+ import { useEffect, useMemo, useState } from "@ulearning/ai-tap";
5
+ import type { Chat } from "@ai-sdk/vue";
6
+ import type { UIMessage } from "ai";
7
+ import type { ChatTransport } from "ai";
8
+ import {
9
+ InMemoryThreadList,
10
+ inMemoryThreadListTransformScopes,
11
+ } from "@ulearning/ai-core/store";
12
+ import { ThreadClient } from "@ulearning/ai-core/store/internal";
13
+ import {
14
+ attachTransformScopes,
15
+ useAssistantClientRef,
16
+ useAssistantScopeEffect,
17
+ } from "@ulearning/ai-store/client";
18
+ import { AssistantChatTransport } from "../transport/AssistantChatTransport";
19
+ import {
20
+ createChat,
21
+ splitChatThreadOptions,
22
+ useChatThread,
23
+ type ChatThreadOptions,
24
+ } from "./useChatThread";
25
+ import { MessageRepository } from "@ulearning/ai-core/internal";
26
+ import { useResourceCleanup } from "./useResourceCleanup";
27
+
28
+ export type AISDKThreadsOptions<UI_MESSAGE extends UIMessage = UIMessage> =
29
+ Omit<ChatThreadOptions<UI_MESSAGE>, "id" | "transport" | "messages"> & {
30
+ /**
31
+ * The transport threads send through. A factory is invoked once per
32
+ * thread so each thread owns its instance. A plain
33
+ * `AssistantChatTransport` instance is cloned per thread (its
34
+ * assistant-ui wiring is per thread); any other transport instance is
35
+ * shared as-is. Defaults to one `AssistantChatTransport` per thread.
36
+ */
37
+ transport?:
38
+ | ChatTransport<UI_MESSAGE>
39
+ | (() => ChatTransport<UI_MESSAGE>)
40
+ | undefined;
41
+ };
42
+
43
+ type AISDKThreadChatOptions<UI_MESSAGE extends UIMessage = UIMessage> =
44
+ AISDKThreadsOptions<UI_MESSAGE>;
45
+
46
+ type ChatOptionsRef<UI_MESSAGE extends UIMessage> = {
47
+ current: AISDKThreadChatOptions<UI_MESSAGE> | undefined;
48
+ };
49
+
50
+ type ChatEntry<UI_MESSAGE extends UIMessage> = {
51
+ chat: Chat<UI_MESSAGE>;
52
+ transport: ChatTransport<UI_MESSAGE>;
53
+ repository: MessageRepository;
54
+ optionsRef: ChatOptionsRef<UI_MESSAGE>;
55
+ };
56
+
57
+ const createChatEntry = <UI_MESSAGE extends UIMessage>(
58
+ threadId: string,
59
+ options: AISDKThreadChatOptions<UI_MESSAGE> | undefined,
60
+ ): ChatEntry<UI_MESSAGE> => {
61
+ const optionsRef: ChatOptionsRef<UI_MESSAGE> = { current: options };
62
+ const { chatInit } = splitChatThreadOptions(
63
+ options as ChatThreadOptions<UI_MESSAGE> | undefined,
64
+ );
65
+ const transport =
66
+ typeof options?.transport === "function"
67
+ ? options.transport()
68
+ : options?.transport === undefined
69
+ ? new AssistantChatTransport()
70
+ : options.transport instanceof AssistantChatTransport
71
+ ? options.transport.__internal_clone()
72
+ : options.transport;
73
+ return {
74
+ chat: createChat({ ...chatInit, id: threadId, transport }, optionsRef),
75
+ transport,
76
+ repository: new MessageRepository(),
77
+ optionsRef,
78
+ };
79
+ };
80
+
81
+ const getOrCreateChatEntry = <UI_MESSAGE extends UIMessage>(
82
+ threadId: string,
83
+ options: AISDKThreadChatOptions<UI_MESSAGE> | undefined,
84
+ chats: Map<string, ChatEntry<UI_MESSAGE>>,
85
+ ): ChatEntry<UI_MESSAGE> => {
86
+ const existing = chats.get(threadId);
87
+ if (existing) return existing;
88
+ const created = createChatEntry(threadId, options);
89
+ chats.set(threadId, created);
90
+ return created;
91
+ };
92
+
93
+ const useAISDKChatThread = <UI_MESSAGE extends UIMessage = UIMessage>({
94
+ threadId,
95
+ options,
96
+ chats,
97
+ }: {
98
+ threadId: string;
99
+ options: AISDKThreadChatOptions<UI_MESSAGE> | undefined;
100
+ chats: Map<string, ChatEntry<UI_MESSAGE>>;
101
+ }) => {
102
+ const { chat, transport, repository } = getOrCreateChatEntry(
103
+ threadId,
104
+ options,
105
+ chats,
106
+ );
107
+
108
+ const fallbackItem = useMemo(
109
+ () => ({
110
+ initialize: async () => ({
111
+ remoteId: threadId,
112
+ externalId: undefined,
113
+ }),
114
+ }),
115
+ [threadId],
116
+ );
117
+ const runtime = useChatThread(
118
+ { ...options, transport } as ChatThreadOptions<UI_MESSAGE>,
119
+ {
120
+ id: threadId,
121
+ isMainThread: true,
122
+ getThreadListItem: () => fallbackItem,
123
+ chat,
124
+ messageRepositoryInstance: repository,
125
+ stopOnClientDestroy: false,
126
+ },
127
+ );
128
+
129
+ const clientRef = useAssistantClientRef();
130
+ useAssistantScopeEffect(
131
+ "modelContext",
132
+ () =>
133
+ runtime.registerModelContextProvider(clientRef.current!.modelContext()),
134
+ [runtime],
135
+ );
136
+
137
+ return useResource(ThreadClient({ runtime: runtime.thread }));
138
+ };
139
+
140
+ const AISDKChatThread = resource(useAISDKChatThread);
141
+
142
+ const useAISDKThreads = <UI_MESSAGE extends UIMessage = UIMessage>(
143
+ options?: AISDKThreadsOptions<UI_MESSAGE>,
144
+ ) => {
145
+ const threadOptions = options;
146
+ const [chats] = useState(() => new Map<string, ChatEntry<UI_MESSAGE>>());
147
+
148
+ useEffect(() => {
149
+ for (const { optionsRef } of chats.values()) {
150
+ optionsRef.current = threadOptions;
151
+ }
152
+ });
153
+
154
+ useResourceCleanup(true, () => {
155
+ for (const { chat } of chats.values()) {
156
+ void chat.stop().catch(() => {});
157
+ }
158
+ });
159
+
160
+ const thread = (id: string) => {
161
+ const element = AISDKChatThread({
162
+ threadId: id,
163
+ options: threadOptions,
164
+ chats,
165
+ });
166
+ return element;
167
+ };
168
+
169
+ return useResource(
170
+ InMemoryThreadList({
171
+ thread,
172
+ onDelete: (id) => {
173
+ void chats
174
+ .get(id)
175
+ ?.chat.stop()
176
+ .catch(() => {});
177
+ chats.delete(id);
178
+ },
179
+ }),
180
+ );
181
+ };
182
+
183
+ /**
184
+ * `AuiConfig` entry that runs one AI SDK chat per thread. Hosts the same
185
+ * per-thread orchestration as {@link AISDKChat} inside the client's own
186
+ * resource tree, so it works with any `AssistantClient` host, React or not.
187
+ * Threads live in memory for the client's lifetime and keep
188
+ * their history across switches; only the visible thread is mounted, and a
189
+ * switched-away chat keeps streaming into its stored state until it settles
190
+ * or the thread is deleted. Model context is registered on each mounted thread.
191
+ */
192
+ export const AISDKThreads = resource(useAISDKThreads);
193
+
194
+ attachTransformScopes(useAISDKThreads, inMemoryThreadListTransformScopes);
@@ -0,0 +1,228 @@
1
+ import type { AssistantRuntime } from "@ulearning/ai-core";
2
+ import type { UIMessage } from "@ai-sdk/react";
3
+ import type { ChatTransport, UIMessageChunk } from "ai";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { AssistantChatTransport } from "../transport/AssistantChatTransport";
6
+ import { DynamicChatTransport } from "./DynamicChatTransport";
7
+
8
+ const emptyStreamResponse = () =>
9
+ new Response(
10
+ new ReadableStream({ start: (controller) => controller.close() }),
11
+ {
12
+ headers: { "content-type": "text/event-stream" },
13
+ },
14
+ );
15
+
16
+ const sendMessagesOptions = (chatId: string) => ({
17
+ trigger: "submit-message" as const,
18
+ chatId,
19
+ messageId: undefined,
20
+ messages: [
21
+ {
22
+ id: `message-${chatId}`,
23
+ role: "user" as const,
24
+ parts: [{ type: "text" as const, text: "hello" }],
25
+ },
26
+ ],
27
+ abortSignal: undefined,
28
+ });
29
+
30
+ const createRuntime = (system: string) =>
31
+ ({
32
+ thread: {
33
+ getModelContext: () => ({ system }),
34
+ },
35
+ }) as AssistantRuntime;
36
+
37
+ describe("DynamicChatTransport", () => {
38
+ afterEach(() => {
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ it("forwards replay behavior through the dynamic transport and thread proxy", () => {
43
+ const createTransport = (
44
+ resumeStreamIsReplay: boolean,
45
+ ): ChatTransport<UIMessage> => ({
46
+ resumeStreamIsReplay,
47
+ sendMessages: async () => new ReadableStream<UIMessageChunk>(),
48
+ reconnectToStream: async () => new ReadableStream<UIMessageChunk>(),
49
+ });
50
+ const dynamicTransport = new DynamicChatTransport(createTransport(false));
51
+ const owner = {};
52
+ const proxy = dynamicTransport.createThreadProxy(owner, () => ({
53
+ runtime: createRuntime("system"),
54
+ getThreadListItem: () => undefined,
55
+ }));
56
+
57
+ expect(dynamicTransport.resumeStreamIsReplay).toBe(false);
58
+ expect(proxy.resumeStreamIsReplay).toBe(false);
59
+
60
+ dynamicTransport.setTransport(createTransport(true));
61
+
62
+ expect(dynamicTransport.resumeStreamIsReplay).toBe(true);
63
+ expect(proxy.resumeStreamIsReplay).toBe(true);
64
+ });
65
+
66
+ it("rejects sends without a registered thread context", () => {
67
+ const dynamicTransport = new DynamicChatTransport(
68
+ new AssistantChatTransport<UIMessage>(),
69
+ );
70
+
71
+ expect(() =>
72
+ dynamicTransport.sendMessages(sendMessagesOptions("missing-thread")),
73
+ ).toThrow(
74
+ 'DynamicChatTransport has no registered context for chat "missing-thread"',
75
+ );
76
+ });
77
+
78
+ it("keeps AssistantChatTransport wiring scoped per thread", async () => {
79
+ const createTransport = () => {
80
+ const bodies: Array<{ id: string; system: string }> = [];
81
+ const fetch = vi.fn(async (_input, init) => {
82
+ bodies.push(JSON.parse(String(init?.body)));
83
+ return emptyStreamResponse();
84
+ });
85
+ return {
86
+ bodies,
87
+ fetch,
88
+ transport: new AssistantChatTransport<UIMessage>({
89
+ fetch,
90
+ }),
91
+ };
92
+ };
93
+ const initial = createTransport();
94
+ const dynamicTransport = new DynamicChatTransport(initial.transport);
95
+ const ownerA = {};
96
+ const ownerB = {};
97
+
98
+ dynamicTransport.setThreadContext(
99
+ "thread-a",
100
+ ownerA,
101
+ createRuntime("system-a"),
102
+ () => ({
103
+ initialize: async () => ({
104
+ remoteId: "remote-a",
105
+ externalId: undefined,
106
+ }),
107
+ }),
108
+ );
109
+ dynamicTransport.setThreadContext(
110
+ "thread-b",
111
+ ownerB,
112
+ createRuntime("system-b"),
113
+ () => ({
114
+ initialize: async () => ({
115
+ remoteId: "remote-b",
116
+ externalId: undefined,
117
+ }),
118
+ }),
119
+ );
120
+
121
+ await dynamicTransport.sendMessages(sendMessagesOptions("thread-a"));
122
+ await dynamicTransport.sendMessages(sendMessagesOptions("thread-b"));
123
+
124
+ const replacement = createTransport();
125
+ dynamicTransport.setTransport(replacement.transport);
126
+ await dynamicTransport.sendMessages(sendMessagesOptions("thread-a"));
127
+ await dynamicTransport.sendMessages(sendMessagesOptions("thread-b"));
128
+
129
+ expect(initial.fetch).toHaveBeenCalledTimes(2);
130
+ expect(replacement.fetch).toHaveBeenCalledTimes(2);
131
+ expect(initial.bodies).toEqual([
132
+ expect.objectContaining({ id: "remote-a", system: "system-a" }),
133
+ expect.objectContaining({ id: "remote-b", system: "system-b" }),
134
+ ]);
135
+ expect(replacement.bodies).toEqual([
136
+ expect.objectContaining({ id: "remote-a", system: "system-a" }),
137
+ expect.objectContaining({ id: "remote-b", system: "system-b" }),
138
+ ]);
139
+ expect(dynamicTransport.getCurrentTransport("thread-a")).not.toBe(
140
+ dynamicTransport.getCurrentTransport("thread-b"),
141
+ );
142
+ });
143
+
144
+ it("defers replacement clones until a thread uses the transport", async () => {
145
+ const clone = vi.spyOn(
146
+ AssistantChatTransport.prototype,
147
+ "__internal_clone",
148
+ );
149
+ const dynamicTransport = new DynamicChatTransport(
150
+ new AssistantChatTransport<UIMessage>(),
151
+ );
152
+ const owner = {};
153
+ dynamicTransport.setThreadContext(
154
+ "thread-a",
155
+ owner,
156
+ createRuntime("system-a"),
157
+ () => ({
158
+ initialize: async () => ({
159
+ remoteId: "remote-a",
160
+ externalId: undefined,
161
+ }),
162
+ }),
163
+ );
164
+ clone.mockClear();
165
+
166
+ for (let index = 0; index < 10; index++) {
167
+ dynamicTransport.setTransport(
168
+ new AssistantChatTransport<UIMessage>({
169
+ fetch: async () => emptyStreamResponse(),
170
+ }),
171
+ );
172
+ }
173
+ dynamicTransport.setThreadContext(
174
+ "thread-a",
175
+ owner,
176
+ createRuntime("updated-system-a"),
177
+ () => ({
178
+ initialize: async () => ({
179
+ remoteId: "updated-remote-a",
180
+ externalId: undefined,
181
+ }),
182
+ }),
183
+ );
184
+
185
+ expect(clone).not.toHaveBeenCalled();
186
+
187
+ await dynamicTransport.sendMessages(sendMessagesOptions("thread-a"));
188
+
189
+ expect(clone).toHaveBeenCalledOnce();
190
+ });
191
+
192
+ it("retries a replacement clone after cloning throws", async () => {
193
+ const initialFetch = vi.fn(async () => emptyStreamResponse());
194
+ const dynamicTransport = new DynamicChatTransport(
195
+ new AssistantChatTransport<UIMessage>({ fetch: initialFetch }),
196
+ );
197
+ dynamicTransport.setThreadContext(
198
+ "thread-a",
199
+ {},
200
+ createRuntime("system-a"),
201
+ () => ({
202
+ initialize: async () => ({
203
+ remoteId: "remote-a",
204
+ externalId: undefined,
205
+ }),
206
+ }),
207
+ );
208
+ await dynamicTransport.sendMessages(sendMessagesOptions("thread-a"));
209
+
210
+ const replacement = new AssistantChatTransport<UIMessage>();
211
+ const cloneError = new Error("clone failed");
212
+ const clone = vi
213
+ .spyOn(replacement, "__internal_clone")
214
+ .mockImplementation(() => {
215
+ throw cloneError;
216
+ });
217
+ dynamicTransport.setTransport(replacement);
218
+
219
+ expect(() =>
220
+ dynamicTransport.sendMessages(sendMessagesOptions("thread-a")),
221
+ ).toThrow(cloneError);
222
+ expect(() =>
223
+ dynamicTransport.sendMessages(sendMessagesOptions("thread-a")),
224
+ ).toThrow(cloneError);
225
+ expect(clone).toHaveBeenCalledTimes(2);
226
+ expect(initialFetch).toHaveBeenCalledOnce();
227
+ });
228
+ });
@@ -0,0 +1,281 @@
1
+ import type { AssistantRuntime } from "@ulearning/ai-core";
2
+ import type { UIMessage } from "ai";
3
+ import type { ChatTransport } from "ai";
4
+ import {
5
+ AssistantChatTransport,
6
+ type InitializableThreadListItem,
7
+ } from "../transport/AssistantChatTransport";
8
+ import type { ResumableClientStorage } from "../transport/resumable";
9
+ import { getResumableAdapter } from "./getResumableAdapter";
10
+
11
+ const resumedStreamIdsByStorage = new WeakMap<
12
+ ResumableClientStorage,
13
+ Set<string>
14
+ >();
15
+
16
+ export const getResumedStreamIds = (
17
+ storage: ResumableClientStorage | undefined,
18
+ ) => {
19
+ if (!storage) return new Set<string>();
20
+ let resumedStreamIds = resumedStreamIdsByStorage.get(storage);
21
+ if (!resumedStreamIds) {
22
+ resumedStreamIds = new Set();
23
+ resumedStreamIdsByStorage.set(storage, resumedStreamIds);
24
+ }
25
+ return resumedStreamIds;
26
+ };
27
+
28
+ type ResumableStorageSubscription = {
29
+ listener: () => void;
30
+ threadId: string | undefined;
31
+ unsubscribe: (() => void) | undefined;
32
+ };
33
+
34
+ class DynamicResumableStorage implements ResumableClientStorage {
35
+ private readonly subscriptions = new Set<ResumableStorageSubscription>();
36
+ private storage: ResumableClientStorage | undefined;
37
+ private resumedStreamIds: Set<string>;
38
+ private hasPendingNotification = false;
39
+
40
+ constructor(storage: ResumableClientStorage | undefined) {
41
+ this.storage = storage;
42
+ this.resumedStreamIds = getResumedStreamIds(storage);
43
+ }
44
+
45
+ public setStorage(storage: ResumableClientStorage | undefined) {
46
+ if (this.storage === storage) return;
47
+ this.storage = storage;
48
+ const nextResumedStreamIds = getResumedStreamIds(storage);
49
+ for (const streamId of this.resumedStreamIds) {
50
+ nextResumedStreamIds.add(streamId);
51
+ }
52
+ this.resumedStreamIds = nextResumedStreamIds;
53
+ this.hasPendingNotification = true;
54
+ }
55
+
56
+ public flushChange() {
57
+ if (!this.hasPendingNotification) return;
58
+ this.hasPendingNotification = false;
59
+ for (const subscription of this.subscriptions) {
60
+ subscription.unsubscribe?.();
61
+ subscription.unsubscribe = this.storage?.subscribe?.(
62
+ subscription.listener,
63
+ subscription.threadId,
64
+ );
65
+ subscription.listener();
66
+ }
67
+ }
68
+
69
+ public getResumedStreamIds() {
70
+ return this.resumedStreamIds;
71
+ }
72
+
73
+ public getStreamId(threadId?: string) {
74
+ return this.storage?.getStreamId(threadId) ?? null;
75
+ }
76
+
77
+ public setStreamId(id: string, threadId?: string) {
78
+ this.storage?.setStreamId(id, threadId);
79
+ }
80
+
81
+ public clear(threadId?: string) {
82
+ this.storage?.clear(threadId);
83
+ }
84
+
85
+ public subscribe(listener: () => void, threadId?: string) {
86
+ const subscription: ResumableStorageSubscription = {
87
+ listener,
88
+ threadId,
89
+ unsubscribe: this.storage?.subscribe?.(listener, threadId),
90
+ };
91
+ this.subscriptions.add(subscription);
92
+ return () => {
93
+ this.subscriptions.delete(subscription);
94
+ subscription.unsubscribe?.();
95
+ };
96
+ }
97
+ }
98
+
99
+ type ThreadTransportContext<UI_MESSAGE extends UIMessage> = {
100
+ owner: object;
101
+ sourceTransport?: ChatTransport<UI_MESSAGE> | undefined;
102
+ transport?: ChatTransport<UI_MESSAGE> | undefined;
103
+ runtime?: AssistantRuntime | undefined;
104
+ getThreadListItem?:
105
+ | (() => InitializableThreadListItem | undefined)
106
+ | undefined;
107
+ };
108
+
109
+ type ThreadTransportBinding = {
110
+ runtime: AssistantRuntime;
111
+ getThreadListItem: () => InitializableThreadListItem | undefined;
112
+ };
113
+
114
+ export class DynamicChatTransport<
115
+ UI_MESSAGE extends UIMessage,
116
+ > implements ChatTransport<UI_MESSAGE> {
117
+ private readonly threadContexts = new Map<
118
+ string,
119
+ ThreadTransportContext<UI_MESSAGE>
120
+ >();
121
+ private transport: ChatTransport<UI_MESSAGE>;
122
+ private readonly resumableStorage: DynamicResumableStorage;
123
+
124
+ constructor(transport: ChatTransport<UI_MESSAGE>) {
125
+ this.transport = transport;
126
+ this.resumableStorage = new DynamicResumableStorage(
127
+ getResumableAdapter(transport)?.storage,
128
+ );
129
+ }
130
+
131
+ public readonly sendMessages: ChatTransport<UI_MESSAGE>["sendMessages"] = (
132
+ options,
133
+ ) => this.getTransport(options.chatId).sendMessages(options);
134
+
135
+ public readonly reconnectToStream: ChatTransport<UI_MESSAGE>["reconnectToStream"] =
136
+ (options) => this.getTransport(options.chatId).reconnectToStream(options);
137
+
138
+ public get resumeStreamIsReplay() {
139
+ return this.transport.resumeStreamIsReplay ?? false;
140
+ }
141
+
142
+ public readonly getCurrentTransport = (chatId: string) =>
143
+ this.getThreadTransport(this.threadContexts.get(chatId)) ?? this.transport;
144
+
145
+ public readonly getCurrentResumableStorage = () => this.resumableStorage;
146
+
147
+ public readonly getResumedStreamIds = () =>
148
+ this.resumableStorage.getResumedStreamIds();
149
+
150
+ public createThreadProxy(
151
+ owner: object,
152
+ getBinding: () => ThreadTransportBinding,
153
+ ): ChatTransport<UI_MESSAGE> {
154
+ const dynamicTransport = this;
155
+ return {
156
+ get resumeStreamIsReplay() {
157
+ return dynamicTransport.resumeStreamIsReplay;
158
+ },
159
+ sendMessages: (options) =>
160
+ this.getOrCreateBoundTransport(
161
+ options.chatId,
162
+ owner,
163
+ getBinding,
164
+ ).sendMessages(options),
165
+ reconnectToStream: (options) =>
166
+ this.getOrCreateBoundTransport(
167
+ options.chatId,
168
+ owner,
169
+ getBinding,
170
+ ).reconnectToStream(options),
171
+ };
172
+ }
173
+
174
+ public setTransport(transport: ChatTransport<UI_MESSAGE>) {
175
+ if (this.transport === transport) return;
176
+ this.transport = transport;
177
+ this.resumableStorage.setStorage(getResumableAdapter(transport)?.storage);
178
+ }
179
+
180
+ public flushTransportChange() {
181
+ this.resumableStorage.flushChange();
182
+ }
183
+
184
+ public registerThread(chatId: string, owner: object) {
185
+ const existing = this.threadContexts.get(chatId);
186
+ if (existing?.owner === owner) return;
187
+ this.threadContexts.set(chatId, {
188
+ owner,
189
+ });
190
+ }
191
+
192
+ public setThreadContext(
193
+ chatId: string,
194
+ owner: object,
195
+ runtime: AssistantRuntime,
196
+ getThreadListItem: () => InitializableThreadListItem | undefined,
197
+ ) {
198
+ this.registerThread(chatId, owner);
199
+ const context = this.threadContexts.get(chatId)!;
200
+ context.runtime = runtime;
201
+ context.getThreadListItem = getThreadListItem;
202
+ if (context.transport === undefined) {
203
+ this.getThreadTransport(context);
204
+ return;
205
+ }
206
+ if (
207
+ context.sourceTransport === this.transport &&
208
+ context.transport !== undefined
209
+ ) {
210
+ this.wireTransport(context, context.transport);
211
+ }
212
+ }
213
+
214
+ public unregisterThread(chatId: string, owner: object) {
215
+ if (this.threadContexts.get(chatId)?.owner === owner) {
216
+ this.threadContexts.delete(chatId);
217
+ }
218
+ }
219
+
220
+ private getTransport(chatId: string) {
221
+ const context = this.threadContexts.get(chatId);
222
+ if (!context) {
223
+ throw new Error(
224
+ `DynamicChatTransport has no registered context for chat "${chatId}"`,
225
+ );
226
+ }
227
+ return this.getThreadTransport(context)!;
228
+ }
229
+
230
+ private getBoundTransport(
231
+ chatId: string,
232
+ owner: object,
233
+ binding: ThreadTransportBinding,
234
+ ) {
235
+ this.registerThread(chatId, owner);
236
+ const context = this.threadContexts.get(chatId)!;
237
+ context.runtime = binding.runtime;
238
+ context.getThreadListItem = binding.getThreadListItem;
239
+ return this.getThreadTransport(context)!;
240
+ }
241
+
242
+ private getOrCreateBoundTransport(
243
+ chatId: string,
244
+ owner: object,
245
+ getBinding: () => ThreadTransportBinding,
246
+ ) {
247
+ const context = this.threadContexts.get(chatId);
248
+ return context?.owner === owner
249
+ ? this.getThreadTransport(context)!
250
+ : this.getBoundTransport(chatId, owner, getBinding());
251
+ }
252
+
253
+ private getThreadTransport(
254
+ context: ThreadTransportContext<UI_MESSAGE> | undefined,
255
+ ) {
256
+ if (!context) return undefined;
257
+ if (context.sourceTransport !== this.transport) {
258
+ context.transport = this.createThreadTransport(this.transport);
259
+ context.sourceTransport = this.transport;
260
+ }
261
+ this.wireTransport(context, context.transport!);
262
+ return context.transport!;
263
+ }
264
+
265
+ private createThreadTransport(transport: ChatTransport<UI_MESSAGE>) {
266
+ return transport instanceof AssistantChatTransport
267
+ ? transport.__internal_clone()
268
+ : transport;
269
+ }
270
+
271
+ private wireTransport(
272
+ context: ThreadTransportContext<UI_MESSAGE>,
273
+ transport: ChatTransport<UI_MESSAGE>,
274
+ ) {
275
+ if (!(transport instanceof AssistantChatTransport)) return;
276
+ if (context.runtime) transport.setRuntime(context.runtime);
277
+ if (context.getThreadListItem) {
278
+ transport.__internal_setGetThreadListItem(context.getThreadListItem);
279
+ }
280
+ }
281
+ }