@assistant-ui/ai-sdk 0.0.8 → 0.0.10
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 +1 -1
- package/dist/converters/convertMessage.d.ts +2 -0
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +15 -4
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/toCreateMessage.d.ts.map +1 -1
- package/dist/converters/toCreateMessage.js +2 -1
- package/dist/converters/toCreateMessage.js.map +1 -1
- package/dist/runtime/AISDKChat.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.js.map +1 -1
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +8 -1
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +212 -34
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.js +1 -1
- package/dist/runtime/useChatRuntime.js.map +1 -1
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +40 -10
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +2 -1
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.js.map +1 -1
- package/dist/runtime/useStreamingTiming.js.map +1 -1
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
- package/dist/transport/AssistantChatTransport.js +9 -2
- package/dist/transport/AssistantChatTransport.js.map +1 -1
- package/dist/transport/resumable.js.map +1 -1
- package/dist/usage.js.map +1 -1
- package/package.json +15 -12
- package/src/converters/convertMessage.test.ts +99 -0
- package/src/converters/convertMessage.ts +27 -2
- package/src/converters/toCreateMessage.test.ts +13 -0
- package/src/converters/toCreateMessage.ts +1 -0
- package/src/runtime/AISDKChat.ts +0 -4
- package/src/runtime/AISDKThreads.test.ts +26 -0
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1588 -19
- package/src/runtime/useAISDKRuntime.approval.test.tsx +27 -0
- package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +182 -0
- package/src/runtime/useAISDKRuntime.ts +397 -28
- package/src/runtime/useChatRuntime.fast-refresh.test.tsx +94 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +134 -112
- package/src/runtime/useChatRuntime.test.ts +7 -7
- package/src/runtime/useChatThread.test.ts +166 -2
- package/src/runtime/useChatThread.transport.test.tsx +5 -2
- package/src/runtime/useChatThread.ts +56 -17
- package/src/runtime/useExternalHistory.ts +12 -1
- package/src/transport/AssistantChatTransport.test.ts +164 -0
- package/src/transport/AssistantChatTransport.ts +22 -2
|
@@ -8,7 +8,7 @@ import { useAssistantClientDestroySignal } from "@assistant-ui/store/internal";
|
|
|
8
8
|
import type { AssistantRuntime } from "@assistant-ui/core";
|
|
9
9
|
import { AISDKChat } from "./AISDKChat";
|
|
10
10
|
import type { ChatTransport, UIMessage } from "ai";
|
|
11
|
-
import { Activity, StrictMode, useState, type ReactNode } from "react";
|
|
11
|
+
import { Activity, StrictMode, useState, version, type ReactNode } from "react";
|
|
12
12
|
import { describe, expect, it } from "vitest";
|
|
13
13
|
import { AssistantChatTransport } from "../transport/AssistantChatTransport";
|
|
14
14
|
import {
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
import { useChatRuntime } from "./useChatRuntime";
|
|
20
20
|
import { useThreadTokenUsage } from "../usage";
|
|
21
21
|
|
|
22
|
+
const onReact18 = version.startsWith("18.");
|
|
23
|
+
|
|
22
24
|
const messages: UIMessage[] = [
|
|
23
25
|
{
|
|
24
26
|
id: "initial-user-message",
|
|
@@ -88,7 +90,12 @@ describe("useChatRuntime integration", () => {
|
|
|
88
90
|
await act(async () => send());
|
|
89
91
|
await waitFor(() => expect(isRunning()).toBe(true));
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
// React 18's act holds the render that settles the delete until its callback returns, so the delete is awaited outside it.
|
|
94
|
+
let deletion: unknown;
|
|
95
|
+
await act(async () => {
|
|
96
|
+
deletion = client().threadListItem.delete();
|
|
97
|
+
});
|
|
98
|
+
await deletion;
|
|
92
99
|
|
|
93
100
|
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
94
101
|
view.unmount();
|
|
@@ -113,132 +120,147 @@ describe("useChatRuntime integration", () => {
|
|
|
113
120
|
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
114
121
|
});
|
|
115
122
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
setMode
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
// Activity is React 19 only.
|
|
124
|
+
it.skipIf(onReact18)(
|
|
125
|
+
"keeps streaming while hidden and aborts when the hidden host unmounts",
|
|
126
|
+
async () => {
|
|
127
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
128
|
+
const { Probe, send, isRunning } = createStreamHarness();
|
|
129
|
+
|
|
130
|
+
let setMode: ((mode: "visible" | "hidden") => void) | undefined;
|
|
131
|
+
const Shell = () => {
|
|
132
|
+
const [mode, set] = useState<"visible" | "hidden">("visible");
|
|
133
|
+
setMode = set;
|
|
134
|
+
return (
|
|
135
|
+
<Activity mode={mode}>
|
|
136
|
+
<StreamingApp transport={transport} probe={<Probe />} />
|
|
137
|
+
</Activity>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const view = render(
|
|
142
|
+
<StrictMode>
|
|
143
|
+
<Shell />
|
|
144
|
+
</StrictMode>,
|
|
128
145
|
);
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const view = render(
|
|
132
|
-
<StrictMode>
|
|
133
|
-
<Shell />
|
|
134
|
-
</StrictMode>,
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
await act(async () => send());
|
|
138
|
-
await waitFor(() => expect(isRunning()).toBe(true));
|
|
139
|
-
|
|
140
|
-
await act(async () => setMode?.("hidden"));
|
|
141
|
-
await act(nextTask);
|
|
142
|
-
expect(getCancelCount()).toBe(0);
|
|
143
|
-
expect(isRunning()).toBe(true);
|
|
144
|
-
|
|
145
|
-
await act(async () => setMode?.("visible"));
|
|
146
|
-
await act(nextTask);
|
|
147
|
-
expect(getCancelCount()).toBe(0);
|
|
148
|
-
expect(isRunning()).toBe(true);
|
|
149
|
-
|
|
150
|
-
await act(async () => setMode?.("hidden"));
|
|
151
|
-
view.unmount();
|
|
152
|
-
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
153
|
-
});
|
|
154
146
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const { transport, getCancelCount } = createCancellableTransport();
|
|
158
|
-
let nested: AssistantRuntime | undefined;
|
|
147
|
+
await act(async () => send());
|
|
148
|
+
await waitFor(() => expect(isRunning()).toBe(true));
|
|
159
149
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return null;
|
|
165
|
-
};
|
|
150
|
+
await act(async () => setMode?.("hidden"));
|
|
151
|
+
await act(nextTask);
|
|
152
|
+
expect(getCancelCount()).toBe(0);
|
|
153
|
+
expect(isRunning()).toBe(true);
|
|
166
154
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
threads: AISDKChat({ transport: outer.transport }),
|
|
172
|
-
})}
|
|
173
|
-
>
|
|
174
|
-
<NestedChat />
|
|
175
|
-
</AuiProvider>
|
|
176
|
-
</StrictMode>,
|
|
177
|
-
);
|
|
155
|
+
await act(async () => setMode?.("visible"));
|
|
156
|
+
await act(nextTask);
|
|
157
|
+
expect(getCancelCount()).toBe(0);
|
|
158
|
+
expect(isRunning()).toBe(true);
|
|
178
159
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
view.unmount();
|
|
186
|
-
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
187
|
-
});
|
|
160
|
+
await act(async () => setMode?.("hidden"));
|
|
161
|
+
view.unmount();
|
|
162
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
163
|
+
},
|
|
164
|
+
);
|
|
188
165
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
166
|
+
// Fails on React 18: TypeError: ReactRuntime.use is not a function. Shipped React 18 incompatibility.
|
|
167
|
+
it.skipIf(onReact18)(
|
|
168
|
+
"aborts a nested runtime's stream when the provider above it unmounts",
|
|
169
|
+
async () => {
|
|
170
|
+
const outer = createCancellableTransport();
|
|
171
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
172
|
+
let nested: AssistantRuntime | undefined;
|
|
173
|
+
|
|
174
|
+
// allowNesting: the inner useChatRuntime runs its thread hook directly, as
|
|
175
|
+
// a plain React hook under the provider rather than inside a tap resource.
|
|
176
|
+
const NestedChat = () => {
|
|
177
|
+
nested = useChatRuntime({ transport });
|
|
178
|
+
return null;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const view = render(
|
|
182
|
+
<StrictMode>
|
|
183
|
+
<AuiProvider
|
|
184
|
+
config={AuiConfig({
|
|
185
|
+
threads: AISDKChat({ transport: outer.transport }),
|
|
186
|
+
})}
|
|
187
|
+
>
|
|
188
|
+
<NestedChat />
|
|
189
|
+
</AuiProvider>
|
|
190
|
+
</StrictMode>,
|
|
212
191
|
);
|
|
213
|
-
};
|
|
214
|
-
const listeners = () => getEventListeners(providerSignal!, "abort").length;
|
|
215
192
|
|
|
216
|
-
|
|
217
|
-
await waitFor(() => expect(nested).toBeDefined());
|
|
218
|
-
const mounted = listeners();
|
|
219
|
-
|
|
220
|
-
for (const cycle of [1, 2]) {
|
|
193
|
+
await waitFor(() => expect(nested).toBeDefined());
|
|
221
194
|
await act(async () => {
|
|
222
|
-
await nested!.thread.append(
|
|
195
|
+
await nested!.thread.append("keep streaming");
|
|
223
196
|
});
|
|
224
197
|
await waitFor(() =>
|
|
225
198
|
expect(nested!.thread.getState().isRunning).toBe(true),
|
|
226
199
|
);
|
|
227
200
|
|
|
228
|
-
|
|
229
|
-
await waitFor(() => expect(getCancelCount()).toBe(
|
|
230
|
-
|
|
201
|
+
view.unmount();
|
|
202
|
+
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
203
|
+
},
|
|
204
|
+
);
|
|
231
205
|
|
|
232
|
-
|
|
233
|
-
|
|
206
|
+
// Fails on React 18: TypeError: ReactRuntime.use is not a function. Shipped React 18 incompatibility.
|
|
207
|
+
it.skipIf(onReact18)(
|
|
208
|
+
"aborts a nested runtime when only its own component unmounts",
|
|
209
|
+
async () => {
|
|
210
|
+
const outer = createCancellableTransport();
|
|
211
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
212
|
+
let nested: AssistantRuntime | undefined;
|
|
213
|
+
let providerSignal: AbortSignal | undefined;
|
|
214
|
+
let setVisible: ((visible: boolean) => void) | undefined;
|
|
215
|
+
|
|
216
|
+
const NestedChat = () => {
|
|
217
|
+
providerSignal = useAssistantClientDestroySignal();
|
|
218
|
+
nested = useChatRuntime({ transport });
|
|
219
|
+
return null;
|
|
220
|
+
};
|
|
221
|
+
const Shell = () => {
|
|
222
|
+
const [visible, set] = useState(true);
|
|
223
|
+
setVisible = set;
|
|
224
|
+
return (
|
|
225
|
+
<AuiProvider
|
|
226
|
+
config={AuiConfig({
|
|
227
|
+
threads: AISDKChat({ transport: outer.transport }),
|
|
228
|
+
})}
|
|
229
|
+
>
|
|
230
|
+
{visible && <NestedChat />}
|
|
231
|
+
</AuiProvider>
|
|
232
|
+
);
|
|
233
|
+
};
|
|
234
|
+
const listeners = () =>
|
|
235
|
+
getEventListeners(providerSignal!, "abort").length;
|
|
236
|
+
|
|
237
|
+
const view = render(<Shell />);
|
|
234
238
|
await waitFor(() => expect(nested).toBeDefined());
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
239
|
+
const mounted = listeners();
|
|
240
|
+
|
|
241
|
+
for (const cycle of [1, 2]) {
|
|
242
|
+
await act(async () => {
|
|
243
|
+
await nested!.thread.append(`stream ${cycle}`);
|
|
244
|
+
});
|
|
245
|
+
await waitFor(() =>
|
|
246
|
+
expect(nested!.thread.getState().isRunning).toBe(true),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
await act(async () => setVisible?.(false));
|
|
250
|
+
await waitFor(() => expect(getCancelCount()).toBe(cycle));
|
|
251
|
+
expect(listeners()).toBe(mounted - 1);
|
|
252
|
+
|
|
253
|
+
nested = undefined;
|
|
254
|
+
await act(async () => setVisible?.(true));
|
|
255
|
+
await waitFor(() => expect(nested).toBeDefined());
|
|
256
|
+
expect(listeners()).toBe(mounted);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
view.unmount();
|
|
260
|
+
await act(nextTask);
|
|
261
|
+
expect(getCancelCount()).toBe(2);
|
|
262
|
+
},
|
|
263
|
+
);
|
|
242
264
|
});
|
|
243
265
|
|
|
244
266
|
const StreamingApp = ({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { version } from "react";
|
|
4
5
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
6
|
|
|
6
7
|
const mocks = vi.hoisted(() => {
|
|
@@ -51,17 +52,13 @@ const mocks = vi.hoisted(() => {
|
|
|
51
52
|
};
|
|
52
53
|
});
|
|
53
54
|
|
|
54
|
-
vi.mock("@ai-sdk/react", () => ({
|
|
55
|
+
vi.mock("@ai-sdk/react", async (importOriginal) => ({
|
|
56
|
+
...(await importOriginal<typeof import("@ai-sdk/react")>()),
|
|
55
57
|
useChat: (...args: unknown[]) => {
|
|
56
58
|
const chat = mocks.useChat(...args);
|
|
57
59
|
if (chat) chat.stop ??= vi.fn(async () => {});
|
|
58
60
|
return chat;
|
|
59
61
|
},
|
|
60
|
-
Chat: class MockChat {
|
|
61
|
-
constructor(config: unknown) {
|
|
62
|
-
Object.assign(this, config);
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
62
|
}));
|
|
66
63
|
|
|
67
64
|
vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
|
|
@@ -87,6 +84,8 @@ import {
|
|
|
87
84
|
} from "../transport/resumable";
|
|
88
85
|
import { useChatRuntime } from "./useChatRuntime";
|
|
89
86
|
|
|
87
|
+
const onReact18 = version.startsWith("18.");
|
|
88
|
+
|
|
90
89
|
const sendMessagesOptions = {
|
|
91
90
|
trigger: "submit-message" as const,
|
|
92
91
|
chatId: "thread-id",
|
|
@@ -95,7 +94,8 @@ const sendMessagesOptions = {
|
|
|
95
94
|
abortSignal: undefined,
|
|
96
95
|
};
|
|
97
96
|
|
|
98
|
-
|
|
97
|
+
// Fails on React 18: TypeError: ReactRuntime.use is not a function. Shipped React 18 incompatibility.
|
|
98
|
+
describe.skipIf(onReact18)("useChatRuntime", () => {
|
|
99
99
|
beforeEach(() => {
|
|
100
100
|
mocks.state.isLoadingHistory = false;
|
|
101
101
|
mocks.state.threadId = "thread-id";
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { resource, useResource, flushTapSync } from "@assistant-ui/tap";
|
|
5
5
|
import { useState } from "react";
|
|
6
|
+
import type { ChatTransport, UIMessage } from "ai";
|
|
6
7
|
import {
|
|
7
8
|
RuntimeAdapter,
|
|
8
9
|
runtimeAdapterTransformScopes,
|
|
@@ -12,14 +13,21 @@ import {
|
|
|
12
13
|
AuiConfig,
|
|
13
14
|
createAssistantClient,
|
|
14
15
|
} from "@assistant-ui/store/client";
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createChat,
|
|
18
|
+
useChatThread,
|
|
19
|
+
type ChatThreadEnvironment,
|
|
20
|
+
} from "./useChatThread";
|
|
21
|
+
import { AssistantChatTransport } from "../transport/AssistantChatTransport";
|
|
22
|
+
import { createResumableSessionStorage } from "../transport/resumable";
|
|
16
23
|
import {
|
|
17
24
|
createCancellableTransport,
|
|
18
25
|
nextTask,
|
|
19
26
|
} from "./__tests__/controlled-transport";
|
|
20
27
|
|
|
21
28
|
const createHost = (
|
|
22
|
-
env: Pick<ChatThreadEnvironment, "stopOnClientDestroy">,
|
|
29
|
+
env: Pick<ChatThreadEnvironment, "stopOnClientDestroy" | "chat">,
|
|
30
|
+
onRuntime?: (runtime: ReturnType<typeof useChatThread>) => void,
|
|
23
31
|
) => {
|
|
24
32
|
const useHost = (options: Parameters<typeof useChatThread>[0]) => {
|
|
25
33
|
const [threadListItem] = useState(() => ({
|
|
@@ -31,6 +39,7 @@ const createHost = (
|
|
|
31
39
|
getThreadListItem: () => threadListItem,
|
|
32
40
|
...env,
|
|
33
41
|
});
|
|
42
|
+
onRuntime?.(runtime);
|
|
34
43
|
return useResource(RuntimeAdapter(runtime));
|
|
35
44
|
};
|
|
36
45
|
attachTransformScopes(useHost, runtimeAdapterTransformScopes);
|
|
@@ -64,6 +73,161 @@ const streamThenDestroy = async (
|
|
|
64
73
|
};
|
|
65
74
|
|
|
66
75
|
describe("useChatThread", () => {
|
|
76
|
+
it.each([
|
|
77
|
+
{ adapters: undefined, threadId: "main" },
|
|
78
|
+
{
|
|
79
|
+
adapters: { threadList: { threadId: "caller-id" } },
|
|
80
|
+
threadId: "caller-id",
|
|
81
|
+
},
|
|
82
|
+
])("uses $threadId as the runtime thread id", ({ adapters, threadId }) => {
|
|
83
|
+
let runtime: ReturnType<typeof useChatThread> | undefined;
|
|
84
|
+
const Host = createHost({}, (value) => {
|
|
85
|
+
runtime = value;
|
|
86
|
+
});
|
|
87
|
+
const handle = createAssistantClient(
|
|
88
|
+
AuiConfig({ threads: Host({ adapters }) }),
|
|
89
|
+
);
|
|
90
|
+
try {
|
|
91
|
+
handle.subscribe(() => {});
|
|
92
|
+
expect(runtime?.thread.getState().threadId).toBe(threadId);
|
|
93
|
+
} finally {
|
|
94
|
+
handle.destroy();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it.each(["send", "resume"])(
|
|
99
|
+
"ignores errors from a subsequent %s when a resume finishes",
|
|
100
|
+
async (nextRequest) => {
|
|
101
|
+
const storage = createResumableSessionStorage({
|
|
102
|
+
key: `resume-${nextRequest}-race`,
|
|
103
|
+
});
|
|
104
|
+
storage.setStreamId("stream-1", "main");
|
|
105
|
+
const error = new Error(`${nextRequest} offline`);
|
|
106
|
+
const onError = vi.fn();
|
|
107
|
+
const onResumeError = vi.fn();
|
|
108
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
109
|
+
const transport = {
|
|
110
|
+
getResumableAdapter: () => ({ storage }),
|
|
111
|
+
reconnectToStream: vi
|
|
112
|
+
.fn<ChatTransport<UIMessage>["reconnectToStream"]>()
|
|
113
|
+
.mockRejectedValue(error)
|
|
114
|
+
.mockImplementationOnce(
|
|
115
|
+
async () =>
|
|
116
|
+
new ReadableStream({
|
|
117
|
+
start(controller) {
|
|
118
|
+
controller.enqueue({ type: "start", messageId: "resumed" });
|
|
119
|
+
controller.close();
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
123
|
+
sendMessages: vi.fn(async () => {
|
|
124
|
+
throw error;
|
|
125
|
+
}),
|
|
126
|
+
} satisfies ChatTransport<UIMessage> & {
|
|
127
|
+
getResumableAdapter: () => { storage: typeof storage };
|
|
128
|
+
};
|
|
129
|
+
let sent = false;
|
|
130
|
+
const callbacks = {
|
|
131
|
+
onError,
|
|
132
|
+
onFinish: () => {
|
|
133
|
+
if (!sent) {
|
|
134
|
+
sent = true;
|
|
135
|
+
if (nextRequest === "resume") {
|
|
136
|
+
void chat.resumeStream();
|
|
137
|
+
} else {
|
|
138
|
+
void chat.sendMessage({
|
|
139
|
+
role: "user",
|
|
140
|
+
parts: [{ type: "text", text: "next" }],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
const chat = createChat(
|
|
147
|
+
{
|
|
148
|
+
id: "main",
|
|
149
|
+
transport,
|
|
150
|
+
},
|
|
151
|
+
{ current: callbacks },
|
|
152
|
+
);
|
|
153
|
+
const Host = createHost({ chat });
|
|
154
|
+
const handle = createAssistantClient(
|
|
155
|
+
AuiConfig({ threads: Host({ transport, onResumeError }) }),
|
|
156
|
+
);
|
|
157
|
+
handle.subscribe(() => {});
|
|
158
|
+
try {
|
|
159
|
+
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error));
|
|
160
|
+
await nextTask();
|
|
161
|
+
expect(onResumeError).not.toHaveBeenCalled();
|
|
162
|
+
expect(storage.getStreamId("main")).toBe("stream-1");
|
|
163
|
+
} finally {
|
|
164
|
+
handle.destroy();
|
|
165
|
+
storage.clear();
|
|
166
|
+
warn.mockRestore();
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
it.each([false, true])(
|
|
172
|
+
"reports SDK reconnect failures and preserves replacement checkpoints: %s",
|
|
173
|
+
async (replaceCheckpoint) => {
|
|
174
|
+
const storage = createResumableSessionStorage({
|
|
175
|
+
key: `automatic-resume-error-${replaceCheckpoint}`,
|
|
176
|
+
});
|
|
177
|
+
storage.setStreamId("failed-stream", "main");
|
|
178
|
+
const error = new Error("resume offline");
|
|
179
|
+
const onError = vi.fn();
|
|
180
|
+
const onResumeError = vi.fn(() => {
|
|
181
|
+
if (replaceCheckpoint) storage.setStreamId("replacement", "main");
|
|
182
|
+
});
|
|
183
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
184
|
+
let finishReplacement: (() => void) | undefined;
|
|
185
|
+
const fetch = vi
|
|
186
|
+
.fn<typeof globalThis.fetch>()
|
|
187
|
+
.mockRejectedValueOnce(error)
|
|
188
|
+
.mockImplementation(
|
|
189
|
+
() =>
|
|
190
|
+
new Promise<Response>((resolve) => {
|
|
191
|
+
finishReplacement = () =>
|
|
192
|
+
resolve(new Response(null, { status: 204 }));
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
const Host = createHost({});
|
|
196
|
+
const handle = createAssistantClient(
|
|
197
|
+
AuiConfig({
|
|
198
|
+
threads: Host({
|
|
199
|
+
transport: new AssistantChatTransport({
|
|
200
|
+
fetch,
|
|
201
|
+
resumable: { storage, resumeApi: (id) => `/api/resume/${id}` },
|
|
202
|
+
}),
|
|
203
|
+
onError,
|
|
204
|
+
onResumeError,
|
|
205
|
+
}),
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
handle.subscribe(() => {});
|
|
209
|
+
try {
|
|
210
|
+
await vi.waitFor(() => {
|
|
211
|
+
expect(onError).toHaveBeenCalledWith(error);
|
|
212
|
+
expect(onResumeError).toHaveBeenCalledOnce();
|
|
213
|
+
});
|
|
214
|
+
expect(onResumeError).toHaveBeenCalledWith(error);
|
|
215
|
+
expect(warn).toHaveBeenCalledWith(
|
|
216
|
+
"[assistant-ui] resumable: resume failed",
|
|
217
|
+
error,
|
|
218
|
+
);
|
|
219
|
+
expect(storage.getStreamId("main")).toBe(
|
|
220
|
+
replaceCheckpoint ? "replacement" : null,
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
handle.destroy();
|
|
224
|
+
finishReplacement?.();
|
|
225
|
+
storage.clear();
|
|
226
|
+
warn.mockRestore();
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
|
|
67
231
|
it("stops an in-flight chat on client destroy when stopOnClientDestroy is omitted", async () => {
|
|
68
232
|
expect(await streamThenDestroy({})).toBe(1);
|
|
69
233
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
-
import { useEffect } from "react";
|
|
4
|
+
import { useEffect, version } from "react";
|
|
5
5
|
import { describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { Chat } from "@ai-sdk/react";
|
|
7
7
|
import {
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from "../transport/AssistantChatTransport";
|
|
11
11
|
import { useChatThread } from "./useChatThread";
|
|
12
12
|
|
|
13
|
+
const onReact18 = version.startsWith("18.");
|
|
14
|
+
|
|
13
15
|
const itemFor = (remoteId: string) => ({
|
|
14
16
|
initialize: async () => ({ remoteId, externalId: undefined }),
|
|
15
17
|
});
|
|
@@ -29,7 +31,8 @@ const finishedStream = () =>
|
|
|
29
31
|
{ headers: { "content-type": "text/event-stream" } },
|
|
30
32
|
);
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
// Fails on React 18: TypeError: ReactRuntime.use is not a function. Shipped React 18 incompatibility.
|
|
35
|
+
describe.skipIf(onReact18)("useChatThread shared transport isolation", () => {
|
|
33
36
|
it("gives each thread its own clone wired to its own thread-list item", async () => {
|
|
34
37
|
const transport = new AssistantChatTransport({ api: "/api/chat" });
|
|
35
38
|
const setRuntime = vi.spyOn(transport, "setRuntime");
|