@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,88 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, cleanup, render, waitFor } from "./__tests__/vue";
4
+ import { defineComponent, h, shallowRef } from "vue";
5
+ import { useTapRoot } from "@ulearning/ai-tap";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import type { AppendMessage } from "@ulearning/ai-core";
8
+ import { useMessageQueue } from "./useMessageQueue";
9
+
10
+ const passiveEffects = vi.hoisted(() => ({ deferred: false }));
11
+
12
+ vi.mock("@ulearning/ai-tap", async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import("@ulearning/ai-tap")>();
14
+ return {
15
+ ...actual,
16
+ useEffect: (...[effect, deps]: Parameters<typeof actual.useEffect>) =>
17
+ actual.useEffect(() => {
18
+ if (!passiveEffects.deferred) return effect();
19
+ return undefined;
20
+ }, deps),
21
+ };
22
+ });
23
+
24
+ const message: AppendMessage = {
25
+ role: "user",
26
+ content: [{ type: "text", text: "pending" }],
27
+ attachments: [],
28
+ createdAt: new Date(0),
29
+ parentId: null,
30
+ sourceId: null,
31
+ runConfig: {},
32
+ metadata: { custom: {} },
33
+ };
34
+
35
+ afterEach(() => {
36
+ passiveEffects.deferred = false;
37
+ cleanup();
38
+ });
39
+
40
+ describe("useMessageQueue before passive effects", () => {
41
+ it.each([true, false])(
42
+ "holds sends while busy (busy on mount: %s)",
43
+ async (initiallyBusy) => {
44
+ passiveEffects.deferred = initiallyBusy;
45
+ const send = vi.fn(async (_message: AppendMessage) => {});
46
+ const isRunning = shallowRef(initiallyBusy);
47
+ let root!: useTapRoot.Root<ReturnType<typeof useMessageQueue>>;
48
+ render(
49
+ h(
50
+ defineComponent({
51
+ setup() {
52
+ root = useTapRoot(() =>
53
+ useMessageQueue({
54
+ enabled: true,
55
+ isRunning: isRunning.value,
56
+ isSendDisabled: false,
57
+ send,
58
+ cancel: async () => {},
59
+ }),
60
+ );
61
+ return () => null;
62
+ },
63
+ }),
64
+ ),
65
+ );
66
+ const result = {
67
+ get current() {
68
+ return root.getValue();
69
+ },
70
+ };
71
+ const rerender = (next: { isRunning: boolean }) => {
72
+ isRunning.value = next.isRunning;
73
+ };
74
+ if (!initiallyBusy) {
75
+ passiveEffects.deferred = true;
76
+ rerender({ isRunning: true });
77
+ }
78
+ await act(async () => result.current.adapter!.enqueue(message));
79
+ expect(send).not.toHaveBeenCalled();
80
+
81
+ passiveEffects.deferred = false;
82
+ rerender({ isRunning: false });
83
+ await waitFor(() =>
84
+ expect(send).toHaveBeenCalledExactlyOnceWith(message),
85
+ );
86
+ },
87
+ );
88
+ });
@@ -0,0 +1,256 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, cleanup, renderHook, waitFor } from "./__tests__/tap";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { AppendMessage } from "@ulearning/ai-core";
6
+ import { useMessageQueue } from "./useMessageQueue";
7
+
8
+ const message = (text: string): AppendMessage => ({
9
+ role: "user",
10
+ content: [{ type: "text", text }],
11
+ attachments: [],
12
+ createdAt: new Date(0),
13
+ parentId: null,
14
+ sourceId: null,
15
+ runConfig: {},
16
+ metadata: { custom: {} },
17
+ });
18
+
19
+ afterEach(cleanup);
20
+
21
+ describe("useMessageQueue lifecycle", () => {
22
+ it("releases a cleared rejection without surfacing its stale error", async () => {
23
+ const error = new Error("discarded send rejected");
24
+ let reject!: (error: Error) => void;
25
+ const pending = new Promise<void>((_resolve, fail) => {
26
+ reject = fail;
27
+ });
28
+ const send = vi.fn(async (_message: AppendMessage) => {});
29
+ send.mockReturnValueOnce(pending);
30
+ const onError = vi.fn();
31
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
32
+ try {
33
+ const { result } = renderHook(() =>
34
+ useMessageQueue({
35
+ enabled: true,
36
+ isRunning: false,
37
+ isSendDisabled: false,
38
+ send,
39
+ cancel: async () => {},
40
+ onError,
41
+ }),
42
+ );
43
+ await act(async () => result.current.adapter!.enqueue(message("first")));
44
+ act(() => {
45
+ result.current.clear();
46
+ result.current.adapter!.enqueue(message("replacement"));
47
+ });
48
+ await act(async () => {
49
+ reject(error);
50
+ await pending.catch(() => {});
51
+ });
52
+ expect(send).toHaveBeenCalledTimes(2);
53
+ expect(send).toHaveBeenLastCalledWith(message("replacement"));
54
+ expect(result.current.adapter!.items).toEqual([]);
55
+ expect(onError).not.toHaveBeenCalledWith(error);
56
+ } finally {
57
+ logged.mockRestore();
58
+ }
59
+ });
60
+
61
+ it("pauses later prompts after a rejected send and retries the failed prompt first", async () => {
62
+ const error = new Error("send rejected");
63
+ let reject!: (error: Error) => void;
64
+ const pending = new Promise<void>((_resolve, fail) => {
65
+ reject = fail;
66
+ });
67
+ const send = vi.fn(async (_message: AppendMessage) => {});
68
+ send.mockReturnValueOnce(pending);
69
+ const onError = vi.fn();
70
+ const logged = vi.spyOn(console, "error").mockImplementation(() => {});
71
+ try {
72
+ const { result } = renderHook(() =>
73
+ useMessageQueue({
74
+ enabled: true,
75
+ isRunning: false,
76
+ isSendDisabled: false,
77
+ send,
78
+ cancel: async () => {},
79
+ onError,
80
+ }),
81
+ );
82
+ await act(async () => result.current.adapter!.enqueue(message("first")));
83
+ act(() => result.current.adapter!.enqueue(message("later")));
84
+ await act(async () => {
85
+ reject(error);
86
+ await pending.catch(() => {});
87
+ });
88
+ expect(send).toHaveBeenCalledOnce();
89
+ expect(onError).toHaveBeenLastCalledWith(error);
90
+ expect(result.current.adapter!.items.map((item) => item.prompt)).toEqual([
91
+ "first",
92
+ "later",
93
+ ]);
94
+
95
+ await act(async () => result.current.adapter!.enqueue(message("retry")));
96
+ await waitFor(() =>
97
+ expect(send.mock.calls.map(([m]) => m.content[0])).toEqual([
98
+ { type: "text", text: "first" },
99
+ { type: "text", text: "first" },
100
+ { type: "text", text: "later" },
101
+ { type: "text", text: "retry" },
102
+ ]),
103
+ );
104
+ expect(onError).toHaveBeenLastCalledWith(undefined);
105
+ expect(result.current.adapter!.items).toEqual([]);
106
+ } finally {
107
+ logged.mockRestore();
108
+ }
109
+ });
110
+
111
+ it.each([true, false])(
112
+ "keeps the replacement queue until idle (intermediate idle: %s)",
113
+ async (intermediateIdle) => {
114
+ let finish!: () => void;
115
+ const firstRun = new Promise<void>((resolve) => {
116
+ finish = resolve;
117
+ });
118
+ const send = vi.fn(async (_message: AppendMessage) => {});
119
+ send.mockReturnValueOnce(firstRun);
120
+ const { result, rerender } = renderHook(
121
+ ({ isRunning }) =>
122
+ useMessageQueue({
123
+ enabled: true,
124
+ isRunning,
125
+ isSendDisabled: false,
126
+ send,
127
+ cancel: async () => {},
128
+ }),
129
+ { initialProps: { isRunning: false } },
130
+ );
131
+ await act(async () => result.current.adapter!.enqueue(message("first")));
132
+ rerender({ isRunning: true });
133
+ act(() => result.current.adapter!.steer(message("discarded")));
134
+ act(() => result.current.clear());
135
+ if (intermediateIdle) rerender({ isRunning: false });
136
+ rerender({ isRunning: true });
137
+ act(() => result.current.adapter!.enqueue(message("after replacement")));
138
+
139
+ await act(async () => {
140
+ finish();
141
+ await firstRun;
142
+ });
143
+ expect(send).toHaveBeenCalledOnce();
144
+ expect(result.current.adapter!.items.map((item) => item.prompt)).toEqual([
145
+ "after replacement",
146
+ ]);
147
+
148
+ rerender({ isRunning: false });
149
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(2));
150
+ expect(send).toHaveBeenLastCalledWith(message("after replacement"));
151
+ },
152
+ );
153
+
154
+ it("preserves pending messages while disabled and resumes when enabled", async () => {
155
+ const send = vi.fn(async (_message: AppendMessage) => {});
156
+ const { result, rerender } = renderHook(
157
+ ({ enabled, isRunning }) =>
158
+ useMessageQueue({
159
+ enabled,
160
+ isRunning,
161
+ isSendDisabled: false,
162
+ send,
163
+ cancel: async () => {},
164
+ }),
165
+ { initialProps: { enabled: true, isRunning: true } },
166
+ );
167
+
168
+ act(() => result.current.adapter!.enqueue(message("pending")));
169
+ rerender({ enabled: false, isRunning: false });
170
+ expect(result.current.adapter).toBeUndefined();
171
+ expect(send).not.toHaveBeenCalled();
172
+
173
+ rerender({ enabled: true, isRunning: false });
174
+ await waitFor(() =>
175
+ expect(send).toHaveBeenCalledExactlyOnceWith(message("pending")),
176
+ );
177
+ });
178
+
179
+ it.each(["disable", "clear", "unmount"])(
180
+ "%s prevents a waiting steer from dispatching",
181
+ async (action) => {
182
+ let finish!: () => void;
183
+ const firstRun = new Promise<void>((resolve) => {
184
+ finish = resolve;
185
+ });
186
+ const send = vi.fn(async (_message: AppendMessage) => {});
187
+ send.mockReturnValueOnce(firstRun);
188
+ const { result, rerender, unmount } = renderHook(
189
+ ({ enabled, isRunning }) =>
190
+ useMessageQueue({
191
+ enabled,
192
+ isRunning,
193
+ isSendDisabled: false,
194
+ send,
195
+ cancel: async () => {},
196
+ }),
197
+ { initialProps: { enabled: true, isRunning: false } },
198
+ );
199
+
200
+ act(() => result.current.adapter!.enqueue(message("first")));
201
+ await waitFor(() => expect(send).toHaveBeenCalledOnce());
202
+ rerender({ enabled: true, isRunning: true });
203
+ act(() => result.current.adapter!.steer(message("second")));
204
+ if (action === "unmount") unmount();
205
+ else {
206
+ if (action === "clear") act(() => result.current.clear());
207
+ rerender({ enabled: action !== "disable", isRunning: false });
208
+ }
209
+ await act(async () => {
210
+ finish();
211
+ await firstRun;
212
+ });
213
+ expect(send).toHaveBeenCalledOnce();
214
+
215
+ if (action === "disable") {
216
+ rerender({ enabled: true, isRunning: false });
217
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(2));
218
+ expect(send).toHaveBeenLastCalledWith(message("second"));
219
+ } else if (action === "clear") {
220
+ act(() => result.current.adapter!.enqueue(message("third")));
221
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(2));
222
+ expect(send).toHaveBeenLastCalledWith(message("third"));
223
+ }
224
+ },
225
+ );
226
+
227
+ it("does not dispatch a waiting steer after unmount", async () => {
228
+ let finish!: () => void;
229
+ const firstRun = new Promise<void>((resolve) => {
230
+ finish = resolve;
231
+ });
232
+ const send = vi.fn(async (_message: AppendMessage) => {});
233
+ send.mockReturnValueOnce(firstRun);
234
+ const { result, rerender, unmount } = renderHook(
235
+ ({ isRunning }) =>
236
+ useMessageQueue({
237
+ enabled: true,
238
+ isRunning,
239
+ isSendDisabled: false,
240
+ send,
241
+ cancel: async () => {},
242
+ }),
243
+ { initialProps: { isRunning: false } },
244
+ );
245
+ await act(async () => result.current.adapter!.enqueue(message("first")));
246
+ expect(send).toHaveBeenCalledOnce();
247
+ rerender({ isRunning: true });
248
+ act(() => result.current.adapter!.steer(message("second")));
249
+ unmount();
250
+ await act(async () => {
251
+ finish();
252
+ await firstRun;
253
+ });
254
+ expect(send).toHaveBeenCalledOnce();
255
+ });
256
+ });
@@ -0,0 +1,189 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useInsertionEffect,
7
+ useRef,
8
+ useSyncExternalStore,
9
+ } from "@ulearning/ai-tap";
10
+ import {
11
+ createMessageQueue,
12
+ type AppendMessage,
13
+ type MessageQueueController,
14
+ } from "@ulearning/ai-core";
15
+
16
+ const subscribeNoop = () => () => {};
17
+ const getNoItems = () => undefined;
18
+
19
+ export const useMessageQueue = ({
20
+ enabled,
21
+ isRunning,
22
+ isSendDisabled,
23
+ send,
24
+ cancel,
25
+ onError,
26
+ }: {
27
+ enabled: boolean;
28
+ isRunning: boolean;
29
+ isSendDisabled: boolean;
30
+ send: (message: AppendMessage) => Promise<void>;
31
+ cancel: () => Promise<void>;
32
+ onError?: ((error: Error | undefined) => void) | undefined;
33
+ }) => {
34
+ const sendRef = useRef(send);
35
+ const cancelRef = useRef(cancel);
36
+ const onErrorRef = useRef(onError);
37
+ const heldRef = useRef(!enabled || isSendDisabled);
38
+ const runningRef = useRef(isRunning);
39
+ useInsertionEffect(() => {
40
+ sendRef.current = send;
41
+ cancelRef.current = cancel;
42
+ onErrorRef.current = onError;
43
+ heldRef.current = !enabled || isSendDisabled;
44
+ runningRef.current = isRunning;
45
+ });
46
+
47
+ const cancelsRef = useRef(0);
48
+ const cancelRun = useCallback(() => {
49
+ cancelsRef.current++;
50
+ return cancelRef.current();
51
+ }, []);
52
+
53
+ const reportedRef = useRef<{
54
+ controller: MessageQueueController | null;
55
+ busy: boolean;
56
+ }>({ controller: null, busy: isRunning });
57
+ const busyEdgesRef = useRef(0);
58
+ const idleWaitersRef = useRef<(() => void)[]>([]);
59
+ const lastDispatchRef = useRef<Promise<void>>(Promise.resolve());
60
+ const mountedRef = useRef(true);
61
+ const generationRef = useRef(0);
62
+
63
+ const queueRef = useRef<MessageQueueController | null>(null);
64
+ if (enabled && !queueRef.current) {
65
+ const controller = createMessageQueue({
66
+ run: (message) => {
67
+ const previous = lastDispatchRef.current;
68
+ const cancels = cancelsRef.current;
69
+ const generation = generationRef.current;
70
+ const busyEdgesAtDispatch = busyEdgesRef.current;
71
+ const dispatch = (async () => {
72
+ await previous;
73
+ let settledCancels = cancels;
74
+ while (true) {
75
+ if (!mountedRef.current || generation !== generationRef.current) {
76
+ if (
77
+ mountedRef.current &&
78
+ (!runningRef.current ||
79
+ busyEdgesAtDispatch === busyEdgesRef.current)
80
+ ) {
81
+ controller.notifyIdle();
82
+ }
83
+ return;
84
+ }
85
+ if (
86
+ heldRef.current ||
87
+ runningRef.current ||
88
+ reportedRef.current.busy
89
+ ) {
90
+ await new Promise<void>((resolve) => {
91
+ idleWaitersRef.current.push(resolve);
92
+ });
93
+ continue;
94
+ }
95
+ if (settledCancels !== cancelsRef.current) {
96
+ settledCancels = cancelsRef.current;
97
+ await new Promise((resolve) => setTimeout(resolve));
98
+ continue;
99
+ }
100
+ break;
101
+ }
102
+ const overtaken = settledCancels !== cancels;
103
+ const busyEdges = busyEdgesRef.current;
104
+ onErrorRef.current?.(undefined);
105
+ try {
106
+ if (overtaken) {
107
+ const error = new Error("Queued send cancelled before dispatch");
108
+ error.name = "AbortError";
109
+ throw error;
110
+ }
111
+ await sendRef.current(message);
112
+ } catch (error) {
113
+ if (mountedRef.current && generation === generationRef.current) {
114
+ onErrorRef.current?.(
115
+ error instanceof Error ? error : new Error(String(error)),
116
+ );
117
+ }
118
+ throw error;
119
+ }
120
+ if (busyEdgesRef.current === busyEdges && !runningRef.current) {
121
+ controller.notifyIdle();
122
+ }
123
+ })();
124
+ lastDispatchRef.current = dispatch.catch(() => {});
125
+ return dispatch;
126
+ },
127
+ });
128
+ queueRef.current = controller;
129
+ }
130
+ const controller = queueRef.current;
131
+
132
+ useSyncExternalStore(
133
+ controller?.subscribe ?? subscribeNoop,
134
+ () => controller?.adapter.items,
135
+ getNoItems,
136
+ );
137
+ useSyncExternalStore(
138
+ controller?.subscribe ?? subscribeNoop,
139
+ () => controller?.adapter.steerItems,
140
+ getNoItems,
141
+ );
142
+
143
+ useEffect(() => {
144
+ mountedRef.current = true;
145
+ return () => {
146
+ mountedRef.current = false;
147
+ controller?.hold();
148
+ const waiters = idleWaitersRef.current;
149
+ idleWaitersRef.current = [];
150
+ for (const resolve of waiters) resolve();
151
+ };
152
+ }, [controller]);
153
+
154
+ useEffect(() => {
155
+ if (!controller) return;
156
+ if (!enabled || isSendDisabled) controller.hold();
157
+ else controller.release();
158
+ }, [controller, enabled, isSendDisabled]);
159
+
160
+ useEffect(() => {
161
+ const reported = reportedRef.current;
162
+ if (reported.controller !== controller || reported.busy !== isRunning) {
163
+ reportedRef.current = { controller, busy: isRunning };
164
+ if (isRunning) {
165
+ busyEdgesRef.current++;
166
+ controller?.notifyBusy();
167
+ } else {
168
+ controller?.notifyIdle();
169
+ }
170
+ }
171
+ if (!enabled || isSendDisabled || isRunning) return;
172
+ const waiters = idleWaitersRef.current;
173
+ idleWaitersRef.current = [];
174
+ for (const resolve of waiters) resolve();
175
+ }, [controller, enabled, isRunning, isSendDisabled]);
176
+
177
+ return {
178
+ adapter: enabled ? controller?.adapter : undefined,
179
+ cancel: cancelRun,
180
+ clear: () => {
181
+ onErrorRef.current?.(undefined);
182
+ generationRef.current++;
183
+ controller?.clear();
184
+ const waiters = idleWaitersRef.current;
185
+ idleWaitersRef.current = [];
186
+ for (const resolve of waiters) resolve();
187
+ },
188
+ };
189
+ };
@@ -0,0 +1,58 @@
1
+ import { useEffect, useRef } from "@ulearning/ai-tap";
2
+ import { useAssistantClientDestroySignal } from "@ulearning/ai-store/internal";
3
+
4
+ export const useResourceCleanup = (
5
+ enabled: boolean,
6
+ cleanup: () => void,
7
+ hostDestroySignal?: AbortSignal,
8
+ ) => {
9
+ const destroySignal = useAssistantClientDestroySignal();
10
+ const cleanupRef = useRef(cleanup);
11
+ const enabledRef = useRef(enabled);
12
+ const registrationRef = useRef<{
13
+ destroySignal: AbortSignal | undefined;
14
+ hostDestroySignal: AbortSignal | undefined;
15
+ controller: AbortController;
16
+ } | null>(null);
17
+
18
+ useEffect(() => {
19
+ cleanupRef.current = cleanup;
20
+ enabledRef.current = enabled;
21
+ });
22
+
23
+ useEffect(() => {
24
+ if (!enabled || (!destroySignal && !hostDestroySignal)) return undefined;
25
+ const current = registrationRef.current;
26
+ if (
27
+ current !== null &&
28
+ current.destroySignal === destroySignal &&
29
+ current.hostDestroySignal === hostDestroySignal
30
+ ) {
31
+ return undefined;
32
+ }
33
+ current?.controller.abort();
34
+
35
+ const registration = new AbortController();
36
+ registrationRef.current = {
37
+ destroySignal,
38
+ hostDestroySignal,
39
+ controller: registration,
40
+ };
41
+ const run = () => {
42
+ if (registration.signal.aborted) return;
43
+ registration.abort();
44
+ if (enabledRef.current) cleanupRef.current();
45
+ };
46
+ if (destroySignal?.aborted || hostDestroySignal?.aborted) {
47
+ run();
48
+ return undefined;
49
+ }
50
+ const options = { once: true, signal: registration.signal };
51
+ destroySignal?.addEventListener("abort", run, options);
52
+ hostDestroySignal?.addEventListener("abort", run, options);
53
+
54
+ // The listeners must survive standalone soft unmounts so a later
55
+ // permanent destroy still cleans up the retained resource state.
56
+ return undefined;
57
+ }, [destroySignal, enabled, hostDestroySignal]);
58
+ };
@@ -0,0 +1,96 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { cleanup, renderHook } from "./__tests__/tap";
4
+ import type { UIMessage } from "ai";
5
+ import { afterEach, expect, it, vi } from "vitest";
6
+ import { aiSdkStreamingTimingAccessors } from "./useStreamingTiming";
7
+ import { useStreamingTimingValue } from "@ulearning/ai-core/vue/internal";
8
+
9
+ afterEach(() => {
10
+ cleanup();
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ const answer: UIMessage = {
15
+ id: "a1",
16
+ role: "assistant",
17
+ parts: [{ type: "text", text: "Previous answer" }],
18
+ };
19
+
20
+ it("times file output added to an existing answer without new text", () => {
21
+ vi.useFakeTimers();
22
+ const { result, rerender } = renderHook(
23
+ ({ messages, isRunning }) =>
24
+ useStreamingTimingValue(
25
+ messages,
26
+ isRunning,
27
+ aiSdkStreamingTimingAccessors,
28
+ ),
29
+ { initialProps: { messages: [answer], isRunning: false } },
30
+ );
31
+ vi.setSystemTime(1000);
32
+ rerender({ messages: [answer], isRunning: true });
33
+ vi.setSystemTime(1500);
34
+ rerender({
35
+ messages: [
36
+ {
37
+ ...answer,
38
+ parts: [
39
+ ...answer.parts,
40
+ {
41
+ type: "file",
42
+ mediaType: "image/png",
43
+ url: "https://example.com/image.png",
44
+ },
45
+ ],
46
+ },
47
+ ],
48
+ isRunning: false,
49
+ });
50
+ expect(result.current.a1).toMatchObject({
51
+ streamStartTime: 1000,
52
+ totalStreamTime: 500,
53
+ toolCallCount: 0,
54
+ });
55
+ });
56
+
57
+ it("leaves equivalent history without timing after an empty run", () => {
58
+ const { result, rerender } = renderHook(
59
+ ({ messages, isRunning }) =>
60
+ useStreamingTimingValue(
61
+ messages,
62
+ isRunning,
63
+ aiSdkStreamingTimingAccessors,
64
+ ),
65
+ { initialProps: { messages: [answer], isRunning: false } },
66
+ );
67
+ rerender({ messages: [structuredClone(answer)], isRunning: true });
68
+ rerender({ messages: [structuredClone(answer)], isRunning: false });
69
+ expect(result.current).toEqual({});
70
+ });
71
+
72
+ it("leaves older history untimed when regeneration drops the latest answer", () => {
73
+ const question: UIMessage = {
74
+ id: "u2",
75
+ role: "user",
76
+ parts: [{ type: "text", text: "Next question" }],
77
+ };
78
+ const secondAnswer: UIMessage = { ...answer, id: "a2" };
79
+ const { result, rerender } = renderHook(
80
+ ({ messages, isRunning }) =>
81
+ useStreamingTimingValue(
82
+ messages,
83
+ isRunning,
84
+ aiSdkStreamingTimingAccessors,
85
+ ),
86
+ {
87
+ initialProps: {
88
+ messages: [answer, question, secondAnswer],
89
+ isRunning: false,
90
+ },
91
+ },
92
+ );
93
+ rerender({ messages: [answer, question], isRunning: true });
94
+ rerender({ messages: [answer, question], isRunning: false });
95
+ expect(result.current).toEqual({});
96
+ });