@assistant-ui/ai-sdk 0.0.6 → 0.0.8
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/README.md +1 -1
- package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -2
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts +2 -5
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
- package/dist/adapters/vercelAttachmentAdapter.js +3 -3
- package/dist/adapters/vercelAttachmentAdapter.js.map +1 -1
- package/dist/aiSDKExtras.d.ts +6 -9
- package/dist/aiSDKExtras.d.ts.map +1 -1
- package/dist/converters/convertMessage.d.ts +34 -25
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +78 -8
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/modelContentEnvelope.d.ts +10 -12
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
- package/dist/converters/toCreateMessage.d.ts +3 -6
- package/dist/converters/toCreateMessage.d.ts.map +1 -1
- package/dist/converters/toolOutputConversion.d.ts +22 -25
- package/dist/converters/toolOutputConversion.d.ts.map +1 -1
- package/dist/hooks.d.ts +4 -7
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +17 -14
- package/dist/index.d.ts.map +1 -0
- package/dist/index.native.d.ts +15 -12
- package/dist/index.native.d.ts.map +1 -0
- package/dist/model-context/injectInteractableContext.d.ts +3 -6
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
- package/dist/model-context/injectQuoteContext.d.ts +2 -5
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.d.ts +4 -7
- package/dist/runtime/AISDKChat.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKThreads.d.ts +29 -32
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +26 -33
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/sdkIdentity.d.ts +2 -5
- package/dist/runtime/sdkIdentity.d.ts.map +1 -1
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +72 -61
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +170 -56
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts +8 -11
- package/dist/runtime/useChatRuntime.d.ts.map +1 -1
- package/dist/runtime/useChatRuntime.js +14 -10
- package/dist/runtime/useChatRuntime.js.map +1 -1
- package/dist/runtime/useChatThread.d.ts +100 -81
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +33 -13
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts +8 -9
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +236 -58
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.d.ts +1 -4
- package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
- package/dist/runtime/useResourceCleanup.js +38 -14
- package/dist/runtime/useResourceCleanup.js.map +1 -1
- package/dist/runtime/useStreamingTiming.d.ts +5 -8
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
- package/dist/runtime/useStreamingTiming.js +1 -1
- package/dist/tools/frontendTools.d.ts +24 -27
- package/dist/tools/frontendTools.d.ts.map +1 -1
- package/dist/tools/generativeTools.d.ts +29 -32
- package/dist/tools/generativeTools.d.ts.map +1 -1
- package/dist/tools/mcp-stdio.node.d.ts +2 -2
- package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts +2 -5
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
- package/dist/transport/AssistantChatTransport.d.ts +17 -20
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
- package/dist/transport/resumable.d.ts +29 -32
- package/dist/transport/resumable.d.ts.map +1 -1
- package/dist/usage.d.ts +12 -15
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js.map +1 -1
- package/dist/utils/getVercelAIMessages.d.ts +3 -6
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
- package/dist/utils/sliceMessagesUntil.d.ts +2 -5
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
- package/package.json +16 -14
- package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
- package/src/adapters/vercelAttachmentAdapter.ts +2 -2
- package/src/converters/convertMessage.test.ts +616 -3
- package/src/converters/convertMessage.ts +187 -12
- package/src/index.native.ts +1 -1
- package/src/index.ts +1 -1
- package/src/runtime/AISDKChat.test.ts +4 -5
- package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
- package/src/runtime/AISDKThreads.test.ts +88 -51
- package/src/runtime/AISDKThreads.ts +4 -13
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +338 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +476 -4
- package/src/runtime/useAISDKRuntime.test.ts +282 -8
- package/src/runtime/useAISDKRuntime.ts +274 -46
- package/src/runtime/useAISDKRuntime.voice.test.tsx +319 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +56 -0
- package/src/runtime/useChatRuntime.test.ts +77 -5
- package/src/runtime/useChatRuntime.ts +6 -2
- package/src/runtime/useChatThread.transport.test.tsx +149 -0
- package/src/runtime/useChatThread.ts +77 -10
- package/src/runtime/useExternalHistory.test.ts +989 -3
- package/src/runtime/useExternalHistory.ts +390 -24
- package/src/runtime/useResourceCleanup.ts +41 -15
- package/src/tools/generativeTools.test.ts +7 -1
- package/src/usage.ts +1 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import {
|
|
5
|
+
getExternalStoreMessages,
|
|
6
|
+
type RealtimeVoiceAdapter,
|
|
7
|
+
type ThreadHistoryAdapter,
|
|
8
|
+
type ThreadMessage,
|
|
9
|
+
} from "@assistant-ui/core";
|
|
10
|
+
import type { UIMessage } from "ai";
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { useAISDKRuntime } from "./useAISDKRuntime";
|
|
14
|
+
|
|
15
|
+
const createChatHelpers = (initialMessages: UIMessage[] = []) => {
|
|
16
|
+
let currentMessages: UIMessage[] = initialMessages;
|
|
17
|
+
const chatHelpers: any = {
|
|
18
|
+
id: "chat-1",
|
|
19
|
+
status: "ready",
|
|
20
|
+
error: null,
|
|
21
|
+
messages: currentMessages,
|
|
22
|
+
setMessages: vi.fn(
|
|
23
|
+
(next: UIMessage[] | ((current: UIMessage[]) => UIMessage[])) => {
|
|
24
|
+
currentMessages =
|
|
25
|
+
typeof next === "function" ? next(currentMessages) : [...next];
|
|
26
|
+
chatHelpers.messages = currentMessages;
|
|
27
|
+
return currentMessages;
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
regenerate: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
addToolResult: vi.fn(),
|
|
33
|
+
addToolOutput: vi.fn(),
|
|
34
|
+
stop: vi.fn(),
|
|
35
|
+
};
|
|
36
|
+
return chatHelpers;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const createVoiceAdapter = ({
|
|
40
|
+
sendText,
|
|
41
|
+
}: { sendText?: RealtimeVoiceAdapter.Session["sendText"] } = {}) => {
|
|
42
|
+
let transcriptCallback:
|
|
43
|
+
| ((transcript: RealtimeVoiceAdapter.TranscriptItem) => void)
|
|
44
|
+
| undefined;
|
|
45
|
+
const session: RealtimeVoiceAdapter.Session = {
|
|
46
|
+
status: { type: "running" },
|
|
47
|
+
isMuted: false,
|
|
48
|
+
disconnect: vi.fn(),
|
|
49
|
+
mute: vi.fn(),
|
|
50
|
+
unmute: vi.fn(),
|
|
51
|
+
...(sendText && { sendText }),
|
|
52
|
+
onStatusChange: () => () => {},
|
|
53
|
+
onTranscript: (callback) => {
|
|
54
|
+
transcriptCallback = callback;
|
|
55
|
+
return () => {
|
|
56
|
+
transcriptCallback = undefined;
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
onModeChange: () => () => {},
|
|
60
|
+
onVolumeChange: () => () => {},
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
adapter: { connect: () => session } satisfies RealtimeVoiceAdapter,
|
|
64
|
+
emitTranscript: (transcript: RealtimeVoiceAdapter.TranscriptItem) =>
|
|
65
|
+
transcriptCallback?.(transcript),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const createHistoryAdapter = () => {
|
|
70
|
+
const append = vi.fn(async () => {});
|
|
71
|
+
const adapter: ThreadHistoryAdapter = {
|
|
72
|
+
load: vi.fn(),
|
|
73
|
+
append: vi.fn(),
|
|
74
|
+
withFormat: vi.fn().mockReturnValue({
|
|
75
|
+
load: vi.fn().mockResolvedValue({ headId: null, messages: [] }),
|
|
76
|
+
append,
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
return { adapter, append };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const textOf = (message: ThreadMessage) =>
|
|
83
|
+
message.content
|
|
84
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
85
|
+
.join("");
|
|
86
|
+
|
|
87
|
+
const renderVoiceRuntime = async (
|
|
88
|
+
chat: ReturnType<typeof createChatHelpers>,
|
|
89
|
+
adapters: { voice: RealtimeVoiceAdapter; history?: ThreadHistoryAdapter },
|
|
90
|
+
) => {
|
|
91
|
+
const rendered = renderHook(() => useAISDKRuntime(chat, { adapters }));
|
|
92
|
+
await waitFor(() => {
|
|
93
|
+
expect(rendered.result.current.thread.getState().capabilities.voice).toBe(
|
|
94
|
+
true,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
return rendered;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
describe("useAISDKRuntime voice transcripts", () => {
|
|
101
|
+
it("persists finalized transcripts through the useChat messages", async () => {
|
|
102
|
+
const chat = createChatHelpers();
|
|
103
|
+
const voice = createVoiceAdapter();
|
|
104
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
105
|
+
voice: voice.adapter,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
act(() => {
|
|
109
|
+
result.current.thread.connectVoice();
|
|
110
|
+
voice.emitTranscript({
|
|
111
|
+
role: "assistant",
|
|
112
|
+
text: "Spoken reply",
|
|
113
|
+
isFinal: true,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const transcript = chat.messages[0];
|
|
118
|
+
expect(transcript).toEqual({
|
|
119
|
+
id: expect.any(String),
|
|
120
|
+
role: "assistant",
|
|
121
|
+
parts: [{ type: "text", text: "Spoken reply" }],
|
|
122
|
+
metadata: { modality: "voice" },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
rerender();
|
|
126
|
+
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
const messages = result.current.thread
|
|
129
|
+
.getState()
|
|
130
|
+
.messages.filter((message) => message.id === transcript.id);
|
|
131
|
+
expect(messages).toHaveLength(1);
|
|
132
|
+
expect(messages[0]?.metadata.modality).toBe("voice");
|
|
133
|
+
expect(getExternalStoreMessages(messages[0]!)).toEqual([transcript]);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("keeps a transcript spoken after a typed reply as its own message", async () => {
|
|
138
|
+
const chat = createChatHelpers([
|
|
139
|
+
{
|
|
140
|
+
id: "typed-user",
|
|
141
|
+
role: "user",
|
|
142
|
+
parts: [{ type: "text", text: "Hi" }],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "typed-assistant",
|
|
146
|
+
role: "assistant",
|
|
147
|
+
parts: [{ type: "text", text: "Typed reply" }],
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
150
|
+
const voice = createVoiceAdapter();
|
|
151
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
152
|
+
voice: voice.adapter,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
act(() => {
|
|
156
|
+
result.current.thread.connectVoice();
|
|
157
|
+
voice.emitTranscript({
|
|
158
|
+
role: "assistant",
|
|
159
|
+
text: "Spoken reply",
|
|
160
|
+
isFinal: true,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
const transcript = chat.messages[2];
|
|
164
|
+
rerender();
|
|
165
|
+
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
const spoken = result.current.thread.getState().messages.at(-1)!;
|
|
168
|
+
expect(getExternalStoreMessages(spoken)).toEqual([transcript]);
|
|
169
|
+
});
|
|
170
|
+
const messages = result.current.thread.getState().messages;
|
|
171
|
+
expect(messages.map((message) => message.id)).toEqual([
|
|
172
|
+
"typed-user",
|
|
173
|
+
"typed-assistant",
|
|
174
|
+
transcript.id,
|
|
175
|
+
]);
|
|
176
|
+
expect(textOf(messages[1]!)).toBe("Typed reply");
|
|
177
|
+
expect(messages[1]?.metadata.modality).toBeUndefined();
|
|
178
|
+
expect(textOf(messages[2]!)).toBe("Spoken reply");
|
|
179
|
+
expect(messages[2]?.metadata.modality).toBe("voice");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("keeps consecutive assistant transcripts as separate messages", async () => {
|
|
183
|
+
const chat = createChatHelpers();
|
|
184
|
+
const voice = createVoiceAdapter();
|
|
185
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
186
|
+
voice: voice.adapter,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
act(() => {
|
|
190
|
+
result.current.thread.connectVoice();
|
|
191
|
+
voice.emitTranscript({ role: "assistant", text: "First", isFinal: true });
|
|
192
|
+
voice.emitTranscript({
|
|
193
|
+
role: "assistant",
|
|
194
|
+
text: "Second",
|
|
195
|
+
isFinal: true,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
const [first, second] = chat.messages;
|
|
199
|
+
rerender();
|
|
200
|
+
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
const messages = result.current.thread.getState().messages;
|
|
203
|
+
expect(
|
|
204
|
+
messages.map((message) => getExternalStoreMessages(message)),
|
|
205
|
+
).toEqual([[first], [second]]);
|
|
206
|
+
});
|
|
207
|
+
expect(result.current.thread.getState().messages.map(textOf)).toEqual([
|
|
208
|
+
"First",
|
|
209
|
+
"Second",
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps the transcript in the thread after the session disconnects", async () => {
|
|
214
|
+
const chat = createChatHelpers();
|
|
215
|
+
const voice = createVoiceAdapter();
|
|
216
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
217
|
+
voice: voice.adapter,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
act(() => {
|
|
221
|
+
result.current.thread.connectVoice();
|
|
222
|
+
voice.emitTranscript({
|
|
223
|
+
role: "assistant",
|
|
224
|
+
text: "Spoken reply",
|
|
225
|
+
isFinal: true,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
const transcript = chat.messages[0];
|
|
229
|
+
rerender();
|
|
230
|
+
await waitFor(() => {
|
|
231
|
+
const messages = result.current.thread.getState().messages;
|
|
232
|
+
expect(getExternalStoreMessages(messages[0]!)).toEqual([transcript]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
act(() => {
|
|
236
|
+
result.current.thread.disconnectVoice();
|
|
237
|
+
});
|
|
238
|
+
rerender();
|
|
239
|
+
|
|
240
|
+
const messages = result.current.thread.getState().messages;
|
|
241
|
+
expect(messages).toHaveLength(1);
|
|
242
|
+
expect(messages[0]?.id).toBe(transcript.id);
|
|
243
|
+
expect(messages[0]?.metadata.modality).toBe("voice");
|
|
244
|
+
expect(result.current.thread.getState().voice).toBeUndefined();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("appends a finalized transcript to the history adapter without a text run", async () => {
|
|
248
|
+
const chat = createChatHelpers();
|
|
249
|
+
const voice = createVoiceAdapter();
|
|
250
|
+
const history = createHistoryAdapter();
|
|
251
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
252
|
+
voice: voice.adapter,
|
|
253
|
+
history: history.adapter,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
act(() => {
|
|
257
|
+
result.current.thread.connectVoice();
|
|
258
|
+
voice.emitTranscript({
|
|
259
|
+
role: "assistant",
|
|
260
|
+
text: "Spoken reply",
|
|
261
|
+
isFinal: true,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
const transcript = chat.messages[0];
|
|
265
|
+
rerender();
|
|
266
|
+
|
|
267
|
+
await waitFor(() => expect(history.append).toHaveBeenCalledTimes(1));
|
|
268
|
+
expect(history.append).toHaveBeenCalledWith({
|
|
269
|
+
parentId: null,
|
|
270
|
+
message: transcript,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("persists a message typed into the session as a typed turn", async () => {
|
|
275
|
+
const chat = createChatHelpers();
|
|
276
|
+
const sendText = vi.fn(async (_text: string) => {});
|
|
277
|
+
const voice = createVoiceAdapter({ sendText });
|
|
278
|
+
const history = createHistoryAdapter();
|
|
279
|
+
const { result, rerender } = await renderVoiceRuntime(chat, {
|
|
280
|
+
voice: voice.adapter,
|
|
281
|
+
history: history.adapter,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
act(() => {
|
|
285
|
+
result.current.thread.connectVoice();
|
|
286
|
+
});
|
|
287
|
+
await act(async () => {
|
|
288
|
+
await result.current.thread.append({
|
|
289
|
+
role: "user",
|
|
290
|
+
content: [{ type: "text", text: "Typed" }],
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(sendText).toHaveBeenCalledExactlyOnceWith("Typed");
|
|
295
|
+
expect(chat.sendMessage).not.toHaveBeenCalled();
|
|
296
|
+
const typed = chat.messages[0];
|
|
297
|
+
expect(typed).toEqual({
|
|
298
|
+
id: expect.any(String),
|
|
299
|
+
role: "user",
|
|
300
|
+
parts: [{ type: "text", text: "Typed" }],
|
|
301
|
+
metadata: {},
|
|
302
|
+
});
|
|
303
|
+
rerender();
|
|
304
|
+
|
|
305
|
+
await waitFor(() => {
|
|
306
|
+
const messages = result.current.thread
|
|
307
|
+
.getState()
|
|
308
|
+
.messages.filter((message) => message.id === typed.id);
|
|
309
|
+
expect(messages).toHaveLength(1);
|
|
310
|
+
expect(messages[0]?.metadata.modality).toBeUndefined();
|
|
311
|
+
expect(textOf(messages[0]!)).toBe("Typed");
|
|
312
|
+
});
|
|
313
|
+
await waitFor(() => expect(history.append).toHaveBeenCalledTimes(1));
|
|
314
|
+
expect(history.append).toHaveBeenCalledWith({
|
|
315
|
+
parentId: null,
|
|
316
|
+
message: typed,
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
+
import { getEventListeners } from "node:events";
|
|
3
4
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
4
5
|
import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
|
|
5
6
|
import { AuiConfig, AuiProvider, useAuiState } from "@assistant-ui/store";
|
|
7
|
+
import { useAssistantClientDestroySignal } from "@assistant-ui/store/internal";
|
|
6
8
|
import type { AssistantRuntime } from "@assistant-ui/core";
|
|
7
9
|
import { AISDKChat } from "./AISDKChat";
|
|
8
10
|
import type { ChatTransport, UIMessage } from "ai";
|
|
@@ -183,6 +185,60 @@ describe("useChatRuntime integration", () => {
|
|
|
183
185
|
view.unmount();
|
|
184
186
|
await waitFor(() => expect(getCancelCount()).toBe(1));
|
|
185
187
|
});
|
|
188
|
+
|
|
189
|
+
it("aborts a nested runtime when only its own component unmounts", async () => {
|
|
190
|
+
const outer = createCancellableTransport();
|
|
191
|
+
const { transport, getCancelCount } = createCancellableTransport();
|
|
192
|
+
let nested: AssistantRuntime | undefined;
|
|
193
|
+
let providerSignal: AbortSignal | undefined;
|
|
194
|
+
let setVisible: ((visible: boolean) => void) | undefined;
|
|
195
|
+
|
|
196
|
+
const NestedChat = () => {
|
|
197
|
+
providerSignal = useAssistantClientDestroySignal();
|
|
198
|
+
nested = useChatRuntime({ transport });
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
const Shell = () => {
|
|
202
|
+
const [visible, set] = useState(true);
|
|
203
|
+
setVisible = set;
|
|
204
|
+
return (
|
|
205
|
+
<AuiProvider
|
|
206
|
+
config={AuiConfig({
|
|
207
|
+
threads: AISDKChat({ transport: outer.transport }),
|
|
208
|
+
})}
|
|
209
|
+
>
|
|
210
|
+
{visible && <NestedChat />}
|
|
211
|
+
</AuiProvider>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
const listeners = () => getEventListeners(providerSignal!, "abort").length;
|
|
215
|
+
|
|
216
|
+
const view = render(<Shell />);
|
|
217
|
+
await waitFor(() => expect(nested).toBeDefined());
|
|
218
|
+
const mounted = listeners();
|
|
219
|
+
|
|
220
|
+
for (const cycle of [1, 2]) {
|
|
221
|
+
await act(async () => {
|
|
222
|
+
await nested!.thread.append(`stream ${cycle}`);
|
|
223
|
+
});
|
|
224
|
+
await waitFor(() =>
|
|
225
|
+
expect(nested!.thread.getState().isRunning).toBe(true),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await act(async () => setVisible?.(false));
|
|
229
|
+
await waitFor(() => expect(getCancelCount()).toBe(cycle));
|
|
230
|
+
expect(listeners()).toBe(mounted - 1);
|
|
231
|
+
|
|
232
|
+
nested = undefined;
|
|
233
|
+
await act(async () => setVisible?.(true));
|
|
234
|
+
await waitFor(() => expect(nested).toBeDefined());
|
|
235
|
+
expect(listeners()).toBe(mounted);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
view.unmount();
|
|
239
|
+
await act(nextTask);
|
|
240
|
+
expect(getCancelCount()).toBe(2);
|
|
241
|
+
});
|
|
186
242
|
});
|
|
187
243
|
|
|
188
244
|
const StreamingApp = ({
|
|
@@ -52,7 +52,16 @@ const mocks = vi.hoisted(() => {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
vi.mock("@ai-sdk/react", () => ({
|
|
55
|
-
useChat:
|
|
55
|
+
useChat: (...args: unknown[]) => {
|
|
56
|
+
const chat = mocks.useChat(...args);
|
|
57
|
+
if (chat) chat.stop ??= vi.fn(async () => {});
|
|
58
|
+
return chat;
|
|
59
|
+
},
|
|
60
|
+
Chat: class MockChat {
|
|
61
|
+
constructor(config: unknown) {
|
|
62
|
+
Object.assign(this, config);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
56
65
|
}));
|
|
57
66
|
|
|
58
67
|
vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
|
|
@@ -95,6 +104,51 @@ describe("useChatRuntime", () => {
|
|
|
95
104
|
window.sessionStorage.clear();
|
|
96
105
|
});
|
|
97
106
|
|
|
107
|
+
it("forwards a callback through a ref, so a later render's callback fires instead of the mounted one", () => {
|
|
108
|
+
mocks.useChat.mockReturnValue({
|
|
109
|
+
resumeStream: vi.fn(),
|
|
110
|
+
status: "ready",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const onToolCallA = vi.fn();
|
|
114
|
+
const onToolCallB = vi.fn();
|
|
115
|
+
|
|
116
|
+
const { rerender } = renderHook(
|
|
117
|
+
({ onToolCall }: { onToolCall: typeof onToolCallA }) =>
|
|
118
|
+
useChatRuntime({ onToolCall }),
|
|
119
|
+
{ initialProps: { onToolCall: onToolCallA } },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const chat = mocks.useChat.mock.calls[0]?.[0]?.chat as {
|
|
123
|
+
onToolCall?: (arg: unknown) => void;
|
|
124
|
+
sendAutomaticallyWhen?: (arg: unknown) => boolean;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
chat.onToolCall?.("first");
|
|
128
|
+
expect(onToolCallA).toHaveBeenCalledExactlyOnceWith("first");
|
|
129
|
+
|
|
130
|
+
rerender({ onToolCall: onToolCallB });
|
|
131
|
+
chat.onToolCall?.("second");
|
|
132
|
+
|
|
133
|
+
expect(onToolCallB).toHaveBeenCalledExactlyOnceWith("second");
|
|
134
|
+
expect(onToolCallA).toHaveBeenCalledOnce();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("coerces an unset sendAutomaticallyWhen to false, matching useChat's own default", () => {
|
|
138
|
+
mocks.useChat.mockReturnValue({
|
|
139
|
+
resumeStream: vi.fn(),
|
|
140
|
+
status: "ready",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
renderHook(() => useChatRuntime());
|
|
144
|
+
|
|
145
|
+
const chat = mocks.useChat.mock.calls[0]?.[0]?.chat as {
|
|
146
|
+
sendAutomaticallyWhen?: (arg: unknown) => boolean;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
expect(chat.sendAutomaticallyWhen?.({})).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
98
152
|
it("forwards a defined chat update throttle to useChat", () => {
|
|
99
153
|
mocks.useChat.mockReturnValue({
|
|
100
154
|
resumeStream: vi.fn(),
|
|
@@ -111,6 +165,24 @@ describe("useChatRuntime", () => {
|
|
|
111
165
|
expect(mocks.useChat.mock.calls[1]?.[0]).not.toHaveProperty("throttle");
|
|
112
166
|
});
|
|
113
167
|
|
|
168
|
+
it("forwards a custom approval handler to the runtime only", () => {
|
|
169
|
+
const onRespondToToolApproval = vi.fn();
|
|
170
|
+
mocks.useChat.mockReturnValue({
|
|
171
|
+
resumeStream: vi.fn(),
|
|
172
|
+
status: "ready",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
renderHook(() => useChatRuntime({ onRespondToToolApproval }));
|
|
176
|
+
|
|
177
|
+
expect(mocks.useAISDKRuntime).toHaveBeenCalledWith(
|
|
178
|
+
expect.anything(),
|
|
179
|
+
expect.objectContaining({ onRespondToToolApproval }),
|
|
180
|
+
);
|
|
181
|
+
expect(mocks.useChat.mock.calls[0]?.[0]).not.toHaveProperty(
|
|
182
|
+
"onRespondToToolApproval",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
114
186
|
it("waits for external history to load before resuming a stream", async () => {
|
|
115
187
|
mocks.state.isLoadingHistory = true;
|
|
116
188
|
const resumeStream = vi.fn().mockResolvedValue(undefined);
|
|
@@ -283,8 +355,8 @@ describe("useChatRuntime", () => {
|
|
|
283
355
|
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
284
356
|
status: "streaming",
|
|
285
357
|
};
|
|
286
|
-
mocks.useChat.mockImplementation(({
|
|
287
|
-
id === "thread-a" ? threadA : threadB,
|
|
358
|
+
mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
|
|
359
|
+
chat.id === "thread-a" ? threadA : threadB,
|
|
288
360
|
);
|
|
289
361
|
|
|
290
362
|
mocks.state.threadId = "thread-a";
|
|
@@ -321,8 +393,8 @@ describe("useChatRuntime", () => {
|
|
|
321
393
|
resumeStream: vi.fn().mockResolvedValue(undefined),
|
|
322
394
|
status: "ready",
|
|
323
395
|
};
|
|
324
|
-
mocks.useChat.mockImplementation(({
|
|
325
|
-
id === "__LOCALID_background" ? backgroundThread : mainThread,
|
|
396
|
+
mocks.useChat.mockImplementation(({ chat }: { chat: { id: string } }) =>
|
|
397
|
+
chat.id === "__LOCALID_background" ? backgroundThread : mainThread,
|
|
326
398
|
);
|
|
327
399
|
const transport = {
|
|
328
400
|
getResumableAdapter: () => ({
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useRemoteThreadListRuntime,
|
|
9
9
|
} from "@assistant-ui/core/react";
|
|
10
10
|
import { useAui, useAuiState } from "@assistant-ui/store";
|
|
11
|
+
import { useHostDestroySignal } from "@assistant-ui/store/internal";
|
|
11
12
|
import { useChatThread, type ChatThreadOptions } from "./useChatThread";
|
|
12
13
|
import { AI_SDK_SDK } from "./sdkIdentity";
|
|
13
14
|
|
|
@@ -18,7 +19,8 @@ export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
|
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
21
|
-
options
|
|
22
|
+
options: ChatThreadOptions<UI_MESSAGE> | undefined,
|
|
23
|
+
hostDestroySignal: AbortSignal,
|
|
22
24
|
): AssistantRuntime => {
|
|
23
25
|
const id = useAuiState((s) => s.threadListItem.id);
|
|
24
26
|
const isMainThread = useAuiState(
|
|
@@ -31,6 +33,7 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
31
33
|
getThreadListItem: () =>
|
|
32
34
|
aui.threadListItem.source ? aui.threadListItem : undefined,
|
|
33
35
|
stopOnClientDestroy: true,
|
|
36
|
+
hostDestroySignal,
|
|
34
37
|
});
|
|
35
38
|
};
|
|
36
39
|
|
|
@@ -39,10 +42,11 @@ export const useChatRuntime = <UI_MESSAGE extends UIMessage = UIMessage>({
|
|
|
39
42
|
onThreadIdChange,
|
|
40
43
|
...options
|
|
41
44
|
}: UseChatRuntimeOptions<UI_MESSAGE> = {}): AssistantRuntime => {
|
|
45
|
+
const hostDestroySignal = useHostDestroySignal();
|
|
42
46
|
const cloudAdapter = useCloudThreadListAdapter({ cloud, sdk: AI_SDK_SDK });
|
|
43
47
|
return useRemoteThreadListRuntime({
|
|
44
48
|
runtimeHook: function RuntimeHook() {
|
|
45
|
-
return useChatThreadRuntime(options);
|
|
49
|
+
return useChatThreadRuntime(options, hostDestroySignal);
|
|
46
50
|
},
|
|
47
51
|
adapter: cloudAdapter,
|
|
48
52
|
allowNesting: true,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Chat } from "@ai-sdk/react";
|
|
7
|
+
import {
|
|
8
|
+
AssistantChatTransport,
|
|
9
|
+
type InitializableThreadListItem,
|
|
10
|
+
} from "../transport/AssistantChatTransport";
|
|
11
|
+
import { useChatThread } from "./useChatThread";
|
|
12
|
+
|
|
13
|
+
const itemFor = (remoteId: string) => ({
|
|
14
|
+
initialize: async () => ({ remoteId, externalId: undefined }),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const finishedStream = () =>
|
|
18
|
+
new Response(
|
|
19
|
+
new ReadableStream<Uint8Array>({
|
|
20
|
+
start(controller) {
|
|
21
|
+
controller.enqueue(
|
|
22
|
+
new TextEncoder().encode(
|
|
23
|
+
'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
controller.close();
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
describe("useChatThread shared transport isolation", () => {
|
|
33
|
+
it("gives each thread its own clone wired to its own thread-list item", async () => {
|
|
34
|
+
const transport = new AssistantChatTransport({ api: "/api/chat" });
|
|
35
|
+
const setRuntime = vi.spyOn(transport, "setRuntime");
|
|
36
|
+
const setGetItem = vi.spyOn(transport, "__internal_setGetThreadListItem");
|
|
37
|
+
const clones: AssistantChatTransport<never>[] = [];
|
|
38
|
+
const getters: (() => InitializableThreadListItem | undefined)[] = [];
|
|
39
|
+
const realClone = transport.__internal_clone.bind(transport);
|
|
40
|
+
vi.spyOn(transport, "__internal_clone").mockImplementation(() => {
|
|
41
|
+
const clone = realClone();
|
|
42
|
+
const index = clones.push(clone as AssistantChatTransport<never>) - 1;
|
|
43
|
+
vi.spyOn(clone, "__internal_setGetThreadListItem").mockImplementation(
|
|
44
|
+
(getter) => {
|
|
45
|
+
getters[index] = getter;
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
return clone;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
renderHook(() =>
|
|
52
|
+
useChatThread(
|
|
53
|
+
{ transport },
|
|
54
|
+
{
|
|
55
|
+
id: "thread-a",
|
|
56
|
+
isMainThread: true,
|
|
57
|
+
getThreadListItem: () => itemFor("remote-a"),
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
renderHook(() =>
|
|
62
|
+
useChatThread(
|
|
63
|
+
{ transport },
|
|
64
|
+
{
|
|
65
|
+
id: "thread-b",
|
|
66
|
+
isMainThread: false,
|
|
67
|
+
getThreadListItem: () => itemFor("remote-b"),
|
|
68
|
+
},
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(setRuntime).not.toHaveBeenCalled();
|
|
73
|
+
expect(setGetItem).not.toHaveBeenCalled();
|
|
74
|
+
expect(clones).toHaveLength(2);
|
|
75
|
+
expect(clones[0]).not.toBe(clones[1]);
|
|
76
|
+
|
|
77
|
+
const remoteIdOf = async (
|
|
78
|
+
getter?: () => InitializableThreadListItem | undefined,
|
|
79
|
+
) => (await getter?.()?.initialize())?.remoteId;
|
|
80
|
+
expect(await remoteIdOf(getters[0])).toBe("remote-a");
|
|
81
|
+
expect(await remoteIdOf(getters[1])).toBe("remote-b");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sends each thread's request with that thread's remoteId and model context", async () => {
|
|
85
|
+
const sent: { id: unknown; system: unknown }[] = [];
|
|
86
|
+
const transport = new AssistantChatTransport({
|
|
87
|
+
api: "/api/chat",
|
|
88
|
+
fetch: async (_input, init) => {
|
|
89
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
90
|
+
sent.push({ id: body["id"], system: body["system"] });
|
|
91
|
+
return finishedStream();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const mount = (id: string, remoteId: string, system: string) =>
|
|
96
|
+
renderHook(() => {
|
|
97
|
+
const runtime = useChatThread(
|
|
98
|
+
{ transport },
|
|
99
|
+
{
|
|
100
|
+
id,
|
|
101
|
+
isMainThread: id === "thread-a",
|
|
102
|
+
getThreadListItem: () => itemFor(remoteId),
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
useEffect(
|
|
106
|
+
() =>
|
|
107
|
+
runtime.registerModelContextProvider({
|
|
108
|
+
getModelContext: () => ({ system }),
|
|
109
|
+
}),
|
|
110
|
+
[runtime],
|
|
111
|
+
);
|
|
112
|
+
return runtime;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const threadA = mount("thread-a", "remote-a", "system-a");
|
|
116
|
+
const threadB = mount("thread-b", "remote-b", "system-b");
|
|
117
|
+
|
|
118
|
+
threadA.rerender();
|
|
119
|
+
|
|
120
|
+
await act(async () => {
|
|
121
|
+
threadB.result.current.thread.append("hello");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await waitFor(() => expect(sent).toHaveLength(1));
|
|
125
|
+
expect(sent[0]).toEqual({ id: "remote-b", system: "system-b" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("uses the supplied instance when the caller owns the chat", () => {
|
|
129
|
+
const transport = new AssistantChatTransport({ api: "/api/chat" });
|
|
130
|
+
const clone = vi.spyOn(transport, "__internal_clone");
|
|
131
|
+
const chat = new Chat({ id: "thread-a", transport });
|
|
132
|
+
|
|
133
|
+
renderHook(() =>
|
|
134
|
+
useChatThread(
|
|
135
|
+
{ transport },
|
|
136
|
+
{
|
|
137
|
+
id: "thread-a",
|
|
138
|
+
isMainThread: true,
|
|
139
|
+
getThreadListItem: () => itemFor("remote-a"),
|
|
140
|
+
chat,
|
|
141
|
+
},
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// A caller-owned chat is already bound to its transport, so cloning again
|
|
146
|
+
// would wire an instance the chat never sends through.
|
|
147
|
+
expect(clone).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|