@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.
- package/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/adapters/aiSDKFormatAdapter.d.ts +6 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
- package/dist/adapters/aiSDKFormatAdapter.js +17 -0
- package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts +3 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.js +36 -0
- package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
- package/dist/aiSDKExtras.d.ts +13 -0
- package/dist/aiSDKExtras.d.ts.map +1 -0
- package/dist/aiSDKExtras.js +6 -0
- package/dist/aiSDKExtras.js.map +1 -0
- package/dist/converters/convertMessage.d.ts +44 -0
- package/dist/converters/convertMessage.d.ts.map +1 -0
- package/dist/converters/convertMessage.js +465 -0
- package/dist/converters/convertMessage.js.map +1 -0
- package/dist/converters/toCreateMessage.d.ts +4 -0
- package/dist/converters/toCreateMessage.d.ts.map +1 -0
- package/dist/converters/toCreateMessage.js +65 -0
- package/dist/converters/toCreateMessage.js.map +1 -0
- package/dist/converters/toolApprovalAnswers.d.ts +3 -0
- package/dist/converters/toolApprovalAnswers.d.ts.map +1 -0
- package/dist/converters/toolApprovalAnswers.js +16 -0
- package/dist/converters/toolApprovalAnswers.js.map +1 -0
- package/dist/hooks.d.ts +17 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +19 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -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 +54 -0
- package/dist/model-context/injectInteractableContext.js.map +1 -0
- package/dist/model-context/injectQuoteContext.d.ts +25 -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 +13 -0
- package/dist/runtime/AISDKChat.d.ts.map +1 -0
- package/dist/runtime/AISDKChat.js +34 -0
- package/dist/runtime/AISDKChat.js.map +1 -0
- package/dist/runtime/AISDKThreads.d.ts +24 -0
- package/dist/runtime/AISDKThreads.d.ts.map +1 -0
- package/dist/runtime/AISDKThreads.js +92 -0
- package/dist/runtime/AISDKThreads.js.map +1 -0
- package/dist/runtime/DynamicChatTransport.d.ts +50 -0
- package/dist/runtime/DynamicChatTransport.d.ts.map +1 -0
- package/dist/runtime/DynamicChatTransport.js +153 -0
- package/dist/runtime/DynamicChatTransport.js.map +1 -0
- package/dist/runtime/getResumableAdapter.d.ts +5 -0
- package/dist/runtime/getResumableAdapter.d.ts.map +1 -0
- package/dist/runtime/getResumableAdapter.js +11 -0
- package/dist/runtime/getResumableAdapter.js.map +1 -0
- package/dist/runtime/toolHistoryCodec.d.ts +20 -0
- package/dist/runtime/toolHistoryCodec.d.ts.map +1 -0
- package/dist/runtime/toolHistoryCodec.js +122 -0
- package/dist/runtime/toolHistoryCodec.js.map +1 -0
- package/dist/runtime/useAISDKRuntime.d.ts +95 -0
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
- package/dist/runtime/useAISDKRuntime.js +754 -0
- package/dist/runtime/useAISDKRuntime.js.map +1 -0
- package/dist/runtime/useChatRuntime.d.ts +17 -0
- package/dist/runtime/useChatRuntime.d.ts.map +1 -0
- package/dist/runtime/useChatRuntime.js +50 -0
- package/dist/runtime/useChatRuntime.js.map +1 -0
- package/dist/runtime/useChatSnapshot.d.ts +6 -0
- package/dist/runtime/useChatSnapshot.d.ts.map +1 -0
- package/dist/runtime/useChatSnapshot.js +62 -0
- package/dist/runtime/useChatSnapshot.js.map +1 -0
- package/dist/runtime/useChatThread.d.ts +121 -0
- package/dist/runtime/useChatThread.d.ts.map +1 -0
- package/dist/runtime/useChatThread.js +255 -0
- package/dist/runtime/useChatThread.js.map +1 -0
- package/dist/runtime/useDynamicChatTransport.d.ts +4 -0
- package/dist/runtime/useDynamicChatTransport.d.ts.map +1 -0
- package/dist/runtime/useDynamicChatTransport.js +26 -0
- package/dist/runtime/useDynamicChatTransport.js.map +1 -0
- package/dist/runtime/useExternalHistory.d.ts +12 -0
- package/dist/runtime/useExternalHistory.d.ts.map +1 -0
- package/dist/runtime/useExternalHistory.js +556 -0
- package/dist/runtime/useExternalHistory.js.map +1 -0
- package/dist/runtime/useMessageQueue.d.ts +14 -0
- package/dist/runtime/useMessageQueue.d.ts.map +1 -0
- package/dist/runtime/useMessageQueue.js +142 -0
- package/dist/runtime/useMessageQueue.js.map +1 -0
- package/dist/runtime/useResourceCleanup.d.ts +2 -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 +12 -0
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
- package/dist/runtime/useStreamingTiming.js +36 -0
- package/dist/runtime/useStreamingTiming.js.map +1 -0
- package/dist/tools/generativeTools.d.ts +71 -0
- package/dist/tools/generativeTools.d.ts.map +1 -0
- package/dist/tools/generativeTools.js +258 -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.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.node.js +1 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts +3 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.unsupported.js +10 -0
- package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
- package/dist/transport/AssistantChatTransport.d.ts +20 -0
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
- package/dist/transport/AssistantChatTransport.js +151 -0
- package/dist/transport/AssistantChatTransport.js.map +1 -0
- package/dist/transport/resumable.d.ts +36 -0
- package/dist/transport/resumable.d.ts.map +1 -0
- package/dist/transport/resumable.js +112 -0
- package/dist/transport/resumable.js.map +1 -0
- package/dist/usage.d.ts +24 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +121 -0
- package/dist/usage.js.map +1 -0
- package/dist/utils/getVercelAIMessages.d.ts +4 -0
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
- package/dist/utils/getVercelAIMessages.js +8 -0
- package/dist/utils/getVercelAIMessages.js.map +1 -0
- package/dist/utils/sliceMessagesUntil.d.ts +3 -0
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
- package/dist/utils/sliceMessagesUntil.js +10 -0
- package/dist/utils/sliceMessagesUntil.js.map +1 -0
- package/package.json +96 -3
- package/src/__tests__/standalone.e2e.ts +118 -0
- package/src/adapters/aiSDKFormatAdapter.test.ts +36 -0
- package/src/adapters/aiSDKFormatAdapter.ts +18 -0
- package/src/adapters/vercelAttachmentAdapter.test.ts +52 -0
- package/src/adapters/vercelAttachmentAdapter.ts +36 -0
- package/src/aiSDKExtras.ts +31 -0
- package/src/converters/convertMessage.metadata-key.test.tsx +182 -0
- package/src/converters/convertMessage.test.ts +2428 -0
- package/src/converters/convertMessage.tool-args-status.test.tsx +185 -0
- package/src/converters/convertMessage.ts +919 -0
- package/src/converters/toCreateMessage.test.ts +716 -0
- package/src/converters/toCreateMessage.ts +110 -0
- package/src/converters/toolApprovalAnswers.ts +27 -0
- package/src/hooks.test.ts +168 -0
- package/src/hooks.ts +26 -0
- package/src/index.ts +29 -0
- package/src/model-context/injectInteractableContext.test.ts +167 -0
- package/src/model-context/injectInteractableContext.ts +64 -0
- package/src/model-context/injectQuoteContext.test.ts +33 -0
- package/src/model-context/injectQuoteContext.ts +63 -0
- package/src/runtime/AISDKChat.artifacts.test.ts +48 -0
- package/src/runtime/AISDKChat.integration.test.tsx +148 -0
- package/src/runtime/AISDKChat.test.ts +241 -0
- package/src/runtime/AISDKChat.ts +43 -0
- package/src/runtime/AISDKThreads.test.ts +811 -0
- package/src/runtime/AISDKThreads.ts +194 -0
- package/src/runtime/DynamicChatTransport.test.ts +228 -0
- package/src/runtime/DynamicChatTransport.ts +281 -0
- package/src/runtime/__tests__/controlled-transport.ts +70 -0
- package/src/runtime/__tests__/tap.ts +80 -0
- package/src/runtime/__tests__/vue.ts +46 -0
- package/src/runtime/getResumableAdapter.ts +16 -0
- package/src/runtime/toolHistoryCodec.test.ts +187 -0
- package/src/runtime/toolHistoryCodec.ts +245 -0
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1956 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +580 -0
- package/src/runtime/useAISDKRuntime.denied-tool.test.tsx +122 -0
- package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +302 -0
- package/src/runtime/useAISDKRuntime.metadata-cache.test.tsx +157 -0
- package/src/runtime/useAISDKRuntime.queue.test.tsx +707 -0
- package/src/runtime/useAISDKRuntime.reload.test.tsx +217 -0
- package/src/runtime/useAISDKRuntime.test.ts +2345 -0
- package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
- package/src/runtime/useAISDKRuntime.ts +1503 -0
- package/src/runtime/useAISDKRuntime.voice.test.tsx +319 -0
- package/src/runtime/useChatOnTap.test.ts +60 -0
- package/src/runtime/useChatRuntime.fast-refresh.test.tsx +83 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +661 -0
- package/src/runtime/useChatRuntime.local-storage.test.tsx +114 -0
- package/src/runtime/useChatRuntime.native.test.ts +147 -0
- package/src/runtime/useChatRuntime.run-telemetry.test.tsx +226 -0
- package/src/runtime/useChatRuntime.test.ts +619 -0
- package/src/runtime/useChatRuntime.ts +80 -0
- package/src/runtime/useChatSnapshot.test.ts +60 -0
- package/src/runtime/useChatSnapshot.ts +86 -0
- package/src/runtime/useChatThread.binding.test.tsx +140 -0
- package/src/runtime/useChatThread.test.ts +993 -0
- package/src/runtime/useChatThread.transport.test.tsx +149 -0
- package/src/runtime/useChatThread.ts +533 -0
- package/src/runtime/useDynamicChatTransport.ts +26 -0
- package/src/runtime/useExternalHistory.test.ts +4187 -0
- package/src/runtime/useExternalHistory.ts +902 -0
- package/src/runtime/useMessageQueue.effects.test.tsx +88 -0
- package/src/runtime/useMessageQueue.test.tsx +256 -0
- package/src/runtime/useMessageQueue.ts +189 -0
- package/src/runtime/useResourceCleanup.ts +58 -0
- package/src/runtime/useStreamingTiming.test.tsx +96 -0
- package/src/runtime/useStreamingTiming.ts +69 -0
- package/src/tools/generativeTools.test.ts +1058 -0
- package/src/tools/generativeTools.ts +491 -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 +631 -0
- package/src/transport/AssistantChatTransport.ts +236 -0
- package/src/transport/resumable.test.ts +262 -0
- package/src/transport/resumable.ts +162 -0
- package/src/usage.test.ts +204 -0
- package/src/usage.ts +179 -0
- package/src/utils/getVercelAIMessages.ts +11 -0
- package/src/utils/sliceMessagesUntil.test.ts +51 -0
- package/src/utils/sliceMessagesUntil.ts +16 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { RESUMABLE_STREAM_ID_HEADER } from "./resumable";
|
|
5
|
+
import { AssistantChatTransport } from "./AssistantChatTransport";
|
|
6
|
+
|
|
7
|
+
const emptyStreamResponse = () =>
|
|
8
|
+
new Response(
|
|
9
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
10
|
+
{
|
|
11
|
+
status: 200,
|
|
12
|
+
headers: { "content-type": "text/event-stream" },
|
|
13
|
+
},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const createThreadListItem = (remoteId: string) => ({
|
|
17
|
+
initialize: vi.fn(async () => ({ remoteId, externalId: undefined })),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const sendMessagesOptions = {
|
|
21
|
+
trigger: "submit-message" as const,
|
|
22
|
+
chatId: "local-chat-id",
|
|
23
|
+
messageId: undefined,
|
|
24
|
+
messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
|
|
25
|
+
abortSignal: undefined,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("AssistantChatTransport.prepareSendMessagesRequest", () => {
|
|
29
|
+
it("passes the initialized remote thread id to prepareSendMessagesRequest as options.id", async () => {
|
|
30
|
+
const threadListItem = createThreadListItem("remote-thread-id");
|
|
31
|
+
const captured: { id: unknown } = { id: undefined };
|
|
32
|
+
const fetchMock = vi.fn(async () => emptyStreamResponse());
|
|
33
|
+
|
|
34
|
+
const transport = new AssistantChatTransport({
|
|
35
|
+
fetch: fetchMock as never,
|
|
36
|
+
prepareSendMessagesRequest: async (options) => {
|
|
37
|
+
captured.id = options.id;
|
|
38
|
+
return { body: { id: options.id } };
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
transport.__internal_setGetThreadListItem(() => threadListItem as never);
|
|
42
|
+
|
|
43
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
44
|
+
|
|
45
|
+
expect(captured.id).toBe("remote-thread-id");
|
|
46
|
+
expect(threadListItem.initialize).toHaveBeenCalledTimes(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("uses the initialized remote thread id in the default request body", async () => {
|
|
50
|
+
const threadListItem = createThreadListItem("remote-thread-id");
|
|
51
|
+
let capturedBody: string | undefined;
|
|
52
|
+
const fetchMock = vi.fn(
|
|
53
|
+
async (_input: unknown, init: { body?: string } | undefined) => {
|
|
54
|
+
capturedBody = init?.body;
|
|
55
|
+
return emptyStreamResponse();
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const transport = new AssistantChatTransport({
|
|
60
|
+
fetch: fetchMock as never,
|
|
61
|
+
});
|
|
62
|
+
transport.__internal_setGetThreadListItem(() => threadListItem as never);
|
|
63
|
+
|
|
64
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
65
|
+
|
|
66
|
+
expect(threadListItem.initialize).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
68
|
+
const body = JSON.parse(capturedBody as string);
|
|
69
|
+
expect(body.id).toBe("remote-thread-id");
|
|
70
|
+
expect(body.messages).toEqual(sendMessagesOptions.messages);
|
|
71
|
+
expect(body.trigger).toBe("submit-message");
|
|
72
|
+
expect(body.messageId).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("falls back to the local chat id when no thread list item is available", async () => {
|
|
76
|
+
const captured: { id: unknown } = { id: undefined };
|
|
77
|
+
const fetchMock = vi.fn(async () => emptyStreamResponse());
|
|
78
|
+
|
|
79
|
+
const transport = new AssistantChatTransport({
|
|
80
|
+
fetch: fetchMock as never,
|
|
81
|
+
prepareSendMessagesRequest: async (options) => {
|
|
82
|
+
captured.id = options.id;
|
|
83
|
+
return { body: { id: options.id } };
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
transport.__internal_setGetThreadListItem(() => undefined);
|
|
87
|
+
|
|
88
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
89
|
+
|
|
90
|
+
expect(captured.id).toBe("local-chat-id");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("stores response stream ids under the local thread id", async () => {
|
|
94
|
+
const setStreamId = vi.fn();
|
|
95
|
+
const fetchMock = vi.fn(async (_input: string, _init?: RequestInit) => {
|
|
96
|
+
return new Response(
|
|
97
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
98
|
+
{
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: {
|
|
101
|
+
"content-type": "text/event-stream",
|
|
102
|
+
[RESUMABLE_STREAM_ID_HEADER]: "stream-1",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
const transport = new AssistantChatTransport({
|
|
108
|
+
fetch: fetchMock as never,
|
|
109
|
+
resumable: {
|
|
110
|
+
storage: {
|
|
111
|
+
getStreamId: vi.fn(),
|
|
112
|
+
setStreamId,
|
|
113
|
+
clear: vi.fn(),
|
|
114
|
+
},
|
|
115
|
+
resumeApi: "/api/chat/resume",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
120
|
+
|
|
121
|
+
expect(setStreamId).toHaveBeenCalledWith("stream-1", "local-chat-id");
|
|
122
|
+
const requestHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
|
123
|
+
expect(requestHeaders.has("x-assistant-ui-resumable-thread-id")).toBe(
|
|
124
|
+
false,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("does not add the resumable thread header without resumable storage", async () => {
|
|
129
|
+
const fetchMock = vi.fn(async (_input: string, _init?: RequestInit) =>
|
|
130
|
+
emptyStreamResponse(),
|
|
131
|
+
);
|
|
132
|
+
const transport = new AssistantChatTransport({
|
|
133
|
+
fetch: fetchMock as never,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
137
|
+
|
|
138
|
+
const requestHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
|
139
|
+
expect(requestHeaders.has("x-assistant-ui-resumable-thread-id")).toBe(
|
|
140
|
+
false,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reads reconnect stream ids under the local thread id", async () => {
|
|
145
|
+
const getStreamId = vi.fn(() => "stream-1");
|
|
146
|
+
const fetchMock = vi.fn(async (_input: string, _init?: RequestInit) =>
|
|
147
|
+
emptyStreamResponse(),
|
|
148
|
+
);
|
|
149
|
+
const transport = new AssistantChatTransport({
|
|
150
|
+
fetch: fetchMock as never,
|
|
151
|
+
resumable: {
|
|
152
|
+
storage: {
|
|
153
|
+
getStreamId,
|
|
154
|
+
setStreamId: vi.fn(),
|
|
155
|
+
clear: vi.fn(),
|
|
156
|
+
},
|
|
157
|
+
resumeApi: (streamId) => `/api/chat/resume/${streamId}`,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await transport.reconnectToStream({ chatId: "local-chat-id" });
|
|
162
|
+
|
|
163
|
+
expect(getStreamId).toHaveBeenCalledWith("local-chat-id");
|
|
164
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/chat/resume/stream-1");
|
|
165
|
+
const requestHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
|
166
|
+
expect(requestHeaders.has("x-assistant-ui-resumable-thread-id")).toBe(
|
|
167
|
+
false,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const createMemoryStorage = (initialId: string | null = null) => {
|
|
173
|
+
let id = initialId;
|
|
174
|
+
return {
|
|
175
|
+
getStreamId: () => id,
|
|
176
|
+
setStreamId: (next: string) => {
|
|
177
|
+
id = next;
|
|
178
|
+
},
|
|
179
|
+
clear: () => {
|
|
180
|
+
id = null;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// A null-body status response carrying a non-null empty body, as WebKit returns
|
|
186
|
+
// for a 204. The JS `Response` constructor cannot represent a null-body status
|
|
187
|
+
// with a body, so this is a plain object shaped like a Response.
|
|
188
|
+
const nullBodyStatusWithBody = (status: number) =>
|
|
189
|
+
({
|
|
190
|
+
status,
|
|
191
|
+
statusText: "",
|
|
192
|
+
headers: new Headers(),
|
|
193
|
+
body: new ReadableStream({ start: (controller) => controller.close() }),
|
|
194
|
+
}) as unknown as Response;
|
|
195
|
+
|
|
196
|
+
// The resumable wrapper replaces the transport's `fetch`, which is protected on
|
|
197
|
+
// the upstream `HttpChatTransport`; reach it through a structural cast.
|
|
198
|
+
const wrappedFetchOf = (
|
|
199
|
+
transport: AssistantChatTransport<UIMessage>,
|
|
200
|
+
): ((input: string, init?: unknown) => Promise<Response>) =>
|
|
201
|
+
(
|
|
202
|
+
transport as unknown as {
|
|
203
|
+
fetch: (input: string, init?: unknown) => Promise<Response>;
|
|
204
|
+
}
|
|
205
|
+
).fetch;
|
|
206
|
+
|
|
207
|
+
describe("AssistantChatTransport resumable fetch wrapper", () => {
|
|
208
|
+
it.each([
|
|
209
|
+
{ type: "data-progress", data: { type: "finish", step: "search" } },
|
|
210
|
+
{
|
|
211
|
+
type: "tool-output-available",
|
|
212
|
+
toolCallId: "call-1",
|
|
213
|
+
output: { type: "finish" },
|
|
214
|
+
},
|
|
215
|
+
{ type: "message-metadata", messageMetadata: { type: "finish" } },
|
|
216
|
+
])(
|
|
217
|
+
"keeps $type resumable when its payload contains a finish type",
|
|
218
|
+
async (event) => {
|
|
219
|
+
const storage = createMemoryStorage();
|
|
220
|
+
const fetch = vi
|
|
221
|
+
.fn<typeof globalThis.fetch>()
|
|
222
|
+
.mockResolvedValueOnce(
|
|
223
|
+
new Response(`data: ${JSON.stringify(event)}\n\n`, {
|
|
224
|
+
headers: { [RESUMABLE_STREAM_ID_HEADER]: "active-stream" },
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
.mockResolvedValueOnce(new Response('data: {"type":"finish"}\n\n'));
|
|
228
|
+
const transport = new AssistantChatTransport({
|
|
229
|
+
fetch,
|
|
230
|
+
resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const stream = await transport.sendMessages(sendMessagesOptions as never);
|
|
234
|
+
const reader = stream.getReader();
|
|
235
|
+
expect((await reader.read()).value).toEqual(event);
|
|
236
|
+
await reader.cancel();
|
|
237
|
+
|
|
238
|
+
expect(storage.getStreamId()).toBe("active-stream");
|
|
239
|
+
const resumed = await transport.reconnectToStream({
|
|
240
|
+
chatId: "local-chat-id",
|
|
241
|
+
});
|
|
242
|
+
expect(fetch.mock.calls[1]?.[0]).toBe("/api/resume/active-stream");
|
|
243
|
+
const resumedReader = resumed!.getReader();
|
|
244
|
+
expect((await resumedReader.read()).value).toEqual({ type: "finish" });
|
|
245
|
+
expect(storage.getStreamId()).toBeNull();
|
|
246
|
+
await resumedReader.cancel();
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
it.each(["\n", "\r\n", "\r"])(
|
|
251
|
+
"detects complete multiline finish events across byte boundaries (%j)",
|
|
252
|
+
async (newline) => {
|
|
253
|
+
const storage = createMemoryStorage("active-stream");
|
|
254
|
+
const body = [
|
|
255
|
+
"\uFEFF: heartbeat",
|
|
256
|
+
'data: {"messageMetadata": {"note":"✓"},',
|
|
257
|
+
'data: "type": "finish"}',
|
|
258
|
+
"",
|
|
259
|
+
"",
|
|
260
|
+
].join(newline);
|
|
261
|
+
const transport = new AssistantChatTransport({
|
|
262
|
+
fetch: async () =>
|
|
263
|
+
new Response(
|
|
264
|
+
new ReadableStream({
|
|
265
|
+
start(controller) {
|
|
266
|
+
for (const byte of new TextEncoder().encode(body)) {
|
|
267
|
+
controller.enqueue(new Uint8Array([byte]));
|
|
268
|
+
}
|
|
269
|
+
controller.close();
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
),
|
|
273
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const response = await wrappedFetchOf(transport)("https://example.com");
|
|
277
|
+
expect(new Uint8Array(await response.arrayBuffer())).toEqual(
|
|
278
|
+
new TextEncoder().encode(body),
|
|
279
|
+
);
|
|
280
|
+
expect(storage.getStreamId()).toBeNull();
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
it.each([
|
|
285
|
+
'data: {"type":"finish"}',
|
|
286
|
+
': {"type":"finish"}\n\n',
|
|
287
|
+
'data: {"type":"finish",}\n\n',
|
|
288
|
+
"data: null\n\n",
|
|
289
|
+
"data: [DONE]\n\n",
|
|
290
|
+
])("retains the checkpoint for non-finish input %j", async (body) => {
|
|
291
|
+
const storage = createMemoryStorage("active-stream");
|
|
292
|
+
const transport = new AssistantChatTransport({
|
|
293
|
+
fetch: async () => new Response(body),
|
|
294
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
295
|
+
});
|
|
296
|
+
const response = await wrappedFetchOf(transport)("https://example.com");
|
|
297
|
+
|
|
298
|
+
expect(await response.text()).toBe(body);
|
|
299
|
+
expect(storage.getStreamId()).toBe("active-stream");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("waits for the finish event delimiter before clearing the checkpoint", async () => {
|
|
303
|
+
const storage = createMemoryStorage("active-stream");
|
|
304
|
+
let source!: ReadableStreamDefaultController<Uint8Array>;
|
|
305
|
+
const transport = new AssistantChatTransport({
|
|
306
|
+
fetch: async () =>
|
|
307
|
+
new Response(
|
|
308
|
+
new ReadableStream({
|
|
309
|
+
start(controller) {
|
|
310
|
+
source = controller;
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
),
|
|
314
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
315
|
+
});
|
|
316
|
+
const response = await wrappedFetchOf(transport)("https://example.com");
|
|
317
|
+
const reader = response.body!.getReader();
|
|
318
|
+
|
|
319
|
+
source.enqueue(new TextEncoder().encode('data: {"type":"finish"}\n'));
|
|
320
|
+
await reader.read();
|
|
321
|
+
expect(storage.getStreamId()).toBe("active-stream");
|
|
322
|
+
source.enqueue(new TextEncoder().encode("\n"));
|
|
323
|
+
await reader.read();
|
|
324
|
+
expect(storage.getStreamId()).toBeNull();
|
|
325
|
+
await reader.cancel();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("keeps large events intact before checking their top-level type", async () => {
|
|
329
|
+
const storage = createMemoryStorage("active-stream");
|
|
330
|
+
const body = `data: ${JSON.stringify({
|
|
331
|
+
type: "finish",
|
|
332
|
+
messageMetadata: { text: "x".repeat(8192) },
|
|
333
|
+
})}\n\n`;
|
|
334
|
+
const transport = new AssistantChatTransport({
|
|
335
|
+
fetch: async () =>
|
|
336
|
+
new Response(
|
|
337
|
+
new ReadableStream({
|
|
338
|
+
start(controller) {
|
|
339
|
+
controller.enqueue(new TextEncoder().encode(body.slice(0, 6000)));
|
|
340
|
+
controller.enqueue(new TextEncoder().encode(body.slice(6000)));
|
|
341
|
+
controller.close();
|
|
342
|
+
},
|
|
343
|
+
}),
|
|
344
|
+
),
|
|
345
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
346
|
+
});
|
|
347
|
+
const response = await wrappedFetchOf(transport)("https://example.com");
|
|
348
|
+
|
|
349
|
+
expect(await response.text()).toBe(body);
|
|
350
|
+
expect(storage.getStreamId()).toBeNull();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("preserves custom detectors and their decoded accumulator", async () => {
|
|
354
|
+
const storage = createMemoryStorage("active-stream");
|
|
355
|
+
const bytes = new TextEncoder().encode("✓done");
|
|
356
|
+
const chunks = [bytes.slice(0, 1), bytes.slice(1, 2), bytes.slice(2)];
|
|
357
|
+
const isFinishEvent = vi.fn(
|
|
358
|
+
(_chunk: Uint8Array, text: string) => text === "✓done",
|
|
359
|
+
);
|
|
360
|
+
const transport = new AssistantChatTransport({
|
|
361
|
+
fetch: async () =>
|
|
362
|
+
new Response(
|
|
363
|
+
new ReadableStream({
|
|
364
|
+
start(controller) {
|
|
365
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
366
|
+
controller.close();
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
),
|
|
370
|
+
resumable: { storage, resumeApi: "/api/resume", isFinishEvent },
|
|
371
|
+
});
|
|
372
|
+
const response = await wrappedFetchOf(transport)("https://example.com");
|
|
373
|
+
|
|
374
|
+
expect(await response.text()).toBe("✓done");
|
|
375
|
+
expect(isFinishEvent.mock.calls).toEqual([
|
|
376
|
+
[chunks[0], ""],
|
|
377
|
+
[chunks[1], ""],
|
|
378
|
+
[chunks[2], "✓done"],
|
|
379
|
+
]);
|
|
380
|
+
expect(storage.getStreamId()).toBeNull();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("keeps a replacement checkpoint set while preparing an older reconnect", async () => {
|
|
384
|
+
const storage = createMemoryStorage("stream-old");
|
|
385
|
+
let finishPrepare!: () => void;
|
|
386
|
+
const prepareReconnectToStreamRequest = vi.fn(async () => {
|
|
387
|
+
await new Promise<void>((resolve) => {
|
|
388
|
+
finishPrepare = resolve;
|
|
389
|
+
});
|
|
390
|
+
return { headers: { "x-custom": "retained" } };
|
|
391
|
+
});
|
|
392
|
+
const fetch = vi
|
|
393
|
+
.fn<typeof globalThis.fetch>()
|
|
394
|
+
.mockResolvedValue(new Response(null, { status: 204 }));
|
|
395
|
+
const transport = new AssistantChatTransport({
|
|
396
|
+
fetch,
|
|
397
|
+
prepareReconnectToStreamRequest,
|
|
398
|
+
resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
|
|
399
|
+
});
|
|
400
|
+
const pending = transport.reconnectToStream({ chatId: "thread" });
|
|
401
|
+
await vi.waitFor(() =>
|
|
402
|
+
expect(prepareReconnectToStreamRequest).toHaveBeenCalledOnce(),
|
|
403
|
+
);
|
|
404
|
+
storage.setStreamId("stream-new");
|
|
405
|
+
finishPrepare();
|
|
406
|
+
await expect(pending).resolves.toBeNull();
|
|
407
|
+
expect(fetch.mock.calls[0]?.[0]).toBe("/api/resume/stream-old");
|
|
408
|
+
expect(
|
|
409
|
+
Array.from(new Headers(fetch.mock.calls[0]?.[1]?.headers).entries()),
|
|
410
|
+
).toEqual([["x-custom", "retained"]]);
|
|
411
|
+
expect(storage.getStreamId()).toBe("stream-new");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it.each([
|
|
415
|
+
{ status: 204, replaceCheckpoint: false },
|
|
416
|
+
{ status: 204, replaceCheckpoint: true },
|
|
417
|
+
{ status: 404, replaceCheckpoint: false },
|
|
418
|
+
{ status: 404, replaceCheckpoint: true },
|
|
419
|
+
])(
|
|
420
|
+
"clears only the matching checkpoint after $status (replacement: $replaceCheckpoint)",
|
|
421
|
+
async ({ status, replaceCheckpoint }) => {
|
|
422
|
+
const storage = createMemoryStorage("stream-old");
|
|
423
|
+
let respond!: (response: Response) => void;
|
|
424
|
+
const fetch = vi.fn(
|
|
425
|
+
() =>
|
|
426
|
+
new Promise<Response>((resolve) => {
|
|
427
|
+
respond = resolve;
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
const transport = new AssistantChatTransport({
|
|
431
|
+
fetch,
|
|
432
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
433
|
+
});
|
|
434
|
+
const pending = transport.reconnectToStream({ chatId: "thread" });
|
|
435
|
+
await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
|
|
436
|
+
if (replaceCheckpoint) storage.setStreamId("stream-new");
|
|
437
|
+
respond(
|
|
438
|
+
new Response(status === 404 ? "stream expired" : null, { status }),
|
|
439
|
+
);
|
|
440
|
+
if (status === 404) {
|
|
441
|
+
await expect(pending).rejects.toThrow("stream expired");
|
|
442
|
+
} else {
|
|
443
|
+
await expect(pending).resolves.toBeNull();
|
|
444
|
+
}
|
|
445
|
+
expect(storage.getStreamId()).toBe(
|
|
446
|
+
replaceCheckpoint ? "stream-new" : null,
|
|
447
|
+
);
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
it.each([undefined, "stream-old", "response-id"])(
|
|
452
|
+
"preserves a replacement checkpoint on a delayed successful reconnect (%s)",
|
|
453
|
+
async (responseId) => {
|
|
454
|
+
const storage = createMemoryStorage("stream-old");
|
|
455
|
+
let respond!: (response: Response) => void;
|
|
456
|
+
const fetch = vi.fn(
|
|
457
|
+
() =>
|
|
458
|
+
new Promise<Response>((resolve) => {
|
|
459
|
+
respond = resolve;
|
|
460
|
+
}),
|
|
461
|
+
);
|
|
462
|
+
const transport = new AssistantChatTransport({
|
|
463
|
+
fetch,
|
|
464
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
465
|
+
});
|
|
466
|
+
const pending = transport.reconnectToStream({ chatId: "thread" });
|
|
467
|
+
await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
|
|
468
|
+
storage.setStreamId("stream-new");
|
|
469
|
+
respond(
|
|
470
|
+
new Response('data: {"type":"finish"}\n\n', {
|
|
471
|
+
headers: {
|
|
472
|
+
"content-type": "text/event-stream",
|
|
473
|
+
...(responseId && { [RESUMABLE_STREAM_ID_HEADER]: responseId }),
|
|
474
|
+
},
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
477
|
+
const stream = await pending;
|
|
478
|
+
expect(storage.getStreamId()).toBe("stream-new");
|
|
479
|
+
const reader = stream!.getReader();
|
|
480
|
+
while (!(await reader.read()).done) {}
|
|
481
|
+
expect(storage.getStreamId()).toBe("stream-new");
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
it.each([undefined, "stream-old", "response-id"])(
|
|
486
|
+
"preserves a checkpoint replaced while consuming a reconnect (%s)",
|
|
487
|
+
async (responseId) => {
|
|
488
|
+
const storage = createMemoryStorage("stream-old");
|
|
489
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
490
|
+
const transport = new AssistantChatTransport({
|
|
491
|
+
fetch: vi.fn(
|
|
492
|
+
async () =>
|
|
493
|
+
new Response(
|
|
494
|
+
new ReadableStream({
|
|
495
|
+
start(value) {
|
|
496
|
+
controller = value;
|
|
497
|
+
},
|
|
498
|
+
}),
|
|
499
|
+
{
|
|
500
|
+
headers: {
|
|
501
|
+
"content-type": "text/event-stream",
|
|
502
|
+
...(responseId && {
|
|
503
|
+
[RESUMABLE_STREAM_ID_HEADER]: responseId,
|
|
504
|
+
}),
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
),
|
|
508
|
+
),
|
|
509
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
510
|
+
});
|
|
511
|
+
const stream = await transport.reconnectToStream({ chatId: "thread" });
|
|
512
|
+
expect(storage.getStreamId()).toBe(responseId ?? "stream-old");
|
|
513
|
+
storage.setStreamId("stream-new");
|
|
514
|
+
controller.enqueue(
|
|
515
|
+
new TextEncoder().encode('data: {"type":"finish"}\n\n'),
|
|
516
|
+
);
|
|
517
|
+
controller.close();
|
|
518
|
+
const reader = stream!.getReader();
|
|
519
|
+
while (!(await reader.read()).done) {}
|
|
520
|
+
expect(storage.getStreamId()).toBe("stream-new");
|
|
521
|
+
},
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
it.each([undefined, "stream-old", "response-id"])(
|
|
525
|
+
"clears the checkpoint owned by a completed reconnect (%s)",
|
|
526
|
+
async (responseId) => {
|
|
527
|
+
const storage = createMemoryStorage("stream-old");
|
|
528
|
+
const transport = new AssistantChatTransport({
|
|
529
|
+
fetch: vi.fn(
|
|
530
|
+
async () =>
|
|
531
|
+
new Response('data: {"type":"finish"}\n\n', {
|
|
532
|
+
headers: {
|
|
533
|
+
"content-type": "text/event-stream",
|
|
534
|
+
...(responseId && { [RESUMABLE_STREAM_ID_HEADER]: responseId }),
|
|
535
|
+
},
|
|
536
|
+
}),
|
|
537
|
+
),
|
|
538
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
539
|
+
});
|
|
540
|
+
const stream = await transport.reconnectToStream({ chatId: "thread" });
|
|
541
|
+
const reader = stream!.getReader();
|
|
542
|
+
while (!(await reader.read()).done) {}
|
|
543
|
+
expect(storage.getStreamId()).toBeNull();
|
|
544
|
+
},
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
it("passes a 204 with a non-null empty body through untouched (WebKit)", async () => {
|
|
548
|
+
const response = nullBodyStatusWithBody(204);
|
|
549
|
+
const fetchMock = vi.fn(async () => response);
|
|
550
|
+
const transport = new AssistantChatTransport({
|
|
551
|
+
resumable: {
|
|
552
|
+
storage: createMemoryStorage(),
|
|
553
|
+
resumeApi: "/api/resume",
|
|
554
|
+
},
|
|
555
|
+
fetch: fetchMock as never,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const res = await wrappedFetchOf(transport)("https://example.com", {});
|
|
559
|
+
|
|
560
|
+
expect(res).toBe(response);
|
|
561
|
+
expect(res.status).toBe(204);
|
|
562
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("passes a 204 with a null body through untouched", async () => {
|
|
566
|
+
const response = new Response(null, { status: 204 });
|
|
567
|
+
const fetchMock = vi.fn(async () => response);
|
|
568
|
+
const transport = new AssistantChatTransport({
|
|
569
|
+
resumable: {
|
|
570
|
+
storage: createMemoryStorage(),
|
|
571
|
+
resumeApi: "/api/resume",
|
|
572
|
+
},
|
|
573
|
+
fetch: fetchMock as never,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const res = await wrappedFetchOf(transport)("https://example.com", {});
|
|
577
|
+
|
|
578
|
+
expect(res).toBe(response);
|
|
579
|
+
expect(res.status).toBe(204);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("passes other null-body statuses with a body through untouched", async () => {
|
|
583
|
+
const response = nullBodyStatusWithBody(304);
|
|
584
|
+
const fetchMock = vi.fn(async () => response);
|
|
585
|
+
const transport = new AssistantChatTransport({
|
|
586
|
+
resumable: {
|
|
587
|
+
storage: createMemoryStorage(),
|
|
588
|
+
resumeApi: "/api/resume",
|
|
589
|
+
},
|
|
590
|
+
fetch: fetchMock as never,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const res = await wrappedFetchOf(transport)("https://example.com", {});
|
|
594
|
+
|
|
595
|
+
expect(res).toBe(response);
|
|
596
|
+
expect(res.status).toBe(304);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("still reconstructs and taps a normal streaming response, clearing storage on finish", async () => {
|
|
600
|
+
const storage = createMemoryStorage("stream-id-123");
|
|
601
|
+
const body = new ReadableStream({
|
|
602
|
+
start(controller) {
|
|
603
|
+
controller.enqueue(
|
|
604
|
+
new TextEncoder().encode('data: {"type":"finish"}\n\n'),
|
|
605
|
+
);
|
|
606
|
+
controller.close();
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
const response = new Response(body, {
|
|
610
|
+
status: 200,
|
|
611
|
+
headers: { "content-type": "text/event-stream" },
|
|
612
|
+
});
|
|
613
|
+
const fetchMock = vi.fn(async () => response);
|
|
614
|
+
const transport = new AssistantChatTransport({
|
|
615
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
616
|
+
fetch: fetchMock as never,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
const res = await wrappedFetchOf(transport)("https://example.com", {});
|
|
620
|
+
|
|
621
|
+
expect(res.status).toBe(200);
|
|
622
|
+
expect(res).not.toBe(response);
|
|
623
|
+
expect(res.body).not.toBeNull();
|
|
624
|
+
const reader = res.body!.getReader();
|
|
625
|
+
while (true) {
|
|
626
|
+
const { done } = await reader.read();
|
|
627
|
+
if (done) break;
|
|
628
|
+
}
|
|
629
|
+
expect(storage.getStreamId()).toBeNull();
|
|
630
|
+
});
|
|
631
|
+
});
|