@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,236 @@
1
+ import type {
2
+ AssistantRuntime,
3
+ ThreadListItemRuntime,
4
+ } from "@ulearning/ai-core";
5
+ import {
6
+ DefaultChatTransport,
7
+ type HttpChatTransportInitOptions,
8
+ type UIMessage,
9
+ } from "ai";
10
+ import { toToolsJSONSchema } from "@ulearning/ai-stream";
11
+ import { SSEEventDecoder } from "@ulearning/ai-stream/utils";
12
+ import {
13
+ RESUMABLE_STREAM_ID_HEADER,
14
+ type AssistantChatResumableOptions,
15
+ } from "./resumable";
16
+
17
+ export type InitializableThreadListItem = Pick<
18
+ ThreadListItemRuntime,
19
+ "initialize"
20
+ >;
21
+
22
+ const FINISH_BUFFER_LIMIT = 4096;
23
+ const FINISH_BUFFER_TAIL = 1024;
24
+ const RESUMABLE_THREAD_ID_HEADER = "x-assistant-ui-resumable-thread-id";
25
+ const RESUMABLE_RECONNECT_ID_HEADER = "x-assistant-ui-resumable-reconnect-id";
26
+
27
+ // 101/204/205/304 are null-body statuses per the fetch spec: `new Response(body, { status })`
28
+ // throws for them, and WebKit returns a non-null empty body, so the body check alone does not guard it.
29
+ const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
30
+
31
+ export type AssistantChatTransportInitOptions<UI_MESSAGE extends UIMessage> =
32
+ HttpChatTransportInitOptions<UI_MESSAGE> & {
33
+ resumable?: AssistantChatResumableOptions;
34
+ };
35
+
36
+ export class AssistantChatTransport<
37
+ UI_MESSAGE extends UIMessage,
38
+ > extends DefaultChatTransport<UI_MESSAGE> {
39
+ private runtime: AssistantRuntime | undefined;
40
+ private getThreadListItem:
41
+ | (() => InitializableThreadListItem | undefined)
42
+ | undefined;
43
+ private readonly resumable: AssistantChatResumableOptions | undefined;
44
+
45
+ private readonly __internal_initOptions:
46
+ | AssistantChatTransportInitOptions<UI_MESSAGE>
47
+ | undefined;
48
+
49
+ /** Constructs an unwired copy with the same init options. */
50
+ public __internal_clone(): AssistantChatTransport<UI_MESSAGE> {
51
+ const Constructor = this.constructor as new (
52
+ initOptions?: AssistantChatTransportInitOptions<UI_MESSAGE>,
53
+ ) => AssistantChatTransport<UI_MESSAGE>;
54
+ return new Constructor(this.__internal_initOptions);
55
+ }
56
+
57
+ constructor(initOptions?: AssistantChatTransportInitOptions<UI_MESSAGE>) {
58
+ const { resumable, ...rest } = initOptions ?? {};
59
+ const userFetch = rest.fetch;
60
+ const userPrepareReconnect = rest.prepareReconnectToStreamRequest;
61
+
62
+ super({
63
+ ...rest,
64
+ ...(resumable && {
65
+ fetch: wrapFetchWithResumable(resumable, userFetch),
66
+ prepareReconnectToStreamRequest: wrapPrepareReconnect(
67
+ resumable,
68
+ userPrepareReconnect,
69
+ ),
70
+ }),
71
+ prepareSendMessagesRequest: async (options) => {
72
+ const threadId = options.id;
73
+ const context = this.runtime?.thread.getModelContext();
74
+ const threadListItem =
75
+ this.getThreadListItem?.() ?? this.runtime?.threads.mainItem;
76
+ const id = (await threadListItem?.initialize())?.remoteId ?? options.id;
77
+
78
+ const optionsEx = {
79
+ ...options,
80
+ id,
81
+ body: {
82
+ callSettings: context?.callSettings,
83
+ system: context?.system,
84
+ config: context?.config,
85
+ tools: toToolsJSONSchema(context?.tools ?? {}),
86
+ ...options?.body,
87
+ },
88
+ };
89
+ const preparedRequest =
90
+ await rest.prepareSendMessagesRequest?.(optionsEx);
91
+ const headers = resumable
92
+ ? new Headers(preparedRequest?.headers ?? options.headers)
93
+ : undefined;
94
+ headers?.set(RESUMABLE_THREAD_ID_HEADER, threadId);
95
+
96
+ return {
97
+ ...preparedRequest,
98
+ ...(headers && { headers }),
99
+ body: preparedRequest?.body ?? {
100
+ ...optionsEx.body,
101
+ id,
102
+ messages: options.messages,
103
+ trigger: options.trigger,
104
+ messageId: options.messageId,
105
+ metadata: options.requestMetadata,
106
+ },
107
+ };
108
+ },
109
+ });
110
+
111
+ this.resumable = resumable;
112
+ this.__internal_initOptions = initOptions;
113
+ }
114
+
115
+ setRuntime(runtime: AssistantRuntime) {
116
+ this.runtime = runtime;
117
+ }
118
+
119
+ getResumableAdapter(): AssistantChatResumableOptions | undefined {
120
+ return this.resumable;
121
+ }
122
+
123
+ __internal_setGetThreadListItem(
124
+ getter: () => InitializableThreadListItem | undefined,
125
+ ) {
126
+ this.getThreadListItem = getter;
127
+ }
128
+ }
129
+
130
+ function wrapFetchWithResumable(
131
+ resumable: AssistantChatResumableOptions,
132
+ userFetch: HttpChatTransportInitOptions<UIMessage>["fetch"],
133
+ ): NonNullable<HttpChatTransportInitOptions<UIMessage>["fetch"]> {
134
+ const baseFetch: typeof globalThis.fetch = userFetch
135
+ ? (input, init) => userFetch(input as RequestInfo | URL, init)
136
+ : globalThis.fetch.bind(globalThis);
137
+
138
+ return async (input, init) => {
139
+ const headers = new Headers(init?.headers);
140
+ const threadId = headers.get(RESUMABLE_THREAD_ID_HEADER) ?? undefined;
141
+ const reconnectingStreamId = headers.get(RESUMABLE_RECONNECT_ID_HEADER);
142
+ const checkpointAtRequest =
143
+ reconnectingStreamId ?? resumable.storage.getStreamId(threadId);
144
+ headers.delete(RESUMABLE_THREAD_ID_HEADER);
145
+ headers.delete(RESUMABLE_RECONNECT_ID_HEADER);
146
+ const res = await baseFetch(input, { ...init, headers });
147
+ const id = res.headers.get(RESUMABLE_STREAM_ID_HEADER);
148
+ const ownsCheckpoint =
149
+ !reconnectingStreamId ||
150
+ resumable.storage.getStreamId(threadId) === reconnectingStreamId;
151
+ if (id && ownsCheckpoint) resumable.storage.setStreamId(id, threadId);
152
+ if (
153
+ (res.status === 204 || res.status === 404) &&
154
+ reconnectingStreamId &&
155
+ resumable.storage.getStreamId(threadId) === reconnectingStreamId
156
+ ) {
157
+ resumable.storage.clear(threadId);
158
+ }
159
+ if (!res.body || NULL_BODY_STATUSES.has(res.status)) return res;
160
+
161
+ const detectFinish =
162
+ resumable.isFinishEvent ?? createDefaultFinishDetector();
163
+ const decoder = resumable.isFinishEvent ? new TextDecoder() : undefined;
164
+ let accumulator = "";
165
+ const tap = new TransformStream<Uint8Array, Uint8Array>({
166
+ transform(chunk, controller) {
167
+ controller.enqueue(chunk);
168
+ accumulator += decoder?.decode(chunk, { stream: true }) ?? "";
169
+ if (detectFinish(chunk, accumulator)) {
170
+ if (
171
+ ownsCheckpoint &&
172
+ resumable.storage.getStreamId(threadId) ===
173
+ (id ?? checkpointAtRequest)
174
+ ) {
175
+ resumable.storage.clear(threadId);
176
+ }
177
+ accumulator = "";
178
+ } else if (accumulator.length > FINISH_BUFFER_LIMIT) {
179
+ accumulator = accumulator.slice(-FINISH_BUFFER_TAIL);
180
+ }
181
+ },
182
+ });
183
+
184
+ return new Response(res.body.pipeThrough(tap), {
185
+ status: res.status,
186
+ statusText: res.statusText,
187
+ headers: res.headers,
188
+ });
189
+ };
190
+ }
191
+
192
+ function createDefaultFinishDetector() {
193
+ const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
194
+ // Observing completion must not impose a smaller event limit than the AI SDK.
195
+ const events = new SSEEventDecoder({
196
+ maxLineLength: Number.MAX_SAFE_INTEGER,
197
+ maxEventLength: Number.MAX_SAFE_INTEGER,
198
+ });
199
+ return (chunk: Uint8Array) =>
200
+ events.push(decoder.decode(chunk, { stream: true })).some(({ data }) => {
201
+ try {
202
+ return JSON.parse(data)?.type === "finish";
203
+ } catch {
204
+ return false;
205
+ }
206
+ });
207
+ }
208
+
209
+ function wrapPrepareReconnect(
210
+ resumable: AssistantChatResumableOptions,
211
+ userPrepareReconnect: HttpChatTransportInitOptions<UIMessage>["prepareReconnectToStreamRequest"],
212
+ ): NonNullable<
213
+ HttpChatTransportInitOptions<UIMessage>["prepareReconnectToStreamRequest"]
214
+ > {
215
+ return async (options) => {
216
+ const streamId = resumable.storage.getStreamId(options.id);
217
+ if (!streamId) {
218
+ throw new Error(
219
+ "AssistantChatTransport: no resumable stream id available; nothing to resume",
220
+ );
221
+ }
222
+ const api =
223
+ typeof resumable.resumeApi === "function"
224
+ ? resumable.resumeApi(streamId)
225
+ : resumable.resumeApi;
226
+ const userPrepared = await userPrepareReconnect?.({ ...options, api });
227
+ const headers = new Headers(userPrepared?.headers ?? options.headers);
228
+ headers.set(RESUMABLE_THREAD_ID_HEADER, options.id);
229
+ headers.set(RESUMABLE_RECONNECT_ID_HEADER, streamId);
230
+ return {
231
+ ...userPrepared,
232
+ headers,
233
+ api: userPrepared?.api ?? api,
234
+ };
235
+ };
236
+ }
@@ -0,0 +1,262 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createResumableSessionStorage } from "./resumable";
5
+
6
+ const originalSessionStorageDescriptor = Object.getOwnPropertyDescriptor(
7
+ window,
8
+ "sessionStorage",
9
+ );
10
+
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ if (originalSessionStorageDescriptor) {
14
+ Object.defineProperty(
15
+ window,
16
+ "sessionStorage",
17
+ originalSessionStorageDescriptor,
18
+ );
19
+ }
20
+ window.sessionStorage.clear();
21
+ });
22
+
23
+ describe("createResumableSessionStorage", () => {
24
+ it("stores stream ids in sessionStorage", () => {
25
+ const storage = createResumableSessionStorage({ key: "test-stream-id" });
26
+
27
+ expect(storage.getStreamId()).toBeNull();
28
+
29
+ storage.setStreamId("stream-1");
30
+ expect(storage.getStreamId()).toBe("stream-1");
31
+
32
+ storage.clear();
33
+ expect(storage.getStreamId()).toBeNull();
34
+ });
35
+
36
+ it("notifies subscribers when the stored stream id changes", () => {
37
+ const storage = createResumableSessionStorage({ key: "test-stream-id" });
38
+ const listener = vi.fn();
39
+ const unsubscribe = storage.subscribe?.(listener);
40
+
41
+ storage.setStreamId("stream-1");
42
+ storage.clear();
43
+
44
+ expect(listener).toHaveBeenCalledTimes(2);
45
+
46
+ unsubscribe?.();
47
+ storage.setStreamId("stream-2");
48
+
49
+ expect(listener).toHaveBeenCalledTimes(2);
50
+ });
51
+
52
+ it("isolates the current stream owner and subscribers by thread", () => {
53
+ const storage = createResumableSessionStorage({ key: "test-stream-id" });
54
+ const listenerA = vi.fn();
55
+ const listenerB = vi.fn();
56
+ storage.subscribe?.(listenerA, "thread-a");
57
+ storage.subscribe?.(listenerB, "thread-b");
58
+
59
+ storage.setStreamId("stream-a", "thread-a");
60
+
61
+ expect(storage.getStreamId("thread-a")).toBe("stream-a");
62
+ expect(storage.getStreamId("thread-b")).toBeNull();
63
+ expect(listenerA).toHaveBeenCalledTimes(1);
64
+ expect(listenerB).not.toHaveBeenCalled();
65
+
66
+ storage.clear("thread-b");
67
+
68
+ expect(storage.getStreamId("thread-a")).toBe("stream-a");
69
+ expect(listenerA).toHaveBeenCalledTimes(1);
70
+ expect(listenerB).not.toHaveBeenCalled();
71
+
72
+ storage.clear("thread-a");
73
+
74
+ expect(storage.getStreamId("thread-a")).toBeNull();
75
+ expect(listenerA).toHaveBeenCalledTimes(2);
76
+ expect(listenerB).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("keeps the persisted key readable after the storage is recreated", () => {
80
+ const storage = createResumableSessionStorage({ key: "test-stream-id" });
81
+ storage.setStreamId("stream-a", "__LOCALID_before_reload");
82
+
83
+ const reloadedStorage = createResumableSessionStorage({
84
+ key: "test-stream-id",
85
+ });
86
+
87
+ expect(reloadedStorage.getStreamId("__LOCALID_after_reload")).toBe(
88
+ "stream-a",
89
+ );
90
+ });
91
+
92
+ it("preserves the current owner for unscoped storage updates", () => {
93
+ const storage = createResumableSessionStorage({ key: "test-stream-id" });
94
+ storage.setStreamId("stream-a", "thread-a");
95
+ storage.subscribe?.(vi.fn(), "thread-b");
96
+
97
+ storage.setStreamId("stream-b");
98
+
99
+ expect(storage.getStreamId("thread-a")).toBe("stream-b");
100
+ expect(storage.getStreamId("thread-b")).toBeNull();
101
+
102
+ storage.clear("thread-a");
103
+ expect(storage.getStreamId("thread-a")).toBeNull();
104
+ });
105
+
106
+ it("caches the last known value between writes", () => {
107
+ const storageMethods = {
108
+ getItem: vi.fn(() => null),
109
+ setItem: vi.fn(),
110
+ removeItem: vi.fn(),
111
+ } as unknown as Storage;
112
+ Object.defineProperty(window, "sessionStorage", {
113
+ configurable: true,
114
+ value: storageMethods,
115
+ });
116
+ const storage = createResumableSessionStorage({ key: "test-stream-id" });
117
+
118
+ expect(storage.getStreamId("thread-a")).toBeNull();
119
+ expect(storage.getStreamId("thread-a")).toBeNull();
120
+
121
+ expect(storageMethods.getItem).toHaveBeenCalledTimes(1);
122
+
123
+ storage.setStreamId("stream-a", "thread-a");
124
+
125
+ expect(storage.getStreamId("thread-a")).toBe("stream-a");
126
+ expect(storageMethods.getItem).toHaveBeenCalledTimes(1);
127
+ });
128
+
129
+ it("isolates subscriber errors", () => {
130
+ const storage = createResumableSessionStorage({ key: "test-stream-id" });
131
+ const error = new Error("listener failed");
132
+ const consoleError = vi
133
+ .spyOn(console, "error")
134
+ .mockImplementation(() => {});
135
+ const laterListener = vi.fn();
136
+
137
+ storage.subscribe?.(() => {
138
+ throw error;
139
+ });
140
+ storage.subscribe?.(laterListener);
141
+
142
+ expect(() => storage.setStreamId("stream-1")).not.toThrow();
143
+ expect(laterListener).toHaveBeenCalledOnce();
144
+ expect(consoleError).toHaveBeenCalledWith(
145
+ "[assistant-ui] resumable storage listener failed",
146
+ error,
147
+ );
148
+ });
149
+
150
+ it("degrades to null and no-op when sessionStorage access is blocked", () => {
151
+ Object.defineProperty(window, "sessionStorage", {
152
+ configurable: true,
153
+ get() {
154
+ throw new DOMException("blocked", "SecurityError");
155
+ },
156
+ });
157
+
158
+ const storage = createResumableSessionStorage();
159
+
160
+ expect(storage.getStreamId()).toBeNull();
161
+ expect(() => storage.setStreamId("stream-1")).not.toThrow();
162
+ expect(() => storage.clear()).not.toThrow();
163
+ });
164
+
165
+ it("degrades to null and no-op when sessionStorage methods throw", () => {
166
+ const throwingStorage = {
167
+ getItem: vi.fn(() => {
168
+ throw new DOMException("blocked", "SecurityError");
169
+ }),
170
+ setItem: vi.fn(() => {
171
+ throw new DOMException("blocked", "SecurityError");
172
+ }),
173
+ removeItem: vi.fn(() => {
174
+ throw new DOMException("blocked", "SecurityError");
175
+ }),
176
+ } as unknown as Storage;
177
+
178
+ Object.defineProperty(window, "sessionStorage", {
179
+ configurable: true,
180
+ value: throwingStorage,
181
+ });
182
+
183
+ const storage = createResumableSessionStorage();
184
+
185
+ expect(storage.getStreamId()).toBeNull();
186
+ expect(() => storage.setStreamId("stream-1")).not.toThrow();
187
+ expect(() => storage.clear()).not.toThrow();
188
+ expect(throwingStorage.getItem).toHaveBeenCalledTimes(1);
189
+ expect(throwingStorage.setItem).toHaveBeenCalledTimes(1);
190
+ expect(throwingStorage.removeItem).toHaveBeenCalledTimes(1);
191
+ });
192
+
193
+ it("scopes the stored id by a getter key, isolating instances", () => {
194
+ const storageA = createResumableSessionStorage({ key: () => "thread-a" });
195
+ const storageB = createResumableSessionStorage({ key: () => "thread-b" });
196
+
197
+ storageA.setStreamId("stream-a");
198
+ expect(storageA.getStreamId()).toBe("stream-a");
199
+ expect(storageB.getStreamId()).toBeNull();
200
+
201
+ storageB.setStreamId("stream-b");
202
+ expect(storageA.getStreamId()).toBe("stream-a");
203
+ expect(storageB.getStreamId()).toBe("stream-b");
204
+
205
+ storageA.clear();
206
+ expect(storageA.getStreamId()).toBeNull();
207
+ expect(storageB.getStreamId()).toBe("stream-b");
208
+ });
209
+
210
+ it("reads a getter key lazily on every access", () => {
211
+ let current = "first";
212
+ const storage = createResumableSessionStorage({ key: () => current });
213
+
214
+ storage.setStreamId("stream-1");
215
+ expect(storage.getStreamId()).toBe("stream-1");
216
+
217
+ current = "second";
218
+ expect(storage.getStreamId()).toBeNull();
219
+
220
+ storage.setStreamId("stream-2");
221
+ expect(storage.getStreamId()).toBe("stream-2");
222
+ expect(window.sessionStorage.getItem("first")).toBe("stream-1");
223
+ expect(window.sessionStorage.getItem("second")).toBe("stream-2");
224
+
225
+ storage.clear();
226
+ expect(window.sessionStorage.getItem("second")).toBeNull();
227
+ expect(window.sessionStorage.getItem("first")).toBe("stream-1");
228
+ });
229
+
230
+ it("disables storage access while the getter returns undefined", () => {
231
+ let current: string | undefined;
232
+ const storage = createResumableSessionStorage({ key: () => current });
233
+
234
+ storage.setStreamId("stream-1");
235
+ expect(storage.getStreamId()).toBeNull();
236
+ expect(window.sessionStorage.getItem("aui-resumable-stream-id")).toBeNull();
237
+
238
+ current = "thread-a";
239
+ storage.setStreamId("stream-1");
240
+ expect(storage.getStreamId()).toBe("stream-1");
241
+
242
+ current = undefined;
243
+ expect(storage.getStreamId()).toBeNull();
244
+ storage.clear();
245
+
246
+ current = "thread-a";
247
+ expect(storage.getStreamId()).toBe("stream-1");
248
+ });
249
+
250
+ it("treats a throwing key getter as no key", () => {
251
+ const storage = createResumableSessionStorage({
252
+ key: () => {
253
+ throw new Error("no thread context");
254
+ },
255
+ });
256
+
257
+ expect(() => storage.setStreamId("stream-1")).not.toThrow();
258
+ expect(storage.getStreamId()).toBeNull();
259
+ expect(() => storage.clear()).not.toThrow();
260
+ expect(window.sessionStorage.getItem("aui-resumable-stream-id")).toBeNull();
261
+ });
262
+ });
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { RESUMABLE_STREAM_ID_HEADER as RESUMABLE_STREAM_ID_HEADER_VALUE } from "@ulearning/ai-stream/resumable";
4
+
5
+ /** Response header used by the [Resumable Streams](/docs/guides/resumable-streams) server and client wiring. */
6
+ export const RESUMABLE_STREAM_ID_HEADER = RESUMABLE_STREAM_ID_HEADER_VALUE;
7
+
8
+ const DEFAULT_STORAGE_KEY = "aui-resumable-stream-id";
9
+
10
+ export type ResumableClientStorage = {
11
+ getStreamId(threadId?: string): string | null;
12
+ setStreamId(id: string, threadId?: string): void;
13
+ clear(threadId?: string): void;
14
+ /** Subscribes to stream id changes so automatic resume can react after mount. */
15
+ subscribe?(listener: () => void, threadId?: string): () => void;
16
+ };
17
+
18
+ const getSessionStorage = (): Storage | null => {
19
+ if (typeof window === "undefined") return null;
20
+ try {
21
+ return window.sessionStorage;
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+
27
+ type StorageSlot = {
28
+ value: string | null;
29
+ owner: string | undefined;
30
+ };
31
+
32
+ /** `sessionStorage`-backed storage for the pending resumable stream id. See the [Resumable Streams](/docs/guides/resumable-streams) guide for end-to-end wiring. */
33
+ export function createResumableSessionStorage(options?: {
34
+ /**
35
+ * Storage key for the pending stream id. A static string namespaces per route
36
+ * or chat surface. A getter is read lazily on every access, so the key can be
37
+ * derived from the active thread's identity; while the getter returns
38
+ * `undefined`, reads report no pending stream and writes are dropped, so a
39
+ * thread whose identity is not known yet never touches another thread's key.
40
+ *
41
+ * Under a remote thread list with more than one thread, scope the key per
42
+ * thread and create one storage instance per thread runtime rather than a
43
+ * single shared one. A shared key is written and cleared by whichever thread
44
+ * acts last, so one conversation's stream can resume inside another.
45
+ */
46
+ key?: string | (() => string | undefined);
47
+ }): ResumableClientStorage {
48
+ const keyOption = options?.key;
49
+ const resolveKey = (): string | undefined => {
50
+ if (typeof keyOption !== "function")
51
+ return keyOption ?? DEFAULT_STORAGE_KEY;
52
+ try {
53
+ return keyOption();
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ };
58
+ const slots = new Map<string, StorageSlot>();
59
+ const listeners = new Set<{
60
+ listener: () => void;
61
+ threadId: string | undefined;
62
+ }>();
63
+ const readSlot = (key: string): StorageSlot => {
64
+ let slot = slots.get(key);
65
+ if (slot) return slot;
66
+ const storage = getSessionStorage();
67
+ let value: string | null = null;
68
+ if (storage) {
69
+ try {
70
+ value = storage.getItem(key);
71
+ } catch {
72
+ value = null;
73
+ }
74
+ }
75
+ slot = { value, owner: undefined };
76
+ slots.set(key, slot);
77
+ return slot;
78
+ };
79
+ const notify = (threadId?: string) => {
80
+ for (const subscription of listeners) {
81
+ if (threadId && subscription.threadId !== threadId) continue;
82
+ try {
83
+ subscription.listener();
84
+ } catch (error) {
85
+ console.error(
86
+ "[assistant-ui] resumable storage listener failed",
87
+ error,
88
+ );
89
+ }
90
+ }
91
+ };
92
+
93
+ return {
94
+ getStreamId(threadId) {
95
+ const key = resolveKey();
96
+ if (!key) return null;
97
+ const slot = readSlot(key);
98
+ if (slot.value === null) return null;
99
+ if (slot.owner && threadId && slot.owner !== threadId) return null;
100
+ return slot.value;
101
+ },
102
+ setStreamId(id, threadId) {
103
+ const key = resolveKey();
104
+ const storage = getSessionStorage();
105
+ if (!key || !storage) return;
106
+ try {
107
+ storage.setItem(key, id);
108
+ } catch {
109
+ // Ignore blocked or unavailable sessionStorage.
110
+ return;
111
+ }
112
+ const slot = readSlot(key);
113
+ slot.value = id;
114
+ if (threadId) {
115
+ slot.owner = threadId;
116
+ } else if (!slot.owner) {
117
+ slot.owner = Array.from(listeners).find(
118
+ (subscription) => subscription.threadId !== undefined,
119
+ )?.threadId;
120
+ }
121
+ notify(slot.owner);
122
+ },
123
+ clear(threadId) {
124
+ const key = resolveKey();
125
+ if (!key) return;
126
+ const slot = readSlot(key);
127
+ if (slot.owner && threadId && slot.owner !== threadId) return;
128
+ const storage = getSessionStorage();
129
+ if (!storage) return;
130
+ try {
131
+ storage.removeItem(key);
132
+ } catch {
133
+ // Ignore blocked or unavailable sessionStorage.
134
+ return;
135
+ }
136
+ slot.value = null;
137
+ slot.owner = undefined;
138
+ notify(threadId);
139
+ },
140
+ subscribe(listener, threadId) {
141
+ const key = resolveKey();
142
+ if (key && threadId) {
143
+ const slot = readSlot(key);
144
+ if (slot.value !== null) slot.owner ??= threadId;
145
+ }
146
+ const subscription = { listener, threadId };
147
+ listeners.add(subscription);
148
+ return () => listeners.delete(subscription);
149
+ },
150
+ };
151
+ }
152
+
153
+ export type AssistantChatResumableOptions = {
154
+ storage: ResumableClientStorage;
155
+ resumeApi: string | ((streamId: string) => string);
156
+ /**
157
+ * Called for each chunk with the accumulated decoded text. Return true when
158
+ * the stream has finished. The default detects complete SSE events whose
159
+ * top-level JSON type is `finish`. Cancellation does not invoke this callback.
160
+ */
161
+ isFinishEvent?: (chunk: Uint8Array, accumulator: string) => boolean;
162
+ };