@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,149 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "./__tests__/tap";
4
+ import { useEffect } from "@ulearning/ai-tap";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { Chat } from "@ai-sdk/vue";
7
+ import {
8
+ AssistantChatTransport,
9
+ type InitializableThreadListItem,
10
+ } from "../transport/AssistantChatTransport";
11
+ import { useChatThread } from "./useChatThread";
12
+
13
+ const itemFor = (remoteId: string) => ({
14
+ initialize: async () => ({ remoteId, externalId: undefined }),
15
+ });
16
+
17
+ const finishedStream = () =>
18
+ new Response(
19
+ new ReadableStream<Uint8Array>({
20
+ start(controller) {
21
+ controller.enqueue(
22
+ new TextEncoder().encode(
23
+ 'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
24
+ ),
25
+ );
26
+ controller.close();
27
+ },
28
+ }),
29
+ { headers: { "content-type": "text/event-stream" } },
30
+ );
31
+
32
+ describe("useChatThread shared transport isolation", () => {
33
+ it("gives each thread its own clone wired to its own thread-list item", async () => {
34
+ const transport = new AssistantChatTransport({ api: "/api/chat" });
35
+ const setRuntime = vi.spyOn(transport, "setRuntime");
36
+ const setGetItem = vi.spyOn(transport, "__internal_setGetThreadListItem");
37
+ const clones: AssistantChatTransport<never>[] = [];
38
+ const getters: (() => InitializableThreadListItem | undefined)[] = [];
39
+ const realClone = transport.__internal_clone.bind(transport);
40
+ vi.spyOn(transport, "__internal_clone").mockImplementation(() => {
41
+ const clone = realClone();
42
+ const index = clones.push(clone as AssistantChatTransport<never>) - 1;
43
+ vi.spyOn(clone, "__internal_setGetThreadListItem").mockImplementation(
44
+ (getter) => {
45
+ getters[index] = getter;
46
+ },
47
+ );
48
+ return clone;
49
+ });
50
+
51
+ renderHook(() =>
52
+ useChatThread(
53
+ { transport },
54
+ {
55
+ id: "thread-a",
56
+ isMainThread: true,
57
+ getThreadListItem: () => itemFor("remote-a"),
58
+ },
59
+ ),
60
+ );
61
+ renderHook(() =>
62
+ useChatThread(
63
+ { transport },
64
+ {
65
+ id: "thread-b",
66
+ isMainThread: false,
67
+ getThreadListItem: () => itemFor("remote-b"),
68
+ },
69
+ ),
70
+ );
71
+
72
+ expect(setRuntime).not.toHaveBeenCalled();
73
+ expect(setGetItem).not.toHaveBeenCalled();
74
+ expect(clones).toHaveLength(2);
75
+ expect(clones[0]).not.toBe(clones[1]);
76
+
77
+ const remoteIdOf = async (
78
+ getter?: () => InitializableThreadListItem | undefined,
79
+ ) => (await getter?.()?.initialize())?.remoteId;
80
+ expect(await remoteIdOf(getters[0])).toBe("remote-a");
81
+ expect(await remoteIdOf(getters[1])).toBe("remote-b");
82
+ });
83
+
84
+ it("sends each thread's request with that thread's remoteId and model context", async () => {
85
+ const sent: { id: unknown; system: unknown }[] = [];
86
+ const transport = new AssistantChatTransport({
87
+ api: "/api/chat",
88
+ fetch: async (_input, init) => {
89
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
90
+ sent.push({ id: body["id"], system: body["system"] });
91
+ return finishedStream();
92
+ },
93
+ });
94
+
95
+ const mount = (id: string, remoteId: string, system: string) =>
96
+ renderHook(() => {
97
+ const runtime = useChatThread(
98
+ { transport },
99
+ {
100
+ id,
101
+ isMainThread: id === "thread-a",
102
+ getThreadListItem: () => itemFor(remoteId),
103
+ },
104
+ );
105
+ useEffect(
106
+ () =>
107
+ runtime.registerModelContextProvider({
108
+ getModelContext: () => ({ system }),
109
+ }),
110
+ [runtime],
111
+ );
112
+ return runtime;
113
+ });
114
+
115
+ const threadA = mount("thread-a", "remote-a", "system-a");
116
+ const threadB = mount("thread-b", "remote-b", "system-b");
117
+
118
+ threadA.rerender();
119
+
120
+ await act(async () => {
121
+ threadB.result.current.thread.append("hello");
122
+ });
123
+
124
+ await waitFor(() => expect(sent).toHaveLength(1));
125
+ expect(sent[0]).toEqual({ id: "remote-b", system: "system-b" });
126
+ });
127
+
128
+ it("uses the supplied instance when the caller owns the chat", () => {
129
+ const transport = new AssistantChatTransport({ api: "/api/chat" });
130
+ const clone = vi.spyOn(transport, "__internal_clone");
131
+ const chat = new Chat({ id: "thread-a", transport });
132
+
133
+ renderHook(() =>
134
+ useChatThread(
135
+ { transport },
136
+ {
137
+ id: "thread-a",
138
+ isMainThread: true,
139
+ getThreadListItem: () => itemFor("remote-a"),
140
+ chat,
141
+ },
142
+ ),
143
+ );
144
+
145
+ // A caller-owned chat is already bound to its transport, so cloning again
146
+ // would wire an instance the chat never sends through.
147
+ expect(clone).not.toHaveBeenCalled();
148
+ });
149
+ });
@@ -0,0 +1,533 @@
1
+ "use client";
2
+
3
+ import { Chat } from "@ai-sdk/vue";
4
+ import type { UIMessage } from "ai";
5
+ import { useChatSnapshot } from "./useChatSnapshot";
6
+ import type { MessageRepository } from "@ulearning/ai-core/internal";
7
+ import {
8
+ pickExternalStoreSharedOptions,
9
+ type AssistantRuntime,
10
+ type ExternalStoreSharedOptions,
11
+ } from "@ulearning/ai-core";
12
+ import {
13
+ useAISDKRuntime,
14
+ type AISDKRuntimeAdapter,
15
+ type CustomToCreateMessageFunction,
16
+ } from "./useAISDKRuntime";
17
+ import type { ChatInit } from "ai";
18
+ import {
19
+ AssistantChatTransport,
20
+ type InitializableThreadListItem,
21
+ } from "../transport/AssistantChatTransport";
22
+ import {
23
+ useCallback,
24
+ useEffect,
25
+ useEffectEvent,
26
+ useInsertionEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ useSyncExternalStore,
31
+ } from "@ulearning/ai-tap";
32
+ import { useResourceCleanup } from "./useResourceCleanup";
33
+ import {
34
+ DynamicChatTransport,
35
+ getResumedStreamIds,
36
+ } from "./DynamicChatTransport";
37
+ import type { ResumableClientStorage } from "../transport/resumable";
38
+ import { getResumableAdapter } from "./getResumableAdapter";
39
+ import { useDynamicChatTransport } from "./useDynamicChatTransport";
40
+
41
+ export type ChatThreadOptions<UI_MESSAGE extends UIMessage = UIMessage> =
42
+ ChatInit<UI_MESSAGE> &
43
+ ExternalStoreSharedOptions & {
44
+ throttle?: number | undefined;
45
+ adapters?: AISDKRuntimeAdapter["adapters"] | undefined;
46
+ toCreateMessage?: CustomToCreateMessageFunction;
47
+ onResume?: AISDKRuntimeAdapter["onResume"];
48
+ canResume?: AISDKRuntimeAdapter["canResume"];
49
+ onResumeToolCall?: AISDKRuntimeAdapter["onResumeToolCall"];
50
+ onRespondToToolApproval?: AISDKRuntimeAdapter["onRespondToToolApproval"];
51
+ /**
52
+ * Called when a resumable stream reconnect fails. Use this to
53
+ * surface a toast, report telemetry, or mark the thread as needing a
54
+ * retry. With `canResume: true`, transient errors keep the stream id for
55
+ * manual retry. Otherwise, a failed reconnect clears the matching id.
56
+ * A 204 or 404 from the resume endpoint always clears the matching id.
57
+ */
58
+ onResumeError?: ((error: unknown) => void) | undefined;
59
+ joinStrategy?: AISDKRuntimeAdapter["joinStrategy"];
60
+ messageRepository?: AISDKRuntimeAdapter<UI_MESSAGE>["messageRepository"];
61
+ /** @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release. */
62
+ unstable_onBranchChange?: AISDKRuntimeAdapter["unstable_onBranchChange"];
63
+ /** @deprecated Experimental since 2026-10-15. Not scheduled for removal; the API may change in any release. */
64
+ unstable_enableMessageQueue?: AISDKRuntimeAdapter["unstable_enableMessageQueue"];
65
+ };
66
+
67
+ export type ChatThreadEnvironment<UI_MESSAGE extends UIMessage = UIMessage> = {
68
+ id: string;
69
+ isMainThread: boolean;
70
+ getThreadListItem: () => InitializableThreadListItem | undefined;
71
+ stopOnClientDestroy?: boolean;
72
+ /**
73
+ * Aborts when the Vue scope hosting the runtime is deleted. A nested
74
+ * runtime resolves the destroy signal of the provider above it, which
75
+ * outlives the nested component, so this stops the chat on its own unmount.
76
+ */
77
+ hostDestroySignal?: AbortSignal | undefined;
78
+ /**
79
+ * An externally owned chat instance. State lives on the instance, so it
80
+ * survives the hosting resource unmounting; construction options are read
81
+ * from the instance.
82
+ */
83
+ chat?: Chat<UI_MESSAGE> | undefined;
84
+ /**
85
+ * An externally owned per-thread message repository. Hosts that route
86
+ * multiple threads through one mounting pass a distinct instance per
87
+ * thread so histories and branches stay isolated.
88
+ */
89
+ messageRepositoryInstance?: MessageRepository | undefined;
90
+ };
91
+
92
+ type ChatThreadTransportBinding = {
93
+ runtime: AssistantRuntime;
94
+ getThreadListItem: () => InitializableThreadListItem | undefined;
95
+ };
96
+
97
+ const getNoPendingStreamId = () => null;
98
+
99
+ /**
100
+ * Splits the combined options into the assistant-ui side and the `ChatInit`
101
+ * remainder the AI SDK consumes, so external `Chat` construction forwards the
102
+ * same fields `useChat` would.
103
+ */
104
+ export const splitChatThreadOptions = <UI_MESSAGE extends UIMessage>(
105
+ options: ChatThreadOptions<UI_MESSAGE> | undefined,
106
+ ) => {
107
+ const {
108
+ adapters,
109
+ transport,
110
+ throttle,
111
+ toCreateMessage,
112
+ isDisabled: _isDisabled,
113
+ isSendDisabled: _isSendDisabled,
114
+ unstable_capabilities: _unstable_capabilities,
115
+ suggestions: _suggestions,
116
+ onResume,
117
+ canResume,
118
+ onResumeToolCall,
119
+ onRespondToToolApproval,
120
+ onResumeError,
121
+ joinStrategy,
122
+ messageRepository,
123
+ unstable_onBranchChange,
124
+ unstable_enableMessageQueue,
125
+ ...chatInit
126
+ } = options ?? {};
127
+ // peel guard: any shared key left in `chatInit` collapses this to `never`
128
+ true satisfies keyof typeof chatInit &
129
+ keyof ExternalStoreSharedOptions extends never
130
+ ? true
131
+ : never;
132
+ return {
133
+ adapters,
134
+ transport,
135
+ throttle,
136
+ toCreateMessage,
137
+ onResume,
138
+ canResume,
139
+ onResumeToolCall,
140
+ onRespondToToolApproval,
141
+ onResumeError,
142
+ joinStrategy,
143
+ messageRepository,
144
+ unstable_onBranchChange,
145
+ unstable_enableMessageQueue,
146
+ chatInit,
147
+ };
148
+ };
149
+
150
+ type ChatCallbacks<UI_MESSAGE extends UIMessage> = Pick<
151
+ ChatInit<UI_MESSAGE>,
152
+ "onToolCall" | "onData" | "onFinish" | "onError" | "sendAutomaticallyWhen"
153
+ > & {
154
+ canResume?: boolean | undefined;
155
+ resumableStorage?: ResumableClientStorage | undefined;
156
+ };
157
+
158
+ type ChatRequest = {
159
+ reconnectFailure?: { error: unknown };
160
+ };
161
+
162
+ const requestsByChat = new WeakMap<object, ChatRequest>();
163
+
164
+ /**
165
+ * Constructs a `Chat` whose callbacks read the latest options through
166
+ * `callbacksRef`, the forwarding `useChat` applies only to a chat it
167
+ * constructs itself.
168
+ */
169
+ export const createChat = <UI_MESSAGE extends UIMessage>(
170
+ init: ChatInit<UI_MESSAGE>,
171
+ callbacksRef: { readonly current: ChatCallbacks<UI_MESSAGE> | undefined },
172
+ ): Chat<UI_MESSAGE> => {
173
+ const transport = init.transport;
174
+ const chat = new Chat<UI_MESSAGE>({
175
+ ...init,
176
+ ...(transport && {
177
+ transport: {
178
+ get resumeStreamIsReplay() {
179
+ return transport.resumeStreamIsReplay ?? false;
180
+ },
181
+ sendMessages: (options) => {
182
+ requestsByChat.set(chat, {});
183
+ // A new send or regeneration changes the replay target even when
184
+ // the request fails before returning a replacement checkpoint.
185
+ if (callbacksRef.current?.canResume) {
186
+ const storage =
187
+ callbacksRef.current.resumableStorage ??
188
+ getResumableAdapter(transport)?.storage;
189
+ storage?.clear(options.chatId);
190
+ }
191
+ return transport.sendMessages(options);
192
+ },
193
+ reconnectToStream: async (options) => {
194
+ const request: ChatRequest = {};
195
+ requestsByChat.set(chat, request);
196
+ try {
197
+ return await transport.reconnectToStream(options);
198
+ } catch (error) {
199
+ // SDK 7 suppresses a superseded reconnect's rejection after its
200
+ // abort signal fires. Preserve the actual transport failure for
201
+ // the resume invocation that owns it, without borrowing Chat.error
202
+ // from a newer send or resume. Genuine aborts remain cancellation.
203
+ if (
204
+ typeof error !== "object" ||
205
+ error === null ||
206
+ !("name" in error) ||
207
+ error.name !== "AbortError"
208
+ ) {
209
+ request.reconnectFailure = { error };
210
+ }
211
+ throw error;
212
+ }
213
+ },
214
+ },
215
+ }),
216
+ onToolCall: (arg) => callbacksRef.current?.onToolCall?.(arg),
217
+ onData: (arg) => callbacksRef.current?.onData?.(arg),
218
+ onFinish: (arg) => callbacksRef.current?.onFinish?.(arg),
219
+ onError: (arg) => callbacksRef.current?.onError?.(arg),
220
+ sendAutomaticallyWhen: (arg) =>
221
+ callbacksRef.current?.sendAutomaticallyWhen?.(arg) ?? false,
222
+ });
223
+ return chat;
224
+ };
225
+
226
+ export const useChatThread = <UI_MESSAGE extends UIMessage = UIMessage>(
227
+ options: ChatThreadOptions<UI_MESSAGE> | undefined,
228
+ env: ChatThreadEnvironment<UI_MESSAGE>,
229
+ ): AssistantRuntime => {
230
+ const {
231
+ adapters,
232
+ transport: transportOptions,
233
+ throttle,
234
+ toCreateMessage,
235
+ onResume,
236
+ canResume,
237
+ onResumeToolCall,
238
+ onRespondToToolApproval,
239
+ onResumeError,
240
+ joinStrategy,
241
+ messageRepository,
242
+ unstable_onBranchChange,
243
+ unstable_enableMessageQueue,
244
+ chatInit: chatOptions,
245
+ } = splitChatThreadOptions(options);
246
+
247
+ const {
248
+ id,
249
+ isMainThread,
250
+ getThreadListItem,
251
+ stopOnClientDestroy = true,
252
+ hostDestroySignal,
253
+ chat: externalChat,
254
+ messageRepositoryInstance,
255
+ } = env;
256
+
257
+ const defaultTransport = useMemo(() => new AssistantChatTransport(), []);
258
+ const configuredTransport = transportOptions ?? defaultTransport;
259
+ const transport = useDynamicChatTransport(
260
+ configuredTransport,
261
+ externalChat === undefined,
262
+ );
263
+ const transportContextOwner = useMemo(() => ({}), []);
264
+ const getThreadListItemRef = useRef(getThreadListItem);
265
+ useInsertionEffect(() => {
266
+ getThreadListItemRef.current = getThreadListItem;
267
+ }, [getThreadListItem]);
268
+ const getCurrentThreadListItem = useCallback(
269
+ () => getThreadListItemRef.current(),
270
+ [],
271
+ );
272
+ const resumableStorage = useMemo(
273
+ () =>
274
+ transport instanceof DynamicChatTransport
275
+ ? transport.getCurrentResumableStorage()
276
+ : getResumableAdapter(transport)?.storage,
277
+ [transport],
278
+ );
279
+
280
+ const transportBindingRef = useRef<ChatThreadTransportBinding | null>(null);
281
+ // This is null when useMemo runs, so the proxy pins its mount render's binding. The fallback
282
+ // serves only pre-commit sends; after insertion effects, transportBindingRef is authoritative.
283
+ let initialTransportBinding: ChatThreadTransportBinding | null = null;
284
+ const chatTransport = useMemo(
285
+ () =>
286
+ transport instanceof DynamicChatTransport
287
+ ? transport.createThreadProxy(transportContextOwner, () => {
288
+ const binding =
289
+ transportBindingRef.current ?? initialTransportBinding;
290
+ if (!binding) {
291
+ throw new Error("Chat transport used before runtime setup");
292
+ }
293
+ return binding;
294
+ })
295
+ : transport,
296
+ [initialTransportBinding, transport, transportContextOwner],
297
+ );
298
+
299
+ const latestChatOptionsRef = useRef({
300
+ ...chatOptions,
301
+ canResume,
302
+ resumableStorage,
303
+ });
304
+ useEffect(() => {
305
+ latestChatOptionsRef.current = {
306
+ ...chatOptions,
307
+ canResume,
308
+ resumableStorage,
309
+ };
310
+ });
311
+ // `useChat` stops a chat it constructs whenever it unmounts, and a
312
+ // resource's soft unmount runs that cleanup, so the thread owns its chat.
313
+ const [ownedChat] = useState(
314
+ () =>
315
+ externalChat ??
316
+ createChat(
317
+ { ...chatOptions, id, transport: chatTransport },
318
+ latestChatOptionsRef,
319
+ ),
320
+ );
321
+
322
+ const chat = useChatSnapshot(externalChat ?? ownedChat, throttle);
323
+
324
+ useResourceCleanup(
325
+ stopOnClientDestroy,
326
+ () => {
327
+ void chat.stop().catch(() => {});
328
+ },
329
+ hostDestroySignal,
330
+ );
331
+
332
+ const subscribeToResumableStorage = useCallback(
333
+ (callback: () => void) =>
334
+ isMainThread
335
+ ? (resumableStorage?.subscribe?.(callback, id) ?? (() => {}))
336
+ : () => {},
337
+ [id, isMainThread, resumableStorage],
338
+ );
339
+ const getPendingStreamId = useCallback(
340
+ () => (isMainThread ? (resumableStorage?.getStreamId(id) ?? null) : null),
341
+ [id, isMainThread, resumableStorage],
342
+ );
343
+ const pendingStreamId = useSyncExternalStore(
344
+ subscribeToResumableStorage,
345
+ getPendingStreamId,
346
+ getNoPendingStreamId,
347
+ );
348
+ const isChatRunning =
349
+ chat.status === "submitted" || chat.status === "streaming";
350
+
351
+ const staticResumedStreamIds = useMemo(
352
+ () => getResumedStreamIds(resumableStorage),
353
+ [resumableStorage],
354
+ );
355
+ const onResumeErrorRef = useRef(onResumeError);
356
+ useEffect(() => {
357
+ onResumeErrorRef.current = onResumeError;
358
+ });
359
+ const resumeChatStream = chat.resumeStream;
360
+ const resumeStream = useCallback(async () => {
361
+ const resumedStreamIds =
362
+ transport instanceof DynamicChatTransport
363
+ ? transport.getResumedStreamIds()
364
+ : staticResumedStreamIds;
365
+ const streamId = resumableStorage?.getStreamId(id);
366
+ if (!streamId) return;
367
+ resumedStreamIds.add(streamId);
368
+ try {
369
+ const activeChat = externalChat ?? ownedChat;
370
+ activeChat.clearError();
371
+ const pending = resumeChatStream();
372
+ const request = requestsByChat.get(activeChat);
373
+ await pending;
374
+ if (request?.reconnectFailure) {
375
+ throw request.reconnectFailure.error;
376
+ }
377
+ // Chat.error is shared with sends and resumes that can start before
378
+ // this promise settles, including inside the caller's onFinish.
379
+ if (requestsByChat.get(activeChat) === request && activeChat.error) {
380
+ throw activeChat.error;
381
+ }
382
+ } catch (error) {
383
+ try {
384
+ onResumeErrorRef.current?.(error);
385
+ } catch (callbackError) {
386
+ console.error(
387
+ "[assistant-ui] resumable: onResumeError callback failed",
388
+ callbackError,
389
+ );
390
+ } finally {
391
+ if (!canResume && resumableStorage?.getStreamId(id) === streamId) {
392
+ resumableStorage.clear(id);
393
+ }
394
+ }
395
+ throw error;
396
+ }
397
+ }, [
398
+ canResume,
399
+ externalChat,
400
+ id,
401
+ ownedChat,
402
+ resumeChatStream,
403
+ resumableStorage,
404
+ staticResumedStreamIds,
405
+ transport,
406
+ ]);
407
+
408
+ const handleBranchChange = useCallback<
409
+ NonNullable<AISDKRuntimeAdapter["unstable_onBranchChange"]>
410
+ >(
411
+ (event) => {
412
+ if (canResume) resumableStorage?.clear(id);
413
+ unstable_onBranchChange?.(event);
414
+ },
415
+ [canResume, id, resumableStorage, unstable_onBranchChange],
416
+ );
417
+
418
+ const runtime = useAISDKRuntime(chat, {
419
+ adapters: {
420
+ ...adapters,
421
+ threadList: { threadId: id, ...adapters?.threadList },
422
+ },
423
+ ...pickExternalStoreSharedOptions(options ?? {}),
424
+ ...(toCreateMessage && { toCreateMessage }),
425
+ ...(onResume
426
+ ? { onResume }
427
+ : canResume && resumableStorage
428
+ ? { onResume: resumeStream }
429
+ : {}),
430
+ // AI SDK 7 starts resume-stream with fresh message state (chat.ts), so the
431
+ // host must replay the full message with its original start.messageId.
432
+ // Without that ID the replay appends a second message. A stored stream ID
433
+ // alone cannot establish this contract; only the host can opt in.
434
+ canResume: onResume
435
+ ? !!canResume
436
+ : !!canResume && !!pendingStreamId && !isChatRunning,
437
+ ...(onResumeToolCall && { onResumeToolCall }),
438
+ ...(onRespondToToolApproval && { onRespondToToolApproval }),
439
+ ...(joinStrategy && { joinStrategy }),
440
+ ...(messageRepository && { messageRepository }),
441
+ ...(messageRepositoryInstance && {
442
+ unstable_messageRepositoryInstance: messageRepositoryInstance,
443
+ }),
444
+ // The chat outlives this runtime when a host mounts only the visible
445
+ // thread, so a host approval answer is kept with it. This is the Chat
446
+ // instance, not the useChat helpers, which are re-minted every render and
447
+ // would be a dead WeakMap key by the next one.
448
+ unstable_hostApprovalOwner: externalChat ?? ownedChat,
449
+ ...(((canResume && resumableStorage) || unstable_onBranchChange) && {
450
+ unstable_onBranchChange: handleBranchChange,
451
+ }),
452
+ ...(unstable_enableMessageQueue && { unstable_enableMessageQueue }),
453
+ });
454
+
455
+ initialTransportBinding = {
456
+ runtime,
457
+ getThreadListItem: getCurrentThreadListItem,
458
+ };
459
+ useInsertionEffect(() => {
460
+ transportBindingRef.current = {
461
+ runtime,
462
+ getThreadListItem: getCurrentThreadListItem,
463
+ };
464
+ }, [runtime, getCurrentThreadListItem]);
465
+
466
+ const registerTransportContext = useEffectEvent(
467
+ (dynamicTransport: DynamicChatTransport<UI_MESSAGE>, chatId: string) => {
468
+ dynamicTransport.setThreadContext(
469
+ chatId,
470
+ transportContextOwner,
471
+ runtime,
472
+ getCurrentThreadListItem,
473
+ );
474
+ return () =>
475
+ dynamicTransport.unregisterThread(chatId, transportContextOwner);
476
+ },
477
+ );
478
+ useInsertionEffect(() => {
479
+ if (!(transport instanceof DynamicChatTransport)) return undefined;
480
+ return registerTransportContext(transport, id);
481
+ }, [id, runtime, transport, transportContextOwner]);
482
+
483
+ if (
484
+ !(transport instanceof DynamicChatTransport) &&
485
+ configuredTransport instanceof AssistantChatTransport
486
+ ) {
487
+ configuredTransport.setRuntime(runtime);
488
+ configuredTransport.__internal_setGetThreadListItem(
489
+ getCurrentThreadListItem,
490
+ );
491
+ }
492
+
493
+ const subscribeToRuntime = useCallback(
494
+ (callback: () => void) => runtime.thread.subscribe(callback),
495
+ [runtime],
496
+ );
497
+ const getHistoryLoadingSnapshot = useCallback(
498
+ () => runtime.thread.getState().isLoading,
499
+ [runtime],
500
+ );
501
+ const isLoadingHistory = useSyncExternalStore(
502
+ subscribeToRuntime,
503
+ getHistoryLoadingSnapshot,
504
+ getHistoryLoadingSnapshot,
505
+ );
506
+
507
+ useEffect(() => {
508
+ const resumedStreamIds =
509
+ transport instanceof DynamicChatTransport
510
+ ? transport.getResumedStreamIds()
511
+ : staticResumedStreamIds;
512
+ if (!pendingStreamId || resumedStreamIds.has(pendingStreamId)) {
513
+ return;
514
+ }
515
+ if (isChatRunning) {
516
+ resumedStreamIds.add(pendingStreamId);
517
+ return;
518
+ }
519
+ if (isLoadingHistory) return;
520
+ resumeStream().catch((err: unknown) => {
521
+ console.warn("[assistant-ui] resumable: resume failed", err);
522
+ });
523
+ }, [
524
+ isChatRunning,
525
+ isLoadingHistory,
526
+ pendingStreamId,
527
+ resumeStream,
528
+ staticResumedStreamIds,
529
+ transport,
530
+ ]);
531
+
532
+ return runtime;
533
+ };
@@ -0,0 +1,26 @@
1
+ import type { UIMessage } from "ai";
2
+ import type { ChatTransport } from "ai";
3
+ import { useEffect, useInsertionEffect, useState } from "@ulearning/ai-tap";
4
+ import { DynamicChatTransport } from "./DynamicChatTransport";
5
+
6
+ export const useDynamicChatTransport = <UI_MESSAGE extends UIMessage>(
7
+ transport: ChatTransport<UI_MESSAGE>,
8
+ enabled = true,
9
+ ): ChatTransport<UI_MESSAGE> => {
10
+ const [dynamicTransport] = useState(() =>
11
+ transport instanceof DynamicChatTransport
12
+ ? transport
13
+ : new DynamicChatTransport(transport),
14
+ );
15
+
16
+ useInsertionEffect(() => {
17
+ if (!enabled || dynamicTransport === transport) return;
18
+ dynamicTransport.setTransport(transport);
19
+ }, [dynamicTransport, enabled, transport]);
20
+ useEffect(() => {
21
+ if (!enabled || dynamicTransport === transport) return;
22
+ dynamicTransport.flushTransportChange();
23
+ }, [dynamicTransport, enabled, transport]);
24
+
25
+ return enabled ? dynamicTransport : transport;
26
+ };