@assistant-ui/ai-sdk 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts +8 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
- package/dist/adapters/aiSDKFormatAdapter.js +26 -0
- package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts +6 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.js +37 -0
- package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
- package/dist/aiSDKExtras.d.ts +11 -0
- package/dist/aiSDKExtras.d.ts.map +1 -0
- package/dist/aiSDKExtras.js +7 -0
- package/dist/aiSDKExtras.js.map +1 -0
- package/dist/converters/convertMessage.d.ts +28 -0
- package/dist/converters/convertMessage.d.ts.map +1 -0
- package/dist/converters/convertMessage.js +249 -0
- package/dist/converters/convertMessage.js.map +1 -0
- package/dist/converters/modelContentEnvelope.d.ts +16 -0
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -0
- package/dist/converters/modelContentEnvelope.js +22 -0
- package/dist/converters/modelContentEnvelope.js.map +1 -0
- package/dist/converters/toCreateMessage.d.ts +7 -0
- package/dist/converters/toCreateMessage.d.ts.map +1 -0
- package/dist/converters/toCreateMessage.js +58 -0
- package/dist/converters/toCreateMessage.js.map +1 -0
- package/dist/converters/toolOutputConversion.d.ts +29 -0
- package/dist/converters/toolOutputConversion.d.ts.map +1 -0
- package/dist/converters/toolOutputConversion.js +31 -0
- package/dist/converters/toolOutputConversion.js.map +1 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +20 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/index.native.d.ts +12 -0
- package/dist/index.native.js +11 -0
- package/dist/model-context/injectInteractableContext.d.ts +37 -0
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
- package/dist/model-context/injectInteractableContext.js +52 -0
- package/dist/model-context/injectInteractableContext.js.map +1 -0
- package/dist/model-context/injectQuoteContext.d.ts +27 -0
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
- package/dist/model-context/injectQuoteContext.js +51 -0
- package/dist/model-context/injectQuoteContext.js.map +1 -0
- package/dist/runtime/AISDKChat.d.ts +16 -0
- package/dist/runtime/AISDKChat.d.ts.map +1 -0
- package/dist/runtime/AISDKChat.js +69 -0
- package/dist/runtime/AISDKChat.js.map +1 -0
- package/dist/runtime/AISDKThreads.d.ts +45 -0
- package/dist/runtime/AISDKThreads.d.ts.map +1 -0
- package/dist/runtime/AISDKThreads.js +203 -0
- package/dist/runtime/AISDKThreads.js.map +1 -0
- package/dist/runtime/useAISDKRuntime.d.ts +49 -0
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
- package/dist/runtime/useAISDKRuntime.js +266 -0
- package/dist/runtime/useAISDKRuntime.js.map +1 -0
- package/dist/runtime/useChatRuntime.d.ts +13 -0
- package/dist/runtime/useChatRuntime.d.ts.map +1 -0
- package/dist/runtime/useChatRuntime.js +52 -0
- package/dist/runtime/useChatRuntime.js.map +1 -0
- package/dist/runtime/useChatThread.d.ts +77 -0
- package/dist/runtime/useChatThread.d.ts.map +1 -0
- package/dist/runtime/useChatThread.js +134 -0
- package/dist/runtime/useChatThread.js.map +1 -0
- package/dist/runtime/useExternalHistory.d.ts +11 -0
- package/dist/runtime/useExternalHistory.d.ts.map +1 -0
- package/dist/runtime/useExternalHistory.js +278 -0
- package/dist/runtime/useExternalHistory.js.map +1 -0
- package/dist/runtime/useResourceCleanup.d.ts +5 -0
- package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
- package/dist/runtime/useResourceCleanup.js +47 -0
- package/dist/runtime/useResourceCleanup.js.map +1 -0
- package/dist/runtime/useStreamingTiming.d.ts +15 -0
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
- package/dist/runtime/useStreamingTiming.js +38 -0
- package/dist/runtime/useStreamingTiming.js.map +1 -0
- package/dist/tools/frontendTools.d.ts +33 -0
- package/dist/tools/frontendTools.d.ts.map +1 -0
- package/dist/tools/frontendTools.js +33 -0
- package/dist/tools/frontendTools.js.map +1 -0
- package/dist/tools/generativeTools.d.ts +73 -0
- package/dist/tools/generativeTools.d.ts.map +1 -0
- package/dist/tools/generativeTools.js +245 -0
- package/dist/tools/generativeTools.js.map +1 -0
- package/dist/tools/mcp-stdio.node.d.ts +2 -0
- package/dist/tools/mcp-stdio.node.js +2 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts +6 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.unsupported.js +11 -0
- package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
- package/dist/transport/AssistantChatTransport.d.ts +23 -0
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
- package/dist/transport/AssistantChatTransport.js +134 -0
- package/dist/transport/AssistantChatTransport.js.map +1 -0
- package/dist/transport/resumable.d.ts +38 -0
- package/dist/transport/resumable.d.ts.map +1 -0
- package/dist/transport/resumable.js +113 -0
- package/dist/transport/resumable.js.map +1 -0
- package/dist/usage.d.ts +19 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +127 -0
- package/dist/usage.js.map +1 -0
- package/dist/utils/getVercelAIMessages.d.ts +7 -0
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
- package/dist/utils/getVercelAIMessages.js +9 -0
- package/dist/utils/getVercelAIMessages.js.map +1 -0
- package/dist/utils/sliceMessagesUntil.d.ts +6 -0
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
- package/dist/utils/sliceMessagesUntil.js +12 -0
- package/dist/utils/sliceMessagesUntil.js.map +1 -0
- package/package.json +100 -0
- package/src/__tests__/standalone.e2e.ts +59 -0
- package/src/adapters/aiSDKFormatAdapter.ts +41 -0
- package/src/adapters/vercelAttachmentAdapter.test.ts +29 -0
- package/src/adapters/vercelAttachmentAdapter.ts +36 -0
- package/src/aiSDKExtras.ts +11 -0
- package/src/converters/convertMessage.test.ts +1130 -0
- package/src/converters/convertMessage.ts +457 -0
- package/src/converters/modelContentEnvelope.ts +41 -0
- package/src/converters/toCreateMessage.test.ts +596 -0
- package/src/converters/toCreateMessage.ts +99 -0
- package/src/converters/toolOutputConversion.ts +26 -0
- package/src/hooks.ts +23 -0
- package/src/index.native.ts +21 -0
- package/src/index.ts +29 -0
- package/src/model-context/injectInteractableContext.test.ts +167 -0
- package/src/model-context/injectInteractableContext.ts +61 -0
- package/src/model-context/injectQuoteContext.test.ts +33 -0
- package/src/model-context/injectQuoteContext.ts +62 -0
- package/src/runtime/AISDKChat.integration.test.tsx +47 -0
- package/src/runtime/AISDKChat.react-compiler.test.ts +49 -0
- package/src/runtime/AISDKChat.test.ts +193 -0
- package/src/runtime/AISDKChat.ts +47 -0
- package/src/runtime/AISDKThreads.cloud.test.ts +136 -0
- package/src/runtime/AISDKThreads.test.ts +548 -0
- package/src/runtime/AISDKThreads.ts +223 -0
- package/src/runtime/__tests__/controlled-transport.ts +43 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +69 -0
- package/src/runtime/useAISDKRuntime.test.ts +1021 -0
- package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
- package/src/runtime/useAISDKRuntime.ts +524 -0
- package/src/runtime/useChatOnTap.test.ts +58 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +67 -0
- package/src/runtime/useChatRuntime.test.ts +522 -0
- package/src/runtime/useChatRuntime.ts +49 -0
- package/src/runtime/useChatThread.ts +286 -0
- package/src/runtime/useExternalHistory.test.ts +1289 -0
- package/src/runtime/useExternalHistory.ts +435 -0
- package/src/runtime/useResourceCleanup.ts +32 -0
- package/src/runtime/useStreamingTiming.ts +68 -0
- package/src/tools/frontendTools.test.ts +205 -0
- package/src/tools/frontendTools.ts +83 -0
- package/src/tools/generativeTools.test.ts +785 -0
- package/src/tools/generativeTools.ts +470 -0
- package/src/tools/mcp-stdio.node.ts +1 -0
- package/src/tools/mcp-stdio.unsupported.ts +12 -0
- package/src/transport/AssistantChatTransport.test.ts +300 -0
- package/src/transport/AssistantChatTransport.ts +204 -0
- package/src/transport/resumable.test.ts +262 -0
- package/src/transport/resumable.ts +161 -0
- package/src/usage.test.ts +186 -0
- package/src/usage.ts +174 -0
- package/src/utils/getVercelAIMessages.ts +11 -0
- package/src/utils/sliceMessagesUntil.test.ts +55 -0
- package/src/utils/sliceMessagesUntil.ts +20 -0
|
@@ -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,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { RESUMABLE_STREAM_ID_HEADER as RESUMABLE_STREAM_ID_HEADER_VALUE } from "assistant-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
|
+
* Defaults to scanning for the AI SDK UIMessageStream `finish` marker.
|
|
158
|
+
* Cancellation never invokes this callback, only natural completion does.
|
|
159
|
+
*/
|
|
160
|
+
isFinishEvent?: (chunk: Uint8Array, accumulator: string) => boolean;
|
|
161
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getLatestThreadTokenUsage, getThreadMessageTokenUsage } from "./usage";
|
|
3
|
+
|
|
4
|
+
function msg(metadata: unknown): { role: "assistant"; metadata: unknown } {
|
|
5
|
+
return {
|
|
6
|
+
role: "assistant",
|
|
7
|
+
metadata,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("getThreadMessageTokenUsage", () => {
|
|
12
|
+
it("does not double-count reasoning/cached in fallback totalTokens", () => {
|
|
13
|
+
const usage = getThreadMessageTokenUsage(
|
|
14
|
+
msg({
|
|
15
|
+
usage: {
|
|
16
|
+
inputTokens: 4,
|
|
17
|
+
outputTokens: 6,
|
|
18
|
+
reasoningTokens: 9,
|
|
19
|
+
cachedInputTokens: 3,
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// totalTokens = input + output only; reasoning/cached are detail fields
|
|
25
|
+
expect(usage).toEqual({
|
|
26
|
+
totalTokens: 10,
|
|
27
|
+
inputTokens: 4,
|
|
28
|
+
outputTokens: 6,
|
|
29
|
+
reasoningTokens: 9,
|
|
30
|
+
cachedInputTokens: 3,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reads v7 token detail objects for reasoning and cached tokens", () => {
|
|
35
|
+
const usage = getThreadMessageTokenUsage(
|
|
36
|
+
msg({
|
|
37
|
+
usage: {
|
|
38
|
+
inputTokens: 4,
|
|
39
|
+
outputTokens: 6,
|
|
40
|
+
inputTokenDetails: { cacheReadTokens: 3, cacheWriteTokens: 1 },
|
|
41
|
+
outputTokenDetails: { textTokens: 5, reasoningTokens: 9 },
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(usage).toEqual({
|
|
47
|
+
totalTokens: 10,
|
|
48
|
+
inputTokens: 4,
|
|
49
|
+
outputTokens: 6,
|
|
50
|
+
reasoningTokens: 9,
|
|
51
|
+
cachedInputTokens: 3,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not fabricate zero splits when only totalTokens is present", () => {
|
|
56
|
+
const usage = getThreadMessageTokenUsage(
|
|
57
|
+
msg({ usage: { totalTokens: 12 } }),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(usage).toEqual({ totalTokens: 12 });
|
|
61
|
+
expect(usage).not.toHaveProperty("inputTokens");
|
|
62
|
+
expect(usage).not.toHaveProperty("outputTokens");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("retains partial usage when only inputTokens is present", () => {
|
|
66
|
+
const usage = getThreadMessageTokenUsage(
|
|
67
|
+
msg({ usage: { inputTokens: 10 } }),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(usage).toEqual({ inputTokens: 10 });
|
|
71
|
+
expect(usage).not.toHaveProperty("totalTokens");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("retains partial usage when only outputTokens is present", () => {
|
|
75
|
+
const usage = getThreadMessageTokenUsage(
|
|
76
|
+
msg({ usage: { outputTokens: 4 } }),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(usage).toEqual({ outputTokens: 4 });
|
|
80
|
+
expect(usage).not.toHaveProperty("totalTokens");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("retains detail-only usage when only reasoning/cached tokens are present", () => {
|
|
84
|
+
const usage = getThreadMessageTokenUsage(
|
|
85
|
+
msg({ usage: { reasoningTokens: 7, cachedInputTokens: 2 } }),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(usage).toEqual({ reasoningTokens: 7, cachedInputTokens: 2 });
|
|
89
|
+
expect(usage).not.toHaveProperty("totalTokens");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("aggregates multi-step usage without inflating totals", () => {
|
|
93
|
+
const usage = getThreadMessageTokenUsage(
|
|
94
|
+
msg({
|
|
95
|
+
steps: [
|
|
96
|
+
{ usage: { inputTokens: 3, outputTokens: 2, reasoningTokens: 11 } },
|
|
97
|
+
{ usage: { inputTokens: 4, outputTokens: 1, reasoningTokens: 13 } },
|
|
98
|
+
],
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(usage).toEqual({
|
|
103
|
+
totalTokens: 10,
|
|
104
|
+
inputTokens: 7,
|
|
105
|
+
outputTokens: 3,
|
|
106
|
+
reasoningTokens: 24,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("omits totalTokens if any step lacks a computable total", () => {
|
|
111
|
+
const usage = getThreadMessageTokenUsage(
|
|
112
|
+
msg({
|
|
113
|
+
steps: [
|
|
114
|
+
{ usage: { inputTokens: 5, outputTokens: 5 } }, // total = 10
|
|
115
|
+
{ usage: { reasoningTokens: 10 } }, // total = undefined
|
|
116
|
+
],
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Sums known partials but omits the invalid total
|
|
121
|
+
expect(usage).toEqual({
|
|
122
|
+
inputTokens: 5,
|
|
123
|
+
outputTokens: 5,
|
|
124
|
+
reasoningTokens: 10,
|
|
125
|
+
});
|
|
126
|
+
expect(usage).not.toHaveProperty("totalTokens");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("aggregates totalTokens if all steps have a computable total", () => {
|
|
130
|
+
const usage = getThreadMessageTokenUsage(
|
|
131
|
+
msg({
|
|
132
|
+
steps: [
|
|
133
|
+
{ usage: { inputTokens: 5, outputTokens: 5 } }, // implicit total = 10
|
|
134
|
+
{ usage: { totalTokens: 15 } }, // explicit total = 15
|
|
135
|
+
],
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(usage).toEqual({
|
|
140
|
+
totalTokens: 25,
|
|
141
|
+
inputTokens: 5,
|
|
142
|
+
outputTokens: 5,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("omits totalTokens when one step has only total and another has only input", () => {
|
|
147
|
+
const usage = getThreadMessageTokenUsage(
|
|
148
|
+
msg({
|
|
149
|
+
steps: [{ usage: { totalTokens: 10 } }, { usage: { inputTokens: 3 } }],
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(usage).toEqual({
|
|
154
|
+
inputTokens: 3,
|
|
155
|
+
});
|
|
156
|
+
expect(usage).not.toHaveProperty("totalTokens");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("getLatestThreadTokenUsage", () => {
|
|
161
|
+
it("falls back to the latest assistant message with usage", () => {
|
|
162
|
+
const usage = getLatestThreadTokenUsage([
|
|
163
|
+
{ role: "assistant", metadata: { usage: { totalTokens: 100 } } },
|
|
164
|
+
{ role: "user", metadata: {} },
|
|
165
|
+
{ role: "assistant", metadata: {} },
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
expect(usage).toEqual({ totalTokens: 100 });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("prefers the newest assistant message when it has usage", () => {
|
|
172
|
+
const usage = getLatestThreadTokenUsage([
|
|
173
|
+
{ role: "assistant", metadata: { usage: { totalTokens: 100 } } },
|
|
174
|
+
{
|
|
175
|
+
role: "assistant",
|
|
176
|
+
metadata: { usage: { inputTokens: 40, outputTokens: 2 } },
|
|
177
|
+
},
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
expect(usage).toEqual({
|
|
181
|
+
totalTokens: 42,
|
|
182
|
+
inputTokens: 40,
|
|
183
|
+
outputTokens: 2,
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|