@assistant-ui/ai-sdk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts +8 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
- package/dist/adapters/aiSDKFormatAdapter.js +26 -0
- package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts +6 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.js +37 -0
- package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
- package/dist/aiSDKExtras.d.ts +11 -0
- package/dist/aiSDKExtras.d.ts.map +1 -0
- package/dist/aiSDKExtras.js +7 -0
- package/dist/aiSDKExtras.js.map +1 -0
- package/dist/converters/convertMessage.d.ts +28 -0
- package/dist/converters/convertMessage.d.ts.map +1 -0
- package/dist/converters/convertMessage.js +249 -0
- package/dist/converters/convertMessage.js.map +1 -0
- package/dist/converters/modelContentEnvelope.d.ts +16 -0
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -0
- package/dist/converters/modelContentEnvelope.js +22 -0
- package/dist/converters/modelContentEnvelope.js.map +1 -0
- package/dist/converters/toCreateMessage.d.ts +7 -0
- package/dist/converters/toCreateMessage.d.ts.map +1 -0
- package/dist/converters/toCreateMessage.js +58 -0
- package/dist/converters/toCreateMessage.js.map +1 -0
- package/dist/converters/toolOutputConversion.d.ts +29 -0
- package/dist/converters/toolOutputConversion.d.ts.map +1 -0
- package/dist/converters/toolOutputConversion.js +31 -0
- package/dist/converters/toolOutputConversion.js.map +1 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +20 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/index.native.d.ts +12 -0
- package/dist/index.native.js +11 -0
- package/dist/model-context/injectInteractableContext.d.ts +37 -0
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
- package/dist/model-context/injectInteractableContext.js +52 -0
- package/dist/model-context/injectInteractableContext.js.map +1 -0
- package/dist/model-context/injectQuoteContext.d.ts +27 -0
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
- package/dist/model-context/injectQuoteContext.js +51 -0
- package/dist/model-context/injectQuoteContext.js.map +1 -0
- package/dist/runtime/AISDKChat.d.ts +16 -0
- package/dist/runtime/AISDKChat.d.ts.map +1 -0
- package/dist/runtime/AISDKChat.js +69 -0
- package/dist/runtime/AISDKChat.js.map +1 -0
- package/dist/runtime/AISDKThreads.d.ts +45 -0
- package/dist/runtime/AISDKThreads.d.ts.map +1 -0
- package/dist/runtime/AISDKThreads.js +203 -0
- package/dist/runtime/AISDKThreads.js.map +1 -0
- package/dist/runtime/useAISDKRuntime.d.ts +49 -0
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
- package/dist/runtime/useAISDKRuntime.js +266 -0
- package/dist/runtime/useAISDKRuntime.js.map +1 -0
- package/dist/runtime/useChatRuntime.d.ts +13 -0
- package/dist/runtime/useChatRuntime.d.ts.map +1 -0
- package/dist/runtime/useChatRuntime.js +52 -0
- package/dist/runtime/useChatRuntime.js.map +1 -0
- package/dist/runtime/useChatThread.d.ts +77 -0
- package/dist/runtime/useChatThread.d.ts.map +1 -0
- package/dist/runtime/useChatThread.js +134 -0
- package/dist/runtime/useChatThread.js.map +1 -0
- package/dist/runtime/useExternalHistory.d.ts +11 -0
- package/dist/runtime/useExternalHistory.d.ts.map +1 -0
- package/dist/runtime/useExternalHistory.js +278 -0
- package/dist/runtime/useExternalHistory.js.map +1 -0
- package/dist/runtime/useResourceCleanup.d.ts +5 -0
- package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
- package/dist/runtime/useResourceCleanup.js +47 -0
- package/dist/runtime/useResourceCleanup.js.map +1 -0
- package/dist/runtime/useStreamingTiming.d.ts +15 -0
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
- package/dist/runtime/useStreamingTiming.js +38 -0
- package/dist/runtime/useStreamingTiming.js.map +1 -0
- package/dist/tools/frontendTools.d.ts +33 -0
- package/dist/tools/frontendTools.d.ts.map +1 -0
- package/dist/tools/frontendTools.js +33 -0
- package/dist/tools/frontendTools.js.map +1 -0
- package/dist/tools/generativeTools.d.ts +73 -0
- package/dist/tools/generativeTools.d.ts.map +1 -0
- package/dist/tools/generativeTools.js +245 -0
- package/dist/tools/generativeTools.js.map +1 -0
- package/dist/tools/mcp-stdio.node.d.ts +2 -0
- package/dist/tools/mcp-stdio.node.js +2 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts +6 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.unsupported.js +11 -0
- package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
- package/dist/transport/AssistantChatTransport.d.ts +23 -0
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
- package/dist/transport/AssistantChatTransport.js +134 -0
- package/dist/transport/AssistantChatTransport.js.map +1 -0
- package/dist/transport/resumable.d.ts +38 -0
- package/dist/transport/resumable.d.ts.map +1 -0
- package/dist/transport/resumable.js +113 -0
- package/dist/transport/resumable.js.map +1 -0
- package/dist/usage.d.ts +19 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +127 -0
- package/dist/usage.js.map +1 -0
- package/dist/utils/getVercelAIMessages.d.ts +7 -0
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
- package/dist/utils/getVercelAIMessages.js +9 -0
- package/dist/utils/getVercelAIMessages.js.map +1 -0
- package/dist/utils/sliceMessagesUntil.d.ts +6 -0
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
- package/dist/utils/sliceMessagesUntil.js +12 -0
- package/dist/utils/sliceMessagesUntil.js.map +1 -0
- package/package.json +100 -0
- package/src/__tests__/standalone.e2e.ts +59 -0
- package/src/adapters/aiSDKFormatAdapter.ts +41 -0
- package/src/adapters/vercelAttachmentAdapter.test.ts +29 -0
- package/src/adapters/vercelAttachmentAdapter.ts +36 -0
- package/src/aiSDKExtras.ts +11 -0
- package/src/converters/convertMessage.test.ts +1130 -0
- package/src/converters/convertMessage.ts +457 -0
- package/src/converters/modelContentEnvelope.ts +41 -0
- package/src/converters/toCreateMessage.test.ts +596 -0
- package/src/converters/toCreateMessage.ts +99 -0
- package/src/converters/toolOutputConversion.ts +26 -0
- package/src/hooks.ts +23 -0
- package/src/index.native.ts +21 -0
- package/src/index.ts +29 -0
- package/src/model-context/injectInteractableContext.test.ts +167 -0
- package/src/model-context/injectInteractableContext.ts +61 -0
- package/src/model-context/injectQuoteContext.test.ts +33 -0
- package/src/model-context/injectQuoteContext.ts +62 -0
- package/src/runtime/AISDKChat.integration.test.tsx +47 -0
- package/src/runtime/AISDKChat.react-compiler.test.ts +49 -0
- package/src/runtime/AISDKChat.test.ts +193 -0
- package/src/runtime/AISDKChat.ts +47 -0
- package/src/runtime/AISDKThreads.cloud.test.ts +136 -0
- package/src/runtime/AISDKThreads.test.ts +548 -0
- package/src/runtime/AISDKThreads.ts +223 -0
- package/src/runtime/__tests__/controlled-transport.ts +43 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +69 -0
- package/src/runtime/useAISDKRuntime.test.ts +1021 -0
- package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
- package/src/runtime/useAISDKRuntime.ts +524 -0
- package/src/runtime/useChatOnTap.test.ts +58 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +67 -0
- package/src/runtime/useChatRuntime.test.ts +522 -0
- package/src/runtime/useChatRuntime.ts +49 -0
- package/src/runtime/useChatThread.ts +286 -0
- package/src/runtime/useExternalHistory.test.ts +1289 -0
- package/src/runtime/useExternalHistory.ts +435 -0
- package/src/runtime/useResourceCleanup.ts +32 -0
- package/src/runtime/useStreamingTiming.ts +68 -0
- package/src/tools/frontendTools.test.ts +205 -0
- package/src/tools/frontendTools.ts +83 -0
- package/src/tools/generativeTools.test.ts +785 -0
- package/src/tools/generativeTools.ts +470 -0
- package/src/tools/mcp-stdio.node.ts +1 -0
- package/src/tools/mcp-stdio.unsupported.ts +12 -0
- package/src/transport/AssistantChatTransport.test.ts +300 -0
- package/src/transport/AssistantChatTransport.ts +204 -0
- package/src/transport/resumable.test.ts +262 -0
- package/src/transport/resumable.ts +161 -0
- package/src/usage.test.ts +186 -0
- package/src/usage.ts +174 -0
- package/src/utils/getVercelAIMessages.ts +11 -0
- package/src/utils/sliceMessagesUntil.test.ts +55 -0
- package/src/utils/sliceMessagesUntil.ts +20 -0
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { validateUIMessages } from "ai";
|
|
6
|
+
|
|
7
|
+
// Mock only the sibling module that requires AUI store context (not available
|
|
8
|
+
// in isolation). Every other dependency — useExternalStoreRuntime,
|
|
9
|
+
// useToolInvocations, the message converter — runs for real.
|
|
10
|
+
vi.mock("./useExternalHistory", () => ({
|
|
11
|
+
useExternalHistory: vi.fn(() => ({
|
|
12
|
+
isLoading: false,
|
|
13
|
+
deleteMessage: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
})),
|
|
15
|
+
toExportedMessageRepository: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { useExternalHistory } from "./useExternalHistory";
|
|
19
|
+
import { useAISDKRuntime } from "./useAISDKRuntime";
|
|
20
|
+
import { aiSDKExtras } from "../aiSDKExtras";
|
|
21
|
+
|
|
22
|
+
const createChatHelpers = (messages: any[] = []) => {
|
|
23
|
+
let currentMessages = [...messages];
|
|
24
|
+
|
|
25
|
+
const chatHelpers: any = {
|
|
26
|
+
status: "ready",
|
|
27
|
+
error: null,
|
|
28
|
+
messages: currentMessages,
|
|
29
|
+
setMessages: vi.fn((next: any) => {
|
|
30
|
+
currentMessages =
|
|
31
|
+
typeof next === "function" ? next(currentMessages) : [...next];
|
|
32
|
+
chatHelpers.messages = currentMessages;
|
|
33
|
+
return currentMessages;
|
|
34
|
+
}),
|
|
35
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
regenerate: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
addToolResult: vi.fn(),
|
|
38
|
+
addToolOutput: vi.fn(),
|
|
39
|
+
stop: vi.fn(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return chatHelpers;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const captureUnhandledRejections = async (
|
|
46
|
+
callback: () => Promise<void> | void,
|
|
47
|
+
) => {
|
|
48
|
+
const reasons: unknown[] = [];
|
|
49
|
+
const listener = (reason: unknown) => {
|
|
50
|
+
reasons.push(reason);
|
|
51
|
+
};
|
|
52
|
+
const priorListeners = process.listeners("unhandledRejection");
|
|
53
|
+
process.removeAllListeners("unhandledRejection");
|
|
54
|
+
process.on("unhandledRejection", listener);
|
|
55
|
+
try {
|
|
56
|
+
await callback();
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
58
|
+
return reasons;
|
|
59
|
+
} finally {
|
|
60
|
+
process.removeListener("unhandledRejection", listener);
|
|
61
|
+
for (const prior of priorListeners) {
|
|
62
|
+
process.on("unhandledRejection", prior);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const textOf = (message: any): string =>
|
|
68
|
+
message.content
|
|
69
|
+
.filter((part: any) => part.type === "text")
|
|
70
|
+
.map((part: any) => part.text)
|
|
71
|
+
.join("|");
|
|
72
|
+
|
|
73
|
+
describe("useAISDKRuntime", () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
vi.clearAllMocks();
|
|
76
|
+
vi.mocked(useExternalHistory).mockReturnValue({
|
|
77
|
+
isLoading: false,
|
|
78
|
+
deleteMessage: vi.fn().mockResolvedValue(undefined),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("sends a new user message through the runtime", async () => {
|
|
83
|
+
const chat = createChatHelpers();
|
|
84
|
+
|
|
85
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
86
|
+
|
|
87
|
+
act(() => {
|
|
88
|
+
result.current.thread.append({
|
|
89
|
+
role: "user",
|
|
90
|
+
content: [{ type: "text", text: "hello" }],
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(chat.sendMessage).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(chat.sendMessage).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
role: "user",
|
|
101
|
+
parts: expect.arrayContaining([
|
|
102
|
+
expect.objectContaining({ type: "text", text: "hello" }),
|
|
103
|
+
]),
|
|
104
|
+
}),
|
|
105
|
+
expect.anything(),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("forwards runConfig as metadata when sending", async () => {
|
|
110
|
+
const chat = createChatHelpers();
|
|
111
|
+
|
|
112
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
113
|
+
|
|
114
|
+
act(() => {
|
|
115
|
+
result.current.thread.append({
|
|
116
|
+
role: "user",
|
|
117
|
+
content: [{ type: "text", text: "hello" }],
|
|
118
|
+
runConfig: { custom: { model: "gpt-5.6-luna" } },
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(chat.sendMessage).toHaveBeenCalledWith(expect.anything(), {
|
|
124
|
+
metadata: { custom: { model: "gpt-5.6-luna" } },
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("adopts a rejected stop so cancellation is not an unhandled rejection", async () => {
|
|
130
|
+
const abortError = new Error("signal is aborted without reason");
|
|
131
|
+
abortError.name = "AbortError";
|
|
132
|
+
const chat = createChatHelpers();
|
|
133
|
+
let stopCalls = 0;
|
|
134
|
+
chat.stop = () => {
|
|
135
|
+
stopCalls += 1;
|
|
136
|
+
return new Promise((_, reject) => {
|
|
137
|
+
setTimeout(() => reject(abortError), 5);
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
const consoleError = vi
|
|
141
|
+
.spyOn(console, "error")
|
|
142
|
+
.mockImplementation(() => {});
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
146
|
+
const unhandledRejections = await captureUnhandledRejections(() => {
|
|
147
|
+
result.current.thread.cancelRun();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(stopCalls).toBe(1);
|
|
151
|
+
expect(unhandledRejections).toEqual([]);
|
|
152
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
153
|
+
} finally {
|
|
154
|
+
consoleError.mockRestore();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("reports non-AbortError cancellation failures", async () => {
|
|
159
|
+
const stopError = new Error("stop failed");
|
|
160
|
+
const chat = createChatHelpers();
|
|
161
|
+
chat.stop = vi.fn().mockRejectedValue(stopError);
|
|
162
|
+
const consoleError = vi
|
|
163
|
+
.spyOn(console, "error")
|
|
164
|
+
.mockImplementation(() => {});
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
168
|
+
|
|
169
|
+
act(() => {
|
|
170
|
+
result.current.thread.cancelRun();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
175
|
+
"[ExternalStoreThreadRuntimeCore] onCancel callback rejected",
|
|
176
|
+
stopError,
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
} finally {
|
|
180
|
+
consoleError.mockRestore();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("cancels pending tool calls before sending a new message", async () => {
|
|
185
|
+
const chat = createChatHelpers([
|
|
186
|
+
{
|
|
187
|
+
id: "a1",
|
|
188
|
+
role: "assistant",
|
|
189
|
+
parts: [
|
|
190
|
+
{
|
|
191
|
+
type: "tool-weather",
|
|
192
|
+
toolCallId: "tc-1",
|
|
193
|
+
state: "input-available",
|
|
194
|
+
input: { city: "NYC" },
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
type: "tool-weather",
|
|
198
|
+
toolCallId: "tc-2",
|
|
199
|
+
state: "output-available",
|
|
200
|
+
input: { city: "LA" },
|
|
201
|
+
output: { temp: 70 },
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
208
|
+
|
|
209
|
+
// Wait for the runtime to process the initial messages
|
|
210
|
+
await waitFor(() => {
|
|
211
|
+
expect(result.current.thread.getState().messages.length).toBeGreaterThan(
|
|
212
|
+
0,
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
act(() => {
|
|
217
|
+
result.current.thread.append({
|
|
218
|
+
role: "user",
|
|
219
|
+
content: [{ type: "text", text: "continue" }],
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(chat.sendMessage).toHaveBeenCalledTimes(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Pending tool (tc-1) should be marked as cancelled
|
|
228
|
+
expect(chat.messages[0].parts[0].state).toBe("output-error");
|
|
229
|
+
expect(chat.messages[0].parts[0].errorText).toBe(
|
|
230
|
+
"User cancelled tool call by sending a new message.",
|
|
231
|
+
);
|
|
232
|
+
// Completed tool (tc-2) should remain unchanged
|
|
233
|
+
expect(chat.messages[0].parts[1].state).toBe("output-available");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("strips stale approval when cancelling a tool pending approval so history stays valid", async () => {
|
|
237
|
+
const chat = createChatHelpers([
|
|
238
|
+
{
|
|
239
|
+
id: "a1",
|
|
240
|
+
role: "assistant",
|
|
241
|
+
parts: [
|
|
242
|
+
{
|
|
243
|
+
type: "dynamic-tool",
|
|
244
|
+
toolName: "mcp_search",
|
|
245
|
+
toolCallId: "tc-1",
|
|
246
|
+
state: "approval-requested",
|
|
247
|
+
input: { q: "hi" },
|
|
248
|
+
approval: { id: "appr-1" },
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
255
|
+
|
|
256
|
+
await waitFor(() => {
|
|
257
|
+
expect(result.current.thread.getState().messages.length).toBeGreaterThan(
|
|
258
|
+
0,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
act(() => {
|
|
263
|
+
result.current.thread.append({
|
|
264
|
+
role: "user",
|
|
265
|
+
content: [{ type: "text", text: "what" }],
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(chat.sendMessage).toHaveBeenCalledTimes(1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const part = chat.messages[0].parts[0];
|
|
274
|
+
expect(part.state).toBe("output-error");
|
|
275
|
+
// The pending-approval object must not survive into the terminal state,
|
|
276
|
+
// otherwise AI SDK's validateUIMessages rejects the next request.
|
|
277
|
+
expect(part.approval).toBeUndefined();
|
|
278
|
+
|
|
279
|
+
await expect(
|
|
280
|
+
validateUIMessages({ messages: chat.messages }),
|
|
281
|
+
).resolves.toBeDefined();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("forwards a successful tool result through addToolOutput, not the deprecated addToolResult", async () => {
|
|
285
|
+
const chat = createChatHelpers([
|
|
286
|
+
{
|
|
287
|
+
id: "a1",
|
|
288
|
+
role: "assistant",
|
|
289
|
+
parts: [
|
|
290
|
+
{
|
|
291
|
+
type: "tool-weather",
|
|
292
|
+
toolCallId: "tc-1",
|
|
293
|
+
state: "input-available",
|
|
294
|
+
input: { city: "NYC" },
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
301
|
+
|
|
302
|
+
await waitFor(() => {
|
|
303
|
+
expect(result.current.thread.getState().messages.length).toBeGreaterThan(
|
|
304
|
+
0,
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
act(() => {
|
|
309
|
+
result.current.thread
|
|
310
|
+
.getMessageById("a1")
|
|
311
|
+
.getMessagePartByToolCallId("tc-1")
|
|
312
|
+
.addToolResult({ temp: 72 });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await waitFor(() => {
|
|
316
|
+
expect(chat.addToolOutput).toHaveBeenCalledTimes(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(chat.addToolOutput).toHaveBeenCalledWith(
|
|
320
|
+
expect.objectContaining({
|
|
321
|
+
tool: "weather",
|
|
322
|
+
toolCallId: "tc-1",
|
|
323
|
+
output: { temp: 72 },
|
|
324
|
+
options: { metadata: undefined },
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
expect(chat.addToolResult).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("appends a new user message without sending when startRun is false", async () => {
|
|
331
|
+
const chat = createChatHelpers([
|
|
332
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "earlier" }] },
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
336
|
+
|
|
337
|
+
await waitFor(() => {
|
|
338
|
+
expect(result.current.thread.getState().messages.length).toBe(1);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
act(() => {
|
|
342
|
+
result.current.thread.append({
|
|
343
|
+
role: "user",
|
|
344
|
+
content: [{ type: "text", text: "hold this" }],
|
|
345
|
+
startRun: false,
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
await waitFor(() => {
|
|
350
|
+
expect(chat.setMessages).toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(chat.sendMessage).not.toHaveBeenCalled();
|
|
354
|
+
expect(chat.messages).toHaveLength(2);
|
|
355
|
+
expect(chat.messages[1]).toEqual(
|
|
356
|
+
expect.objectContaining({
|
|
357
|
+
role: "user",
|
|
358
|
+
id: expect.any(String),
|
|
359
|
+
parts: expect.arrayContaining([
|
|
360
|
+
expect.objectContaining({ type: "text", text: "hold this" }),
|
|
361
|
+
]),
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("edits without sending when startRun is false", async () => {
|
|
367
|
+
const chat = createChatHelpers([
|
|
368
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
|
|
369
|
+
{
|
|
370
|
+
id: "a1",
|
|
371
|
+
role: "assistant",
|
|
372
|
+
parts: [{ type: "text", text: "first-answer" }],
|
|
373
|
+
},
|
|
374
|
+
{ id: "u2", role: "user", parts: [{ type: "text", text: "second" }] },
|
|
375
|
+
]);
|
|
376
|
+
|
|
377
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
378
|
+
|
|
379
|
+
await waitFor(() => {
|
|
380
|
+
expect(result.current.thread.getState().messages.length).toBe(3);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
act(() => {
|
|
384
|
+
result.current.thread.append({
|
|
385
|
+
role: "user",
|
|
386
|
+
parentId: "u1",
|
|
387
|
+
content: [{ type: "text", text: "rewrite, no run" }],
|
|
388
|
+
startRun: false,
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
await waitFor(() => {
|
|
393
|
+
expect(chat.setMessages).toHaveBeenCalled();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(chat.sendMessage).not.toHaveBeenCalled();
|
|
397
|
+
expect(chat.messages.map((m: any) => m.id)).toEqual([
|
|
398
|
+
"u1",
|
|
399
|
+
"a1",
|
|
400
|
+
expect.any(String),
|
|
401
|
+
]);
|
|
402
|
+
expect(chat.messages[2]).toEqual(
|
|
403
|
+
expect.objectContaining({
|
|
404
|
+
role: "user",
|
|
405
|
+
parts: expect.arrayContaining([
|
|
406
|
+
expect.objectContaining({ type: "text", text: "rewrite, no run" }),
|
|
407
|
+
]),
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("deletes only the selected message from AI SDK state", async () => {
|
|
413
|
+
const deleteMessage = vi.fn().mockResolvedValue(undefined);
|
|
414
|
+
vi.mocked(useExternalHistory).mockReturnValue({
|
|
415
|
+
isLoading: false,
|
|
416
|
+
deleteMessage,
|
|
417
|
+
});
|
|
418
|
+
const chat = createChatHelpers([
|
|
419
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
|
|
420
|
+
{
|
|
421
|
+
id: "a1",
|
|
422
|
+
role: "assistant",
|
|
423
|
+
parts: [{ type: "text", text: "first-answer" }],
|
|
424
|
+
},
|
|
425
|
+
{ id: "u2", role: "user", parts: [{ type: "text", text: "second" }] },
|
|
426
|
+
{
|
|
427
|
+
id: "a2",
|
|
428
|
+
role: "assistant",
|
|
429
|
+
parts: [{ type: "text", text: "second-answer" }],
|
|
430
|
+
},
|
|
431
|
+
]);
|
|
432
|
+
|
|
433
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
434
|
+
|
|
435
|
+
await waitFor(() => {
|
|
436
|
+
expect(result.current.thread.getState().messages).toHaveLength(4);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await act(async () => {
|
|
440
|
+
result.current.thread.getMessageById("u2").delete();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
expect(deleteMessage).toHaveBeenCalledWith("u2");
|
|
444
|
+
expect(chat.messages.map((message: any) => message.id)).toEqual([
|
|
445
|
+
"u1",
|
|
446
|
+
"a1",
|
|
447
|
+
"a2",
|
|
448
|
+
]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("edit slices history to parentId and sends the edited message", async () => {
|
|
452
|
+
const chat = createChatHelpers([
|
|
453
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
|
|
454
|
+
{
|
|
455
|
+
id: "a1",
|
|
456
|
+
role: "assistant",
|
|
457
|
+
parts: [{ type: "text", text: "first-answer" }],
|
|
458
|
+
},
|
|
459
|
+
{ id: "u2", role: "user", parts: [{ type: "text", text: "second" }] },
|
|
460
|
+
{
|
|
461
|
+
id: "a2",
|
|
462
|
+
role: "assistant",
|
|
463
|
+
parts: [{ type: "text", text: "second-answer" }],
|
|
464
|
+
},
|
|
465
|
+
]);
|
|
466
|
+
|
|
467
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
468
|
+
|
|
469
|
+
await waitFor(() => {
|
|
470
|
+
expect(result.current.thread.getState().messages.length).toBe(4);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Append with parentId != last message triggers onEdit
|
|
474
|
+
act(() => {
|
|
475
|
+
result.current.thread.append({
|
|
476
|
+
role: "user",
|
|
477
|
+
parentId: "u1",
|
|
478
|
+
content: [{ type: "text", text: "rewrite first" }],
|
|
479
|
+
runConfig: { custom: { temperature: 0.2 } },
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
await waitFor(() => {
|
|
484
|
+
expect(chat.sendMessage).toHaveBeenCalledTimes(1);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// sliceMessagesUntil("u1") keeps u1 + following assistant messages (a1)
|
|
488
|
+
expect(chat.messages.map((m: any) => m.id)).toEqual(["u1", "a1"]);
|
|
489
|
+
expect(chat.sendMessage).toHaveBeenCalledWith(
|
|
490
|
+
expect.objectContaining({ role: "user" }),
|
|
491
|
+
{ metadata: { custom: { temperature: 0.2 } } },
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("forwards onResume so runtime.thread.resumeRun is delivered to the adapter", async () => {
|
|
496
|
+
const chat = createChatHelpers([
|
|
497
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
|
|
498
|
+
]);
|
|
499
|
+
const onResume = vi.fn().mockResolvedValue(undefined);
|
|
500
|
+
|
|
501
|
+
const { result } = renderHook(() => useAISDKRuntime(chat, { onResume }));
|
|
502
|
+
|
|
503
|
+
await waitFor(() => {
|
|
504
|
+
expect(result.current.thread.getState().messages.length).toBe(1);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
act(() => {
|
|
508
|
+
result.current.thread.resumeRun({
|
|
509
|
+
parentId: "u1",
|
|
510
|
+
runConfig: { custom: { turnId: "t-42" } },
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
await waitFor(() => {
|
|
515
|
+
expect(onResume).toHaveBeenCalledTimes(1);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
expect(onResume).toHaveBeenCalledWith(
|
|
519
|
+
expect.objectContaining({
|
|
520
|
+
parentId: "u1",
|
|
521
|
+
sourceId: null,
|
|
522
|
+
runConfig: { custom: { turnId: "t-42" } },
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("rejects when resumeRun is called without an onResume adapter", async () => {
|
|
528
|
+
const chat = createChatHelpers([
|
|
529
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
|
|
530
|
+
]);
|
|
531
|
+
|
|
532
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
533
|
+
|
|
534
|
+
await waitFor(() => {
|
|
535
|
+
expect(result.current.thread.getState().messages.length).toBe(1);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
await expect(
|
|
539
|
+
result.current.thread.resumeRun({
|
|
540
|
+
parentId: "u1",
|
|
541
|
+
}) as unknown as Promise<void>,
|
|
542
|
+
).rejects.toThrow("Runtime does not support resuming runs.");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("forwards onResumeToolCall so runtime.thread.resumeToolCall is delivered to the adapter", async () => {
|
|
546
|
+
const chat = createChatHelpers([
|
|
547
|
+
{
|
|
548
|
+
id: "a1",
|
|
549
|
+
role: "assistant",
|
|
550
|
+
parts: [
|
|
551
|
+
{
|
|
552
|
+
type: "tool-weather",
|
|
553
|
+
toolCallId: "tc-42",
|
|
554
|
+
state: "input-available",
|
|
555
|
+
input: { city: "NYC" },
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
},
|
|
559
|
+
]);
|
|
560
|
+
const onResumeToolCall = vi.fn();
|
|
561
|
+
|
|
562
|
+
const { result } = renderHook(() =>
|
|
563
|
+
useAISDKRuntime(chat, { onResumeToolCall }),
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
await waitFor(() => {
|
|
567
|
+
expect(result.current.thread.getState().messages.length).toBeGreaterThan(
|
|
568
|
+
0,
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
act(() => {
|
|
573
|
+
result.current.thread
|
|
574
|
+
.getMessageById("a1")
|
|
575
|
+
.getMessagePartByToolCallId("tc-42")
|
|
576
|
+
.resumeToolCall({ answer: "yes" });
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(onResumeToolCall).toHaveBeenCalledTimes(1);
|
|
580
|
+
expect(onResumeToolCall).toHaveBeenCalledWith({
|
|
581
|
+
toolCallId: "tc-42",
|
|
582
|
+
payload: { answer: "yes" },
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("throws when resumeToolCall is called without an onResumeToolCall adapter", async () => {
|
|
587
|
+
const chat = createChatHelpers([
|
|
588
|
+
{
|
|
589
|
+
id: "a1",
|
|
590
|
+
role: "assistant",
|
|
591
|
+
parts: [
|
|
592
|
+
{
|
|
593
|
+
type: "tool-weather",
|
|
594
|
+
toolCallId: "tc-missing",
|
|
595
|
+
state: "input-available",
|
|
596
|
+
input: { city: "NYC" },
|
|
597
|
+
},
|
|
598
|
+
],
|
|
599
|
+
},
|
|
600
|
+
]);
|
|
601
|
+
|
|
602
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
603
|
+
|
|
604
|
+
await waitFor(() => {
|
|
605
|
+
expect(result.current.thread.getState().messages.length).toBeGreaterThan(
|
|
606
|
+
0,
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
expect(() =>
|
|
611
|
+
result.current.thread
|
|
612
|
+
.getMessageById("a1")
|
|
613
|
+
.getMessagePartByToolCallId("tc-missing")
|
|
614
|
+
.resumeToolCall({ answer: "yes" }),
|
|
615
|
+
).toThrow("Tool call tc-missing is not waiting for resume.");
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("reload slices history and regenerates with metadata", async () => {
|
|
619
|
+
const chat = createChatHelpers([
|
|
620
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "first" }] },
|
|
621
|
+
{
|
|
622
|
+
id: "a1",
|
|
623
|
+
role: "assistant",
|
|
624
|
+
parts: [{ type: "text", text: "first-answer" }],
|
|
625
|
+
},
|
|
626
|
+
{ id: "u2", role: "user", parts: [{ type: "text", text: "second" }] },
|
|
627
|
+
{
|
|
628
|
+
id: "a2",
|
|
629
|
+
role: "assistant",
|
|
630
|
+
parts: [{ type: "text", text: "second-answer" }],
|
|
631
|
+
},
|
|
632
|
+
]);
|
|
633
|
+
|
|
634
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
635
|
+
|
|
636
|
+
await waitFor(() => {
|
|
637
|
+
expect(result.current.thread.getState().messages.length).toBe(4);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
act(() => {
|
|
641
|
+
result.current.thread.startRun({
|
|
642
|
+
parentId: "u1",
|
|
643
|
+
runConfig: { custom: { maxTokens: 100 } },
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
await waitFor(() => {
|
|
648
|
+
expect(chat.regenerate).toHaveBeenCalledTimes(1);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
expect(chat.messages.map((m: any) => m.id)).toEqual(["u1", "a1"]);
|
|
652
|
+
expect(chat.regenerate).toHaveBeenCalledWith({
|
|
653
|
+
metadata: { custom: { maxTokens: 100 } },
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("forwards isDisabled to thread state", () => {
|
|
658
|
+
const chat = createChatHelpers();
|
|
659
|
+
const { result } = renderHook(() =>
|
|
660
|
+
useAISDKRuntime(chat, { isDisabled: true }),
|
|
661
|
+
);
|
|
662
|
+
expect(result.current.thread.getState().isDisabled).toBe(true);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("forwards isSendDisabled to the composer canSend gate", () => {
|
|
666
|
+
const chat = createChatHelpers();
|
|
667
|
+
const { result } = renderHook(() =>
|
|
668
|
+
useAISDKRuntime(chat, { isSendDisabled: true }),
|
|
669
|
+
);
|
|
670
|
+
act(() => {
|
|
671
|
+
result.current.thread.composer.setText("hello");
|
|
672
|
+
});
|
|
673
|
+
expect(result.current.thread.composer.getState().canSend).toBe(false);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("forwards unstable_capabilities to thread capabilities", () => {
|
|
677
|
+
const chat = createChatHelpers();
|
|
678
|
+
const { result } = renderHook(() =>
|
|
679
|
+
useAISDKRuntime(chat, { unstable_capabilities: { copy: false } }),
|
|
680
|
+
);
|
|
681
|
+
expect(result.current.thread.getState().capabilities.unstable_copy).toBe(
|
|
682
|
+
false,
|
|
683
|
+
);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("forwards suggestions to thread state", () => {
|
|
687
|
+
const chat = createChatHelpers();
|
|
688
|
+
const suggestions = [{ prompt: "tell me a joke" }];
|
|
689
|
+
const { result } = renderHook(() => useAISDKRuntime(chat, { suggestions }));
|
|
690
|
+
expect(result.current.thread.getState().suggestions).toEqual(suggestions);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it("calls adapters.suggestion after settle with messages and signal", async () => {
|
|
694
|
+
const generate = vi.fn().mockResolvedValue([{ prompt: "next" }]);
|
|
695
|
+
const chat = createChatHelpers([
|
|
696
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
697
|
+
]);
|
|
698
|
+
|
|
699
|
+
const { result, rerender } = renderHook(
|
|
700
|
+
({ status }) => {
|
|
701
|
+
chat.status = status;
|
|
702
|
+
return useAISDKRuntime(chat, {
|
|
703
|
+
adapters: { suggestion: { generate } },
|
|
704
|
+
});
|
|
705
|
+
},
|
|
706
|
+
{ initialProps: { status: "submitted" as string } },
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
expect(generate).not.toHaveBeenCalled();
|
|
710
|
+
expect(result.current.thread.getState().suggestions).toEqual([]);
|
|
711
|
+
|
|
712
|
+
chat.messages = [
|
|
713
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
714
|
+
{
|
|
715
|
+
id: "a1",
|
|
716
|
+
role: "assistant",
|
|
717
|
+
parts: [{ type: "text", text: "hello" }],
|
|
718
|
+
},
|
|
719
|
+
];
|
|
720
|
+
rerender({ status: "ready" });
|
|
721
|
+
|
|
722
|
+
await waitFor(() => {
|
|
723
|
+
expect(generate).toHaveBeenCalledTimes(1);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
expect(generate).toHaveBeenCalledWith({
|
|
727
|
+
messages: expect.any(Array),
|
|
728
|
+
signal: expect.any(AbortSignal),
|
|
729
|
+
});
|
|
730
|
+
const call = generate.mock.calls[0]![0];
|
|
731
|
+
expect(call.messages.some((m: any) => m.role === "user")).toBe(true);
|
|
732
|
+
expect(call.messages.some((m: any) => m.role === "assistant")).toBe(true);
|
|
733
|
+
|
|
734
|
+
await waitFor(() => {
|
|
735
|
+
expect(result.current.thread.getState().suggestions).toEqual([
|
|
736
|
+
{ prompt: "next" },
|
|
737
|
+
]);
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("aborts and clears suggestions when a new run starts", async () => {
|
|
742
|
+
let resolveGenerate!: (value: readonly { prompt: string }[]) => void;
|
|
743
|
+
const generate = vi.fn().mockImplementation(
|
|
744
|
+
() =>
|
|
745
|
+
new Promise<readonly { prompt: string }[]>((resolve) => {
|
|
746
|
+
resolveGenerate = resolve;
|
|
747
|
+
}),
|
|
748
|
+
);
|
|
749
|
+
const chat = createChatHelpers([
|
|
750
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
751
|
+
{
|
|
752
|
+
id: "a1",
|
|
753
|
+
role: "assistant",
|
|
754
|
+
parts: [{ type: "text", text: "hello" }],
|
|
755
|
+
},
|
|
756
|
+
]);
|
|
757
|
+
|
|
758
|
+
const { result, rerender } = renderHook(
|
|
759
|
+
({ status }) => {
|
|
760
|
+
chat.status = status;
|
|
761
|
+
return useAISDKRuntime(chat, {
|
|
762
|
+
adapters: { suggestion: { generate } },
|
|
763
|
+
});
|
|
764
|
+
},
|
|
765
|
+
{ initialProps: { status: "submitted" as string } },
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
rerender({ status: "ready" });
|
|
769
|
+
|
|
770
|
+
await waitFor(() => {
|
|
771
|
+
expect(generate).toHaveBeenCalledTimes(1);
|
|
772
|
+
});
|
|
773
|
+
const firstSignal = generate.mock.calls[0]![0].signal as AbortSignal;
|
|
774
|
+
|
|
775
|
+
resolveGenerate([{ prompt: "stale" }]);
|
|
776
|
+
await waitFor(() => {
|
|
777
|
+
expect(result.current.thread.getState().suggestions).toEqual([
|
|
778
|
+
{ prompt: "stale" },
|
|
779
|
+
]);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
chat.messages = [
|
|
783
|
+
...chat.messages,
|
|
784
|
+
{ id: "u2", role: "user", parts: [{ type: "text", text: "again" }] },
|
|
785
|
+
];
|
|
786
|
+
rerender({ status: "submitted" });
|
|
787
|
+
|
|
788
|
+
expect(firstSignal.aborted).toBe(true);
|
|
789
|
+
await waitFor(() => {
|
|
790
|
+
expect(result.current.thread.getState().suggestions).toEqual([]);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it("applies async generator yields progressively", async () => {
|
|
795
|
+
const generate = vi.fn().mockImplementation(async function* () {
|
|
796
|
+
yield [{ prompt: "a" }];
|
|
797
|
+
yield [{ prompt: "a" }, { prompt: "b" }];
|
|
798
|
+
});
|
|
799
|
+
const chat = createChatHelpers([
|
|
800
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
801
|
+
{
|
|
802
|
+
id: "a1",
|
|
803
|
+
role: "assistant",
|
|
804
|
+
parts: [{ type: "text", text: "hello" }],
|
|
805
|
+
},
|
|
806
|
+
]);
|
|
807
|
+
|
|
808
|
+
const { result, rerender } = renderHook(
|
|
809
|
+
({ status }) => {
|
|
810
|
+
chat.status = status;
|
|
811
|
+
return useAISDKRuntime(chat, {
|
|
812
|
+
adapters: { suggestion: { generate } },
|
|
813
|
+
});
|
|
814
|
+
},
|
|
815
|
+
{ initialProps: { status: "submitted" as string } },
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
rerender({ status: "ready" });
|
|
819
|
+
|
|
820
|
+
await waitFor(() => {
|
|
821
|
+
expect(result.current.thread.getState().suggestions).toEqual([
|
|
822
|
+
{ prompt: "a" },
|
|
823
|
+
{ prompt: "b" },
|
|
824
|
+
]);
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("ignores static suggestions when adapters.suggestion is set", async () => {
|
|
829
|
+
const generate = vi.fn().mockResolvedValue([{ prompt: "dynamic" }]);
|
|
830
|
+
const chat = createChatHelpers([
|
|
831
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
832
|
+
{
|
|
833
|
+
id: "a1",
|
|
834
|
+
role: "assistant",
|
|
835
|
+
parts: [{ type: "text", text: "hello" }],
|
|
836
|
+
},
|
|
837
|
+
]);
|
|
838
|
+
|
|
839
|
+
const { result, rerender } = renderHook(
|
|
840
|
+
({ status }) => {
|
|
841
|
+
chat.status = status;
|
|
842
|
+
return useAISDKRuntime(chat, {
|
|
843
|
+
suggestions: [{ prompt: "static" }],
|
|
844
|
+
adapters: { suggestion: { generate } },
|
|
845
|
+
});
|
|
846
|
+
},
|
|
847
|
+
{ initialProps: { status: "submitted" as string } },
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
expect(result.current.thread.getState().suggestions).toEqual([]);
|
|
851
|
+
|
|
852
|
+
rerender({ status: "ready" });
|
|
853
|
+
|
|
854
|
+
await waitFor(() => {
|
|
855
|
+
expect(result.current.thread.getState().suggestions).toEqual([
|
|
856
|
+
{ prompt: "dynamic" },
|
|
857
|
+
]);
|
|
858
|
+
});
|
|
859
|
+
expect(result.current.thread.getState().suggestions).not.toEqual([
|
|
860
|
+
{ prompt: "static" },
|
|
861
|
+
]);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("skips suggestion generation when the final assistant message requires action", async () => {
|
|
865
|
+
const generate = vi.fn().mockResolvedValue([{ prompt: "next" }]);
|
|
866
|
+
const chat = createChatHelpers([
|
|
867
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
868
|
+
{
|
|
869
|
+
id: "a1",
|
|
870
|
+
role: "assistant",
|
|
871
|
+
parts: [
|
|
872
|
+
{
|
|
873
|
+
type: "tool-weather",
|
|
874
|
+
toolCallId: "tc-1",
|
|
875
|
+
state: "input-available",
|
|
876
|
+
input: { city: "NYC" },
|
|
877
|
+
},
|
|
878
|
+
],
|
|
879
|
+
},
|
|
880
|
+
]);
|
|
881
|
+
|
|
882
|
+
const { result, rerender } = renderHook(
|
|
883
|
+
({ status }) => {
|
|
884
|
+
chat.status = status;
|
|
885
|
+
return useAISDKRuntime(chat, {
|
|
886
|
+
adapters: { suggestion: { generate } },
|
|
887
|
+
});
|
|
888
|
+
},
|
|
889
|
+
{ initialProps: { status: "submitted" as string } },
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
rerender({ status: "ready" });
|
|
893
|
+
|
|
894
|
+
await waitFor(() => {
|
|
895
|
+
const last = result.current.thread.getState().messages.at(-1);
|
|
896
|
+
expect(last?.role).toBe("assistant");
|
|
897
|
+
expect(last?.status?.type).toBe("requires-action");
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
await act(async () => {
|
|
901
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
902
|
+
});
|
|
903
|
+
expect(generate).not.toHaveBeenCalled();
|
|
904
|
+
expect(result.current.thread.getState().suggestions).toEqual([]);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("aborts in-flight generation and drops the stale result when the adapter is removed", async () => {
|
|
908
|
+
let resolveGenerate!: (value: readonly { prompt: string }[]) => void;
|
|
909
|
+
const generate = vi.fn().mockImplementation(
|
|
910
|
+
() =>
|
|
911
|
+
new Promise<readonly { prompt: string }[]>((resolve) => {
|
|
912
|
+
resolveGenerate = resolve;
|
|
913
|
+
}),
|
|
914
|
+
);
|
|
915
|
+
const chat = createChatHelpers([
|
|
916
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
917
|
+
{
|
|
918
|
+
id: "a1",
|
|
919
|
+
role: "assistant",
|
|
920
|
+
parts: [{ type: "text", text: "hello" }],
|
|
921
|
+
},
|
|
922
|
+
]);
|
|
923
|
+
|
|
924
|
+
const { result, rerender } = renderHook(
|
|
925
|
+
({ status, withAdapter }) => {
|
|
926
|
+
chat.status = status;
|
|
927
|
+
return useAISDKRuntime(
|
|
928
|
+
chat,
|
|
929
|
+
withAdapter ? { adapters: { suggestion: { generate } } } : {},
|
|
930
|
+
);
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
initialProps: {
|
|
934
|
+
status: "submitted" as string,
|
|
935
|
+
withAdapter: true,
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
rerender({ status: "ready", withAdapter: true });
|
|
941
|
+
|
|
942
|
+
await waitFor(() => {
|
|
943
|
+
expect(generate).toHaveBeenCalledTimes(1);
|
|
944
|
+
});
|
|
945
|
+
const firstSignal = generate.mock.calls[0]![0].signal as AbortSignal;
|
|
946
|
+
|
|
947
|
+
rerender({ status: "ready", withAdapter: false });
|
|
948
|
+
|
|
949
|
+
expect(firstSignal.aborted).toBe(true);
|
|
950
|
+
|
|
951
|
+
resolveGenerate([{ prompt: "stale" }]);
|
|
952
|
+
await act(async () => {
|
|
953
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
954
|
+
});
|
|
955
|
+
expect(result.current.thread.getState().suggestions).toEqual([]);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it("merges consecutive assistant messages into one turn by default", async () => {
|
|
959
|
+
const chat = createChatHelpers([
|
|
960
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
961
|
+
{ id: "a1", role: "assistant", parts: [{ type: "text", text: "first" }] },
|
|
962
|
+
{
|
|
963
|
+
id: "a2",
|
|
964
|
+
role: "assistant",
|
|
965
|
+
parts: [{ type: "text", text: "second" }],
|
|
966
|
+
},
|
|
967
|
+
]);
|
|
968
|
+
|
|
969
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
970
|
+
|
|
971
|
+
await waitFor(() => {
|
|
972
|
+
expect(result.current.thread.getState().messages.length).toBe(2);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
const messages = result.current.thread.getState().messages;
|
|
976
|
+
expect(messages.map((m: any) => m.role)).toEqual(["user", "assistant"]);
|
|
977
|
+
expect(textOf(messages[1])).toBe("first|second");
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('keeps consecutive assistant messages separate when joinStrategy is "none"', async () => {
|
|
981
|
+
const chat = createChatHelpers([
|
|
982
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
983
|
+
{ id: "a1", role: "assistant", parts: [{ type: "text", text: "first" }] },
|
|
984
|
+
{
|
|
985
|
+
id: "a2",
|
|
986
|
+
role: "assistant",
|
|
987
|
+
parts: [{ type: "text", text: "second" }],
|
|
988
|
+
},
|
|
989
|
+
]);
|
|
990
|
+
|
|
991
|
+
const { result } = renderHook(() =>
|
|
992
|
+
useAISDKRuntime(chat, { joinStrategy: "none" }),
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
await waitFor(() => {
|
|
996
|
+
expect(result.current.thread.getState().messages.length).toBe(3);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const messages = result.current.thread.getState().messages;
|
|
1000
|
+
expect(messages.map((m: any) => m.role)).toEqual([
|
|
1001
|
+
"user",
|
|
1002
|
+
"assistant",
|
|
1003
|
+
"assistant",
|
|
1004
|
+
]);
|
|
1005
|
+
expect(messages.slice(1).map(textOf)).toEqual(["first", "second"]);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("exposes branded extras carrying the chat helpers and error", () => {
|
|
1009
|
+
const chat = createChatHelpers();
|
|
1010
|
+
chat.error = new Error("boom");
|
|
1011
|
+
|
|
1012
|
+
const { result } = renderHook(() => useAISDKRuntime(chat));
|
|
1013
|
+
|
|
1014
|
+
const extras = result.current.thread.getState().extras;
|
|
1015
|
+
expect(aiSDKExtras.is(extras)).toBe(true);
|
|
1016
|
+
expect(aiSDKExtras.tryGet(extras)).toMatchObject({
|
|
1017
|
+
chat,
|
|
1018
|
+
error: chat.error,
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
});
|