@assistant-ui/ai-sdk 0.0.7 → 0.0.9
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/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 +1 -3
- 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 +4 -6
- package/dist/aiSDKExtras.d.ts.map +1 -1
- package/dist/converters/convertMessage.d.ts +34 -24
- package/dist/converters/convertMessage.d.ts.map +1 -1
- package/dist/converters/convertMessage.js +56 -12
- package/dist/converters/convertMessage.js.map +1 -1
- package/dist/converters/modelContentEnvelope.d.ts +6 -7
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
- package/dist/converters/toCreateMessage.d.ts +2 -4
- package/dist/converters/toCreateMessage.d.ts.map +1 -1
- package/dist/converters/toolOutputConversion.d.ts +20 -22
- package/dist/converters/toolOutputConversion.d.ts.map +1 -1
- package/dist/hooks.d.ts +2 -4
- 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 +2 -4
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
- package/dist/model-context/injectQuoteContext.d.ts +1 -3
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.d.ts +2 -4
- package/dist/runtime/AISDKChat.d.ts.map +1 -1
- package/dist/runtime/AISDKChat.js +1 -1
- package/dist/runtime/AISDKChat.js.map +1 -1
- package/dist/runtime/AISDKThreads.d.ts +29 -31
- package/dist/runtime/AISDKThreads.d.ts.map +1 -1
- package/dist/runtime/AISDKThreads.js +22 -23
- package/dist/runtime/AISDKThreads.js.map +1 -1
- package/dist/runtime/sdkIdentity.d.ts +1 -3
- package/dist/runtime/sdkIdentity.d.ts.map +1 -1
- package/dist/runtime/sdkIdentity.js +1 -1
- package/dist/runtime/useAISDKRuntime.d.ts +76 -70
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
- package/dist/runtime/useAISDKRuntime.js +311 -77
- package/dist/runtime/useAISDKRuntime.js.map +1 -1
- package/dist/runtime/useChatRuntime.d.ts +6 -8
- 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 +94 -89
- package/dist/runtime/useChatThread.d.ts.map +1 -1
- package/dist/runtime/useChatThread.js +44 -15
- package/dist/runtime/useChatThread.js.map +1 -1
- package/dist/runtime/useExternalHistory.d.ts +7 -7
- package/dist/runtime/useExternalHistory.d.ts.map +1 -1
- package/dist/runtime/useExternalHistory.js +231 -60
- package/dist/runtime/useExternalHistory.js.map +1 -1
- package/dist/runtime/useResourceCleanup.d.ts +1 -3
- 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 +3 -5
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
- package/dist/runtime/useStreamingTiming.js +1 -1
- package/dist/tools/frontendTools.d.ts +21 -23
- package/dist/tools/frontendTools.d.ts.map +1 -1
- package/dist/tools/generativeTools.d.ts +24 -26
- 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 -4
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
- package/dist/transport/AssistantChatTransport.d.ts +14 -16
- 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.d.ts +25 -27
- package/dist/transport/resumable.d.ts.map +1 -1
- package/dist/usage.d.ts +8 -10
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js.map +1 -1
- package/dist/utils/getVercelAIMessages.d.ts +2 -4
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
- package/dist/utils/sliceMessagesUntil.d.ts +1 -3
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
- package/package.json +13 -9
- package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
- package/src/adapters/vercelAttachmentAdapter.ts +2 -2
- package/src/converters/convertMessage.test.ts +541 -3
- package/src/converters/convertMessage.ts +123 -15
- package/src/index.native.ts +1 -1
- package/src/index.ts +1 -1
- package/src/runtime/AISDKChat.ts +0 -4
- package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
- package/src/runtime/AISDKThreads.test.ts +105 -46
- package/src/runtime/AISDKThreads.ts +1 -1
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1639 -19
- package/src/runtime/useAISDKRuntime.approval.test.tsx +252 -9
- package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +182 -0
- package/src/runtime/useAISDKRuntime.test.ts +275 -5
- package/src/runtime/useAISDKRuntime.ts +533 -60
- package/src/runtime/useAISDKRuntime.voice.test.tsx +50 -1
- package/src/runtime/useChatRuntime.fast-refresh.test.tsx +94 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +143 -65
- package/src/runtime/useChatRuntime.test.ts +11 -7
- package/src/runtime/useChatRuntime.ts +6 -2
- package/src/runtime/useChatThread.test.ts +166 -2
- package/src/runtime/useChatThread.transport.test.tsx +152 -0
- package/src/runtime/useChatThread.ts +89 -22
- package/src/runtime/useExternalHistory.test.ts +943 -3
- package/src/runtime/useExternalHistory.ts +378 -15
- package/src/runtime/useResourceCleanup.ts +41 -15
- package/src/transport/AssistantChatTransport.test.ts +164 -0
- package/src/transport/AssistantChatTransport.ts +22 -2
- package/src/usage.ts +1 -1
|
@@ -1,15 +1,49 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
-
import { useChat } from "@ai-sdk/react";
|
|
4
|
+
import { Chat, useChat } from "@ai-sdk/react";
|
|
5
5
|
import {
|
|
6
6
|
lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
7
7
|
type ChatTransport,
|
|
8
8
|
type UIMessage,
|
|
9
9
|
type UIMessageChunk,
|
|
10
10
|
} from "ai";
|
|
11
|
-
import {
|
|
11
|
+
import { ToolResponse } from "assistant-stream";
|
|
12
|
+
import { flushTapSync } from "@assistant-ui/tap";
|
|
13
|
+
import { AuiConfig, createAssistantClient } from "@assistant-ui/store/client";
|
|
14
|
+
import type {
|
|
15
|
+
ThreadHistoryAdapter,
|
|
16
|
+
ThreadRuntimeCore,
|
|
17
|
+
} from "@assistant-ui/core";
|
|
18
|
+
import { describe, expect, it, onTestFinished, vi } from "vitest";
|
|
19
|
+
import { version } from "react";
|
|
12
20
|
import { useAISDKRuntime } from "./useAISDKRuntime";
|
|
21
|
+
import { useChatThread } from "./useChatThread";
|
|
22
|
+
import { AISDKThreads } from "./AISDKThreads";
|
|
23
|
+
import { isToolUIPart } from "ai";
|
|
24
|
+
|
|
25
|
+
const onReact18 = version.startsWith("18.");
|
|
26
|
+
|
|
27
|
+
const historyState = vi.hoisted(() => ({
|
|
28
|
+
remoteId: undefined as string | undefined,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
32
|
+
const original = await importOriginal<typeof import("@assistant-ui/store")>();
|
|
33
|
+
return {
|
|
34
|
+
...original,
|
|
35
|
+
useAui: (...args: Parameters<typeof original.useAui>) =>
|
|
36
|
+
historyState.remoteId
|
|
37
|
+
? {
|
|
38
|
+
threadListItem: {
|
|
39
|
+
source: "threads",
|
|
40
|
+
getState: () => ({ remoteId: historyState.remoteId }),
|
|
41
|
+
},
|
|
42
|
+
subscribe: () => () => {},
|
|
43
|
+
}
|
|
44
|
+
: original.useAui(...args),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
13
47
|
|
|
14
48
|
type ApprovalHandler = NonNullable<
|
|
15
49
|
NonNullable<Parameters<typeof useAISDKRuntime>[1]>["onRespondToToolApproval"]
|
|
@@ -48,16 +82,50 @@ const toolOutput: UIMessageChunk[] = [
|
|
|
48
82
|
{ type: "finish" },
|
|
49
83
|
];
|
|
50
84
|
|
|
85
|
+
const approvalMessage = (
|
|
86
|
+
messageId: string,
|
|
87
|
+
toolCallId: string,
|
|
88
|
+
approvalId: string,
|
|
89
|
+
): UIMessage => ({
|
|
90
|
+
id: messageId,
|
|
91
|
+
role: "assistant",
|
|
92
|
+
parts: [
|
|
93
|
+
{
|
|
94
|
+
type: "tool-deploy",
|
|
95
|
+
toolCallId,
|
|
96
|
+
state: "approval-requested",
|
|
97
|
+
input: {},
|
|
98
|
+
approval: { id: approvalId },
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const userMessage: UIMessage = {
|
|
104
|
+
id: "user-1",
|
|
105
|
+
role: "user",
|
|
106
|
+
parts: [{ type: "text", text: "deploy" }],
|
|
107
|
+
};
|
|
108
|
+
|
|
51
109
|
const setup = async (
|
|
52
|
-
createHandler:
|
|
110
|
+
createHandler:
|
|
111
|
+
| ((chat: () => ReturnType<typeof useChat>) => ApprovalHandler)
|
|
112
|
+
| undefined,
|
|
53
113
|
{
|
|
54
114
|
messages,
|
|
55
115
|
request = approvalStep(),
|
|
56
116
|
continuation = () => streamOf(toolOutput),
|
|
117
|
+
joinStrategy,
|
|
118
|
+
cancelPendingToolCallsOnSend,
|
|
119
|
+
history,
|
|
120
|
+
hostApprovalOwner,
|
|
57
121
|
}: {
|
|
58
122
|
messages?: UIMessage[];
|
|
59
123
|
request?: UIMessageChunk[];
|
|
60
124
|
continuation?: () => ReadableStream<UIMessageChunk>;
|
|
125
|
+
joinStrategy?: "none";
|
|
126
|
+
cancelPendingToolCallsOnSend?: boolean;
|
|
127
|
+
history?: ThreadHistoryAdapter;
|
|
128
|
+
hostApprovalOwner?: boolean;
|
|
61
129
|
} = {},
|
|
62
130
|
) => {
|
|
63
131
|
let requests = 0;
|
|
@@ -70,27 +138,47 @@ const setup = async (
|
|
|
70
138
|
const sendAutomaticallyWhen = vi.fn(
|
|
71
139
|
lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
72
140
|
);
|
|
141
|
+
const chatInstance = hostApprovalOwner
|
|
142
|
+
? new Chat<UIMessage>({
|
|
143
|
+
id: "chat-1",
|
|
144
|
+
...(messages && { messages }),
|
|
145
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
146
|
+
sendAutomaticallyWhen,
|
|
147
|
+
})
|
|
148
|
+
: undefined;
|
|
73
149
|
|
|
74
150
|
let handler: ApprovalHandler | undefined;
|
|
75
|
-
const { result } = renderHook(() => {
|
|
76
|
-
const chat = useChat(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
151
|
+
const { result, unmount } = renderHook(() => {
|
|
152
|
+
const chat = useChat(
|
|
153
|
+
chatInstance
|
|
154
|
+
? { chat: chatInstance }
|
|
155
|
+
: {
|
|
156
|
+
id: "chat-1",
|
|
157
|
+
...(messages && { messages }),
|
|
158
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
159
|
+
sendAutomaticallyWhen,
|
|
160
|
+
},
|
|
161
|
+
);
|
|
82
162
|
return {
|
|
83
163
|
chat,
|
|
84
164
|
runtime: useAISDKRuntime(chat, {
|
|
85
|
-
|
|
86
|
-
|
|
165
|
+
...(createHandler && {
|
|
166
|
+
onRespondToToolApproval: (response, context) =>
|
|
167
|
+
handler?.(response, context),
|
|
168
|
+
}),
|
|
169
|
+
...(joinStrategy && { joinStrategy }),
|
|
170
|
+
...(history && { adapters: { history } }),
|
|
171
|
+
...(chatInstance && { unstable_hostApprovalOwner: chatInstance }),
|
|
172
|
+
...(cancelPendingToolCallsOnSend !== undefined && {
|
|
173
|
+
cancelPendingToolCallsOnSend,
|
|
174
|
+
}),
|
|
87
175
|
}),
|
|
88
176
|
};
|
|
89
177
|
});
|
|
90
178
|
const chat = () => result.current.chat;
|
|
91
|
-
handler = createHandler(chat);
|
|
179
|
+
handler = createHandler?.(chat);
|
|
92
180
|
|
|
93
|
-
if (!messages) {
|
|
181
|
+
if (!messages && !history) {
|
|
94
182
|
await act(() => chat().sendMessage({ text: "deploy" }));
|
|
95
183
|
await waitFor(() => expect(chat().status).toBe("ready"));
|
|
96
184
|
}
|
|
@@ -101,7 +189,9 @@ const setup = async (
|
|
|
101
189
|
.getMessagePartByToolCallId("tool-1");
|
|
102
190
|
|
|
103
191
|
return {
|
|
192
|
+
unmount,
|
|
104
193
|
chat,
|
|
194
|
+
thread: () => result.current.runtime.thread,
|
|
105
195
|
part,
|
|
106
196
|
approval: () =>
|
|
107
197
|
(part().getState() as { approval?: Record<string, unknown> }).approval,
|
|
@@ -130,6 +220,796 @@ describe("useAISDKRuntime tool approvals with a Chat", () => {
|
|
|
130
220
|
expect(sendMessages).toHaveBeenCalledTimes(1);
|
|
131
221
|
});
|
|
132
222
|
|
|
223
|
+
it("keeps a host answer when a runtime remounts over the same chat", async () => {
|
|
224
|
+
const sendMessages = vi.fn<ChatTransport<UIMessage>["sendMessages"]>(
|
|
225
|
+
async () => streamOf(approvalStep()),
|
|
226
|
+
);
|
|
227
|
+
// One Chat, two runtime lifetimes: the shape AISDKThreads takes when a
|
|
228
|
+
// thread is switched away from and back.
|
|
229
|
+
const chatInstance = new Chat<UIMessage>({
|
|
230
|
+
id: "chat-remount",
|
|
231
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
232
|
+
sendAutomaticallyWhen:
|
|
233
|
+
lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const hostHandler = vi.fn<ApprovalHandler>(async () => {});
|
|
237
|
+
const mount = () =>
|
|
238
|
+
renderHook(() => {
|
|
239
|
+
const chat = useChat({ chat: chatInstance });
|
|
240
|
+
return {
|
|
241
|
+
chat,
|
|
242
|
+
runtime: useAISDKRuntime(chat, {
|
|
243
|
+
onRespondToToolApproval: hostHandler,
|
|
244
|
+
unstable_hostApprovalOwner: chatInstance,
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const first = mount();
|
|
250
|
+
await act(() => first.result.current.chat.sendMessage({ text: "deploy" }));
|
|
251
|
+
await waitFor(() => expect(first.result.current.chat.status).toBe("ready"));
|
|
252
|
+
|
|
253
|
+
const partOf = (r: ReturnType<typeof mount>) =>
|
|
254
|
+
r.result.current.runtime.thread
|
|
255
|
+
.getMessageByIndex(1)
|
|
256
|
+
.getMessagePartByToolCallId("tool-1");
|
|
257
|
+
const approvalOf = (r: ReturnType<typeof mount>) =>
|
|
258
|
+
(partOf(r).getState() as { approval?: Record<string, unknown> }).approval;
|
|
259
|
+
|
|
260
|
+
await act(() => partOf(first).respondToToolApproval({ approved: true }));
|
|
261
|
+
expect(approvalOf(first)).toMatchObject({
|
|
262
|
+
id: "approval-1",
|
|
263
|
+
approved: true,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
first.unmount();
|
|
267
|
+
const second = mount();
|
|
268
|
+
|
|
269
|
+
// The answer belongs to the chat, so the remounted runtime still shows the
|
|
270
|
+
// request resolved rather than open for a second answer.
|
|
271
|
+
await waitFor(() =>
|
|
272
|
+
expect(approvalOf(second)).toMatchObject({
|
|
273
|
+
id: "approval-1",
|
|
274
|
+
approved: true,
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// The remounted runtime rebuilt its answered-id set from the owner, so a
|
|
279
|
+
// second answer is refused and the host handler is not called again.
|
|
280
|
+
expect(() =>
|
|
281
|
+
partOf(second).respondToToolApproval({ approved: true }),
|
|
282
|
+
).toThrow(/no pending approval|not waiting for a response/);
|
|
283
|
+
expect(hostHandler).toHaveBeenCalledOnce();
|
|
284
|
+
second.unmount();
|
|
285
|
+
|
|
286
|
+
// A different Chat carrying the same id is a different owner, so its
|
|
287
|
+
// request starts unanswered. Keying the record by id rather than by the
|
|
288
|
+
// object would carry the first chat's answer across to it.
|
|
289
|
+
const twin = new Chat<UIMessage>({
|
|
290
|
+
id: "chat-remount",
|
|
291
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
292
|
+
sendAutomaticallyWhen:
|
|
293
|
+
lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
294
|
+
});
|
|
295
|
+
const other = renderHook(() => {
|
|
296
|
+
const chat = useChat({ chat: twin });
|
|
297
|
+
return {
|
|
298
|
+
chat,
|
|
299
|
+
runtime: useAISDKRuntime(chat, {
|
|
300
|
+
onRespondToToolApproval: async () => {},
|
|
301
|
+
unstable_hostApprovalOwner: twin,
|
|
302
|
+
}),
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
await act(() => other.result.current.chat.sendMessage({ text: "deploy" }));
|
|
306
|
+
await waitFor(() => expect(other.result.current.chat.status).toBe("ready"));
|
|
307
|
+
|
|
308
|
+
expect(approvalOf(other)).not.toMatchObject({ approved: true });
|
|
309
|
+
other.unmount();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("keeps a host answer while the answered part is out of the visible messages", async () => {
|
|
313
|
+
// A branch switch or a deletion rewrites `messages` without resolving
|
|
314
|
+
// anything. Retiring on absence would drop the answer and let the host
|
|
315
|
+
// handler run a second time for a request it already answered.
|
|
316
|
+
const hostHandler = vi.fn<ApprovalHandler>(async () => {});
|
|
317
|
+
const sendMessages = vi.fn<ChatTransport<UIMessage>["sendMessages"]>(
|
|
318
|
+
async () => streamOf(approvalStep()),
|
|
319
|
+
);
|
|
320
|
+
const chatInstance = new Chat<UIMessage>({
|
|
321
|
+
id: "chat-branch-switch",
|
|
322
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
323
|
+
sendAutomaticallyWhen:
|
|
324
|
+
lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const view = renderHook(() => {
|
|
328
|
+
const chat = useChat({ chat: chatInstance });
|
|
329
|
+
return {
|
|
330
|
+
chat,
|
|
331
|
+
runtime: useAISDKRuntime(chat, {
|
|
332
|
+
onRespondToToolApproval: hostHandler,
|
|
333
|
+
unstable_hostApprovalOwner: chatInstance,
|
|
334
|
+
}),
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await act(() => view.result.current.chat.sendMessage({ text: "deploy" }));
|
|
339
|
+
await waitFor(() => expect(view.result.current.chat.status).toBe("ready"));
|
|
340
|
+
|
|
341
|
+
const part = () =>
|
|
342
|
+
view.result.current.runtime.thread
|
|
343
|
+
.getMessageByIndex(1)
|
|
344
|
+
.getMessagePartByToolCallId("tool-1");
|
|
345
|
+
const approval = () =>
|
|
346
|
+
(part().getState() as { approval?: Record<string, unknown> }).approval;
|
|
347
|
+
|
|
348
|
+
const answered = view.result.current.chat.messages;
|
|
349
|
+
await act(() => part().respondToToolApproval({ approved: true }));
|
|
350
|
+
expect(approval()).toMatchObject({ approved: true });
|
|
351
|
+
|
|
352
|
+
// Switch away: the answered part leaves the visible list entirely.
|
|
353
|
+
await act(async () => {
|
|
354
|
+
view.result.current.chat.setMessages([]);
|
|
355
|
+
});
|
|
356
|
+
// Switch back.
|
|
357
|
+
await act(async () => {
|
|
358
|
+
view.result.current.chat.setMessages(answered);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await waitFor(() =>
|
|
362
|
+
expect(approval()).toMatchObject({ id: "approval-1", approved: true }),
|
|
363
|
+
);
|
|
364
|
+
expect(() => part().respondToToolApproval({ approved: true })).toThrow(
|
|
365
|
+
/no pending approval|not waiting for a response/,
|
|
366
|
+
);
|
|
367
|
+
expect(hostHandler).toHaveBeenCalledOnce();
|
|
368
|
+
view.unmount();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("reopens the request on the remounted runtime when the handler rejects after the remount", async () => {
|
|
372
|
+
// The rollback resolves against a runtime that has already unmounted, so
|
|
373
|
+
// it has to reach whichever runtime is now mounted over that owner.
|
|
374
|
+
const sendMessages = vi.fn<ChatTransport<UIMessage>["sendMessages"]>(
|
|
375
|
+
async () => streamOf(approvalStep()),
|
|
376
|
+
);
|
|
377
|
+
const chatInstance = new Chat<UIMessage>({
|
|
378
|
+
id: "chat-remount-reject",
|
|
379
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
380
|
+
sendAutomaticallyWhen:
|
|
381
|
+
lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
let rejectHandler!: (error: Error) => void;
|
|
385
|
+
const pending = new Promise<void>((_resolve, reject) => {
|
|
386
|
+
rejectHandler = reject;
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const mount = () =>
|
|
390
|
+
renderHook(() => {
|
|
391
|
+
const chat = useChat({ chat: chatInstance });
|
|
392
|
+
return {
|
|
393
|
+
chat,
|
|
394
|
+
runtime: useAISDKRuntime(chat, {
|
|
395
|
+
onRespondToToolApproval: () => pending,
|
|
396
|
+
unstable_hostApprovalOwner: chatInstance,
|
|
397
|
+
}),
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const first = mount();
|
|
402
|
+
await act(() => first.result.current.chat.sendMessage({ text: "deploy" }));
|
|
403
|
+
await waitFor(() => expect(first.result.current.chat.status).toBe("ready"));
|
|
404
|
+
|
|
405
|
+
const partOf = (r: ReturnType<typeof mount>) =>
|
|
406
|
+
r.result.current.runtime.thread
|
|
407
|
+
.getMessageByIndex(1)
|
|
408
|
+
.getMessagePartByToolCallId("tool-1");
|
|
409
|
+
const approvalOf = (r: ReturnType<typeof mount>) =>
|
|
410
|
+
(partOf(r).getState() as { approval?: Record<string, unknown> }).approval;
|
|
411
|
+
|
|
412
|
+
const responded = partOf(first)
|
|
413
|
+
.respondToToolApproval({ approved: true })
|
|
414
|
+
.catch(() => {});
|
|
415
|
+
await waitFor(() =>
|
|
416
|
+
expect(approvalOf(first)).toMatchObject({ approved: true }),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
first.unmount();
|
|
420
|
+
const second = mount();
|
|
421
|
+
await waitFor(() =>
|
|
422
|
+
expect(approvalOf(second)).toMatchObject({ approved: true }),
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
await act(async () => {
|
|
426
|
+
rejectHandler(new Error("host rejected"));
|
|
427
|
+
await responded;
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
await waitFor(() =>
|
|
431
|
+
expect(approvalOf(second)).not.toMatchObject({ approved: true }),
|
|
432
|
+
);
|
|
433
|
+
second.unmount();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("accepts a retry when the host rejects before the remount subscribes", async () => {
|
|
437
|
+
const chatInstance = new Chat<UIMessage>({
|
|
438
|
+
id: "chat-reject-before-remount",
|
|
439
|
+
transport: {
|
|
440
|
+
sendMessages: async () => streamOf(approvalStep()),
|
|
441
|
+
reconnectToStream: async () => null,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
let rejectHandler!: (error: Error) => void;
|
|
445
|
+
const pending = new Promise<void>((_resolve, reject) => {
|
|
446
|
+
rejectHandler = reject;
|
|
447
|
+
});
|
|
448
|
+
const handler = vi
|
|
449
|
+
.fn<ApprovalHandler>()
|
|
450
|
+
.mockImplementationOnce(() => pending)
|
|
451
|
+
.mockImplementation(async () => {});
|
|
452
|
+
const mount = () =>
|
|
453
|
+
renderHook(() => {
|
|
454
|
+
const chat = useChat({ chat: chatInstance });
|
|
455
|
+
return {
|
|
456
|
+
chat,
|
|
457
|
+
runtime: useAISDKRuntime(chat, {
|
|
458
|
+
onRespondToToolApproval: handler,
|
|
459
|
+
unstable_hostApprovalOwner: chatInstance,
|
|
460
|
+
}),
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
const partOf = (view: ReturnType<typeof mount>) =>
|
|
464
|
+
view.result.current.runtime.thread
|
|
465
|
+
.getMessageByIndex(1)
|
|
466
|
+
.getMessagePartByToolCallId("tool-1");
|
|
467
|
+
const approvalOf = (view: ReturnType<typeof mount>) =>
|
|
468
|
+
(partOf(view).getState() as { approval?: Record<string, unknown> })
|
|
469
|
+
.approval;
|
|
470
|
+
|
|
471
|
+
const first = mount();
|
|
472
|
+
await act(() => first.result.current.chat.sendMessage({ text: "deploy" }));
|
|
473
|
+
await waitFor(() => expect(first.result.current.chat.status).toBe("ready"));
|
|
474
|
+
const response = partOf(first).respondToToolApproval({ approved: true });
|
|
475
|
+
await waitFor(() =>
|
|
476
|
+
expect(approvalOf(first)).toMatchObject({ approved: true }),
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
first.unmount();
|
|
480
|
+
rejectHandler(new Error("host rejected"));
|
|
481
|
+
await expect(response).rejects.toThrow("host rejected");
|
|
482
|
+
const second = mount();
|
|
483
|
+
expect(approvalOf(second)).not.toMatchObject({ approved: true });
|
|
484
|
+
await act(() => partOf(second).respondToToolApproval({ approved: false }));
|
|
485
|
+
expect(approvalOf(second)).toMatchObject({ approved: false });
|
|
486
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
487
|
+
second.unmount();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// The production path: AISDKThreads mounts only the visible thread through
|
|
491
|
+
// useChatThread, so the owner has to be the Chat that hook holds, not the
|
|
492
|
+
// useChat helpers it re-mints each render.
|
|
493
|
+
// Fails on React 18: TypeError: ReactRuntime.use is not a function. Shipped React 18 incompatibility.
|
|
494
|
+
it.skipIf(onReact18)(
|
|
495
|
+
"keeps a host answer across a useChatThread remount over one chat",
|
|
496
|
+
async () => {
|
|
497
|
+
const hostHandler = vi.fn<ApprovalHandler>(async () => {});
|
|
498
|
+
const sendMessages = vi.fn<ChatTransport<UIMessage>["sendMessages"]>(
|
|
499
|
+
async () => streamOf(approvalStep()),
|
|
500
|
+
);
|
|
501
|
+
const chatInstance = new Chat<UIMessage>({
|
|
502
|
+
id: "chat-thread-remount",
|
|
503
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
504
|
+
sendAutomaticallyWhen:
|
|
505
|
+
lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const mount = () =>
|
|
509
|
+
renderHook(() =>
|
|
510
|
+
useChatThread(
|
|
511
|
+
{ onRespondToToolApproval: hostHandler },
|
|
512
|
+
{
|
|
513
|
+
id: "thread-1",
|
|
514
|
+
isMainThread: true,
|
|
515
|
+
getThreadListItem: () => undefined,
|
|
516
|
+
chat: chatInstance,
|
|
517
|
+
},
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
const first = mount();
|
|
522
|
+
await act(() =>
|
|
523
|
+
(first.result.current as any).thread.append({
|
|
524
|
+
role: "user",
|
|
525
|
+
content: [{ type: "text", text: "deploy" }],
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
const partOf = (r: ReturnType<typeof mount>) =>
|
|
530
|
+
(r.result.current as any).thread
|
|
531
|
+
.getMessageByIndex(1)
|
|
532
|
+
.getMessagePartByToolCallId("tool-1");
|
|
533
|
+
const approvalOf = (r: ReturnType<typeof mount>) =>
|
|
534
|
+
(partOf(r).getState() as { approval?: Record<string, unknown> })
|
|
535
|
+
.approval;
|
|
536
|
+
|
|
537
|
+
await waitFor(() =>
|
|
538
|
+
expect(approvalOf(first)).toMatchObject({ id: "approval-1" }),
|
|
539
|
+
);
|
|
540
|
+
await act(() => partOf(first).respondToToolApproval({ approved: true }));
|
|
541
|
+
expect(approvalOf(first)).toMatchObject({ approved: true });
|
|
542
|
+
|
|
543
|
+
first.unmount();
|
|
544
|
+
const second = mount();
|
|
545
|
+
|
|
546
|
+
await waitFor(() =>
|
|
547
|
+
expect(approvalOf(second)).toMatchObject({
|
|
548
|
+
id: "approval-1",
|
|
549
|
+
approved: true,
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// Answering again after the remount is refused, and the host handler is
|
|
554
|
+
// not invoked a second time for the same request.
|
|
555
|
+
expect(() =>
|
|
556
|
+
partOf(second).respondToToolApproval({ approved: true }),
|
|
557
|
+
).toThrow(/no pending approval|not waiting for a response/);
|
|
558
|
+
expect(hostHandler).toHaveBeenCalledOnce();
|
|
559
|
+
second.unmount();
|
|
560
|
+
},
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
it("keeps a host answer after switching away and back through AISDKThreads", async () => {
|
|
564
|
+
const handler = vi.fn<ApprovalHandler>(async () => {});
|
|
565
|
+
const sendMessages = vi.fn<ChatTransport<UIMessage>["sendMessages"]>(
|
|
566
|
+
async () => streamOf(approvalStep()),
|
|
567
|
+
);
|
|
568
|
+
const handle = createAssistantClient(
|
|
569
|
+
AuiConfig({
|
|
570
|
+
threads: AISDKThreads({
|
|
571
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
572
|
+
onRespondToToolApproval: handler,
|
|
573
|
+
}),
|
|
574
|
+
}),
|
|
575
|
+
);
|
|
576
|
+
handle.subscribe(() => {});
|
|
577
|
+
try {
|
|
578
|
+
const thread = () => handle.getClient().thread;
|
|
579
|
+
const part = () =>
|
|
580
|
+
thread().message({ index: 1 }).part({ toolCallId: "tool-1" });
|
|
581
|
+
const approval = () =>
|
|
582
|
+
(part().getState() as { approval?: Record<string, unknown> }).approval;
|
|
583
|
+
|
|
584
|
+
flushTapSync(() => handle.getClient().composer.setText("deploy"));
|
|
585
|
+
flushTapSync(() => handle.getClient().composer.send());
|
|
586
|
+
await waitFor(() =>
|
|
587
|
+
expect(approval()).toMatchObject({ id: "approval-1" }),
|
|
588
|
+
);
|
|
589
|
+
await act(() => part().respondToToolApproval({ approved: true }));
|
|
590
|
+
expect(approval()).toMatchObject({ approved: true });
|
|
591
|
+
|
|
592
|
+
flushTapSync(() => handle.getClient().threads.switchToNewThread());
|
|
593
|
+
flushTapSync(() => handle.getClient().threads.switchToThread("main"));
|
|
594
|
+
|
|
595
|
+
expect(approval()).toMatchObject({ id: "approval-1", approved: true });
|
|
596
|
+
expect(() => part().respondToToolApproval({ approved: true })).toThrow(
|
|
597
|
+
/no pending approval|not waiting for a response/,
|
|
598
|
+
);
|
|
599
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
600
|
+
} finally {
|
|
601
|
+
handle.destroy();
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("keeps a host answer on a settled tool part after a remount", async () => {
|
|
606
|
+
const sendMessages = vi.fn<ChatTransport<UIMessage>["sendMessages"]>(
|
|
607
|
+
async () => streamOf(approvalStep()),
|
|
608
|
+
);
|
|
609
|
+
const chatInstance = new Chat<UIMessage>({
|
|
610
|
+
id: "chat-retire",
|
|
611
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const mount = () =>
|
|
615
|
+
renderHook(() => {
|
|
616
|
+
const chat = useChat({ chat: chatInstance });
|
|
617
|
+
return {
|
|
618
|
+
chat,
|
|
619
|
+
runtime: useAISDKRuntime(chat, {
|
|
620
|
+
onRespondToToolApproval: async () => {},
|
|
621
|
+
unstable_hostApprovalOwner: chatInstance,
|
|
622
|
+
}),
|
|
623
|
+
};
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const first = mount();
|
|
627
|
+
await act(() => first.result.current.chat.sendMessage({ text: "deploy" }));
|
|
628
|
+
await waitFor(() => expect(first.result.current.chat.status).toBe("ready"));
|
|
629
|
+
|
|
630
|
+
const partOf = (r: ReturnType<typeof mount>) =>
|
|
631
|
+
r.result.current.runtime.thread
|
|
632
|
+
.getMessageByIndex(1)
|
|
633
|
+
.getMessagePartByToolCallId("tool-1");
|
|
634
|
+
await act(() => partOf(first).respondToToolApproval({ approved: true }));
|
|
635
|
+
expect(
|
|
636
|
+
(partOf(first).getState() as { approval?: Record<string, unknown> })
|
|
637
|
+
.approval,
|
|
638
|
+
).toMatchObject({ approved: true });
|
|
639
|
+
|
|
640
|
+
await act(async () => {
|
|
641
|
+
first.result.current.chat.setMessages((messages) =>
|
|
642
|
+
messages.map((message) => ({
|
|
643
|
+
...message,
|
|
644
|
+
parts: message.parts.map((part) =>
|
|
645
|
+
isToolUIPart(part) && part.state === "approval-requested"
|
|
646
|
+
? ({
|
|
647
|
+
...part,
|
|
648
|
+
state: "output-available",
|
|
649
|
+
output: "deployed",
|
|
650
|
+
} as (typeof message.parts)[number])
|
|
651
|
+
: part,
|
|
652
|
+
),
|
|
653
|
+
})),
|
|
654
|
+
);
|
|
655
|
+
});
|
|
656
|
+
expect(
|
|
657
|
+
(partOf(first).getState() as { approval?: Record<string, unknown> })
|
|
658
|
+
.approval,
|
|
659
|
+
).toMatchObject({ id: "approval-1", approved: true });
|
|
660
|
+
|
|
661
|
+
first.unmount();
|
|
662
|
+
const second = mount();
|
|
663
|
+
await waitFor(() =>
|
|
664
|
+
expect(second.result.current.chat.messages.length).toBeGreaterThan(0),
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
const settled = (
|
|
668
|
+
partOf(second).getState() as { approval?: Record<string, unknown> }
|
|
669
|
+
).approval;
|
|
670
|
+
expect(settled).toMatchObject({ id: "approval-1", approved: true });
|
|
671
|
+
second.unmount();
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("stores a run paused on an unanswered approval, so a reload still shows the request", async () => {
|
|
675
|
+
historyState.remoteId = "remote-pending-approval";
|
|
676
|
+
onTestFinished(() => {
|
|
677
|
+
historyState.remoteId = undefined;
|
|
678
|
+
});
|
|
679
|
+
const append = vi.fn(async (_item: { message: UIMessage }) => {});
|
|
680
|
+
const history = {
|
|
681
|
+
load: vi.fn(),
|
|
682
|
+
append: vi.fn(),
|
|
683
|
+
withFormat: vi.fn().mockReturnValue({
|
|
684
|
+
load: vi.fn(async () => ({ headId: null, messages: [] })),
|
|
685
|
+
append,
|
|
686
|
+
update: vi.fn(async () => {}),
|
|
687
|
+
}),
|
|
688
|
+
} as unknown as ThreadHistoryAdapter;
|
|
689
|
+
const { chat, thread } = await setup(undefined, { history });
|
|
690
|
+
|
|
691
|
+
await act(() => chat().sendMessage({ text: "deploy" }));
|
|
692
|
+
await waitFor(() => expect(chat().status).toBe("ready"));
|
|
693
|
+
|
|
694
|
+
expect(thread().getMessageByIndex(1).getState().status).toMatchObject({
|
|
695
|
+
type: "requires-action",
|
|
696
|
+
reason: "interrupt",
|
|
697
|
+
});
|
|
698
|
+
await waitFor(() =>
|
|
699
|
+
expect(
|
|
700
|
+
append.mock.calls.find(
|
|
701
|
+
([item]) => item.message.id === "assistant-1",
|
|
702
|
+
)?.[0].message.parts,
|
|
703
|
+
).toContainEqual(
|
|
704
|
+
expect.objectContaining({
|
|
705
|
+
state: "approval-requested",
|
|
706
|
+
approval: { id: "approval-1" },
|
|
707
|
+
}),
|
|
708
|
+
),
|
|
709
|
+
);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("keeps a settled host answer in history after the run ends and reloads", async () => {
|
|
713
|
+
historyState.remoteId = "remote-settled-approval";
|
|
714
|
+
onTestFinished(() => {
|
|
715
|
+
historyState.remoteId = undefined;
|
|
716
|
+
});
|
|
717
|
+
const stored = [
|
|
718
|
+
{ parentId: null, message: userMessage },
|
|
719
|
+
{
|
|
720
|
+
parentId: "user-1",
|
|
721
|
+
message: approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
722
|
+
},
|
|
723
|
+
];
|
|
724
|
+
const load = vi.fn(async () => ({
|
|
725
|
+
headId: "assistant-1",
|
|
726
|
+
messages: stored,
|
|
727
|
+
}));
|
|
728
|
+
const update = vi.fn(async (item: (typeof stored)[number], id: string) => {
|
|
729
|
+
const index = stored.findIndex(({ message }) => message.id === id);
|
|
730
|
+
stored[index] = item;
|
|
731
|
+
});
|
|
732
|
+
const history = {
|
|
733
|
+
load: vi.fn(),
|
|
734
|
+
append: vi.fn(),
|
|
735
|
+
withFormat: vi.fn().mockReturnValue({ load, append: vi.fn(), update }),
|
|
736
|
+
} as unknown as ThreadHistoryAdapter;
|
|
737
|
+
const first = await setup(() => async () => {}, {
|
|
738
|
+
history,
|
|
739
|
+
request: toolOutput,
|
|
740
|
+
hostApprovalOwner: true,
|
|
741
|
+
});
|
|
742
|
+
await waitFor(() => expect(first.chat().messages).toHaveLength(2));
|
|
743
|
+
|
|
744
|
+
await first.respond();
|
|
745
|
+
await waitFor(() =>
|
|
746
|
+
expect(stored[1]?.message.metadata).toMatchObject({
|
|
747
|
+
__aui_toolApprovalResponses: { "approval-1": { approved: true } },
|
|
748
|
+
}),
|
|
749
|
+
);
|
|
750
|
+
// React 18's act holds the renders of the run until its callback returns, so history would never see the run start; the send is awaited outside it.
|
|
751
|
+
let sending: Promise<void> | undefined;
|
|
752
|
+
await act(async () => {
|
|
753
|
+
sending = first.chat().sendMessage();
|
|
754
|
+
});
|
|
755
|
+
await sending;
|
|
756
|
+
await waitFor(() => expect(first.chat().status).toBe("ready"));
|
|
757
|
+
await waitFor(() =>
|
|
758
|
+
expect(first.toolPart()).toMatchObject({ state: "output-available" }),
|
|
759
|
+
);
|
|
760
|
+
expect(first.approval()).toMatchObject({ approved: true });
|
|
761
|
+
await waitFor(() =>
|
|
762
|
+
expect(stored[1]?.message.parts).toMatchObject([
|
|
763
|
+
{ state: "output-available" },
|
|
764
|
+
]),
|
|
765
|
+
);
|
|
766
|
+
expect(stored[1]?.message.metadata).toMatchObject({
|
|
767
|
+
__aui_toolApprovalResponses: { "approval-1": { approved: true } },
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
first.unmount();
|
|
771
|
+
const second = await setup(() => async () => {}, { history });
|
|
772
|
+
await waitFor(() => expect(second.chat().messages).toHaveLength(2));
|
|
773
|
+
expect(second.approval()).toMatchObject({
|
|
774
|
+
id: "approval-1",
|
|
775
|
+
approved: true,
|
|
776
|
+
});
|
|
777
|
+
second.unmount();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("persists a successful host answer after the owner changes", async () => {
|
|
781
|
+
historyState.remoteId = "remote-owner-change";
|
|
782
|
+
onTestFinished(() => {
|
|
783
|
+
historyState.remoteId = undefined;
|
|
784
|
+
});
|
|
785
|
+
const stored = [
|
|
786
|
+
{ parentId: null, message: userMessage },
|
|
787
|
+
{
|
|
788
|
+
parentId: "user-1",
|
|
789
|
+
message: approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
790
|
+
},
|
|
791
|
+
];
|
|
792
|
+
const load = vi.fn(async () => ({
|
|
793
|
+
headId: "assistant-1",
|
|
794
|
+
messages: stored,
|
|
795
|
+
}));
|
|
796
|
+
const update = vi.fn(async (item: (typeof stored)[number], id: string) => {
|
|
797
|
+
const index = stored.findIndex(({ message }) => message.id === id);
|
|
798
|
+
stored[index] = item;
|
|
799
|
+
});
|
|
800
|
+
const history = {
|
|
801
|
+
load: vi.fn(),
|
|
802
|
+
append: vi.fn(),
|
|
803
|
+
withFormat: vi.fn().mockReturnValue({ load, append: vi.fn(), update }),
|
|
804
|
+
} as unknown as ThreadHistoryAdapter;
|
|
805
|
+
const chatA = new Chat<UIMessage>({
|
|
806
|
+
id: "chat-owner-a",
|
|
807
|
+
transport: {
|
|
808
|
+
sendMessages: async () => streamOf([]),
|
|
809
|
+
reconnectToStream: async () => null,
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
const ownerB = {};
|
|
813
|
+
let owner: object = chatA;
|
|
814
|
+
let resolveHandler!: () => void;
|
|
815
|
+
const handler = vi.fn<ApprovalHandler>(
|
|
816
|
+
() =>
|
|
817
|
+
new Promise<void>((resolve) => {
|
|
818
|
+
resolveHandler = resolve;
|
|
819
|
+
}),
|
|
820
|
+
);
|
|
821
|
+
const view = renderHook(() => {
|
|
822
|
+
const chat = useChat({ chat: chatA });
|
|
823
|
+
return {
|
|
824
|
+
chat,
|
|
825
|
+
runtime: useAISDKRuntime(chat, {
|
|
826
|
+
adapters: { history },
|
|
827
|
+
onRespondToToolApproval: handler,
|
|
828
|
+
unstable_hostApprovalOwner: owner,
|
|
829
|
+
}),
|
|
830
|
+
};
|
|
831
|
+
});
|
|
832
|
+
const part = () =>
|
|
833
|
+
view.result.current.runtime.thread
|
|
834
|
+
.getMessageByIndex(1)
|
|
835
|
+
.getMessagePartByToolCallId("tool-1");
|
|
836
|
+
|
|
837
|
+
await waitFor(() =>
|
|
838
|
+
expect(view.result.current.chat.messages).toHaveLength(2),
|
|
839
|
+
);
|
|
840
|
+
const responding = part().respondToToolApproval({ approved: true });
|
|
841
|
+
await waitFor(() => expect(handler).toHaveBeenCalledOnce());
|
|
842
|
+
|
|
843
|
+
owner = ownerB;
|
|
844
|
+
view.rerender();
|
|
845
|
+
await act(async () => {
|
|
846
|
+
resolveHandler();
|
|
847
|
+
await responding;
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
expect(stored[1]?.message.metadata).toMatchObject({
|
|
851
|
+
__aui_toolApprovalResponses: { "approval-1": { approved: true } },
|
|
852
|
+
});
|
|
853
|
+
view.unmount();
|
|
854
|
+
|
|
855
|
+
const reloaded = await setup(() => async () => {}, {
|
|
856
|
+
history,
|
|
857
|
+
hostApprovalOwner: true,
|
|
858
|
+
});
|
|
859
|
+
await waitFor(() => expect(reloaded.chat().messages).toHaveLength(2));
|
|
860
|
+
expect(reloaded.approval()).toMatchObject({
|
|
861
|
+
id: "approval-1",
|
|
862
|
+
approved: true,
|
|
863
|
+
});
|
|
864
|
+
reloaded.unmount();
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it("rolls a rejected answer back to its own owner after the owner changes", async () => {
|
|
868
|
+
const sendMessages = vi.fn<ChatTransport<UIMessage>["sendMessages"]>(
|
|
869
|
+
async () => streamOf(approvalStep()),
|
|
870
|
+
);
|
|
871
|
+
const chatA = new Chat<UIMessage>({
|
|
872
|
+
id: "chat-owner-a",
|
|
873
|
+
transport: { sendMessages, reconnectToStream: async () => null },
|
|
874
|
+
});
|
|
875
|
+
const ownerB = {};
|
|
876
|
+
let rejectHandler!: (err: Error) => void;
|
|
877
|
+
// Owner A's answer is held open so it can be rejected after the switch;
|
|
878
|
+
// owner B's resolves, so B holds a real answer under the same approval id.
|
|
879
|
+
let owner: object = chatA;
|
|
880
|
+
const handler = vi.fn<ApprovalHandler>(() =>
|
|
881
|
+
owner === chatA
|
|
882
|
+
? new Promise<void>((_resolve, reject) => {
|
|
883
|
+
rejectHandler = reject;
|
|
884
|
+
})
|
|
885
|
+
: Promise.resolve(),
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
const mountOver = (approvalOwner: object) =>
|
|
889
|
+
renderHook(() => {
|
|
890
|
+
const chat = useChat({ chat: chatA });
|
|
891
|
+
return {
|
|
892
|
+
chat,
|
|
893
|
+
runtime: useAISDKRuntime(chat, {
|
|
894
|
+
onRespondToToolApproval: handler,
|
|
895
|
+
unstable_hostApprovalOwner: approvalOwner,
|
|
896
|
+
}),
|
|
897
|
+
};
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const approvalIn = (r: ReturnType<typeof mountOver>) =>
|
|
901
|
+
(
|
|
902
|
+
r.result.current.runtime.thread
|
|
903
|
+
.getMessageByIndex(1)
|
|
904
|
+
.getMessagePartByToolCallId("tool-1")
|
|
905
|
+
.getState() as { approval?: Record<string, unknown> }
|
|
906
|
+
).approval;
|
|
907
|
+
|
|
908
|
+
const view = renderHook(() => {
|
|
909
|
+
const chat = useChat({ chat: chatA });
|
|
910
|
+
return {
|
|
911
|
+
chat,
|
|
912
|
+
runtime: useAISDKRuntime(chat, {
|
|
913
|
+
onRespondToToolApproval: handler,
|
|
914
|
+
unstable_hostApprovalOwner: owner,
|
|
915
|
+
}),
|
|
916
|
+
};
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
await act(() => view.result.current.chat.sendMessage({ text: "deploy" }));
|
|
920
|
+
await waitFor(() => expect(view.result.current.chat.status).toBe("ready"));
|
|
921
|
+
|
|
922
|
+
const part = () =>
|
|
923
|
+
view.result.current.runtime.thread
|
|
924
|
+
.getMessageByIndex(1)
|
|
925
|
+
.getMessagePartByToolCallId("tool-1");
|
|
926
|
+
const answering = part()
|
|
927
|
+
.respondToToolApproval({ approved: true })
|
|
928
|
+
.catch(() => {});
|
|
929
|
+
await waitFor(() => expect(handler).toHaveBeenCalledOnce());
|
|
930
|
+
|
|
931
|
+
// The runtime moves to a different owner while the answer is in flight.
|
|
932
|
+
owner = ownerB;
|
|
933
|
+
view.rerender();
|
|
934
|
+
|
|
935
|
+
// Owner B answers the same approval id, so the rollback below has a
|
|
936
|
+
// same-id neighbour it could wrongly delete.
|
|
937
|
+
await waitFor(() => expect(approvalIn(view)?.approved).toBeUndefined());
|
|
938
|
+
await act(() => part().respondToToolApproval({ approved: false }));
|
|
939
|
+
await waitFor(() => expect(approvalIn(view)?.approved).toBe(false));
|
|
940
|
+
|
|
941
|
+
await act(async () => {
|
|
942
|
+
rejectHandler(new Error("host refused"));
|
|
943
|
+
await answering;
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
// The rollback belongs to chat A, so the owner on screen keeps its own
|
|
947
|
+
// answer rather than being reopened alongside A.
|
|
948
|
+
await waitFor(() => expect(approvalIn(view)?.approved).toBe(false));
|
|
949
|
+
view.unmount();
|
|
950
|
+
|
|
951
|
+
const reopened = mountOver(chatA);
|
|
952
|
+
await waitFor(() => expect(approvalIn(reopened)?.approved).toBeUndefined());
|
|
953
|
+
reopened.unmount();
|
|
954
|
+
|
|
955
|
+
const stillAnswered = mountOver(ownerB);
|
|
956
|
+
await waitFor(() =>
|
|
957
|
+
expect(approvalIn(stillAnswered)?.approved).toBe(false),
|
|
958
|
+
);
|
|
959
|
+
stillAnswered.unmount();
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it("renders a streamed request as its approvalDescriptor declares", async () => {
|
|
963
|
+
const descriptor = {
|
|
964
|
+
prompt: "Which environment?",
|
|
965
|
+
display: "select",
|
|
966
|
+
options: [{ id: "once", kind: "allow-once", label: "Staging once" }],
|
|
967
|
+
scope: "deploy",
|
|
968
|
+
};
|
|
969
|
+
const handler = vi.fn<ApprovalHandler>(async () => {});
|
|
970
|
+
const { approval, part, toolPart } = await setup(() => handler, {
|
|
971
|
+
request: [
|
|
972
|
+
{ type: "start", messageId: "assistant-1" },
|
|
973
|
+
{ type: "start-step" },
|
|
974
|
+
{
|
|
975
|
+
type: "tool-input-available",
|
|
976
|
+
toolCallId: "tool-1",
|
|
977
|
+
toolName: "deploy",
|
|
978
|
+
input: {},
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
type: "tool-approval-request",
|
|
982
|
+
approvalId: "approval-1",
|
|
983
|
+
toolCallId: "tool-1",
|
|
984
|
+
approvalDescriptor: descriptor,
|
|
985
|
+
},
|
|
986
|
+
{ type: "finish-step" },
|
|
987
|
+
{ type: "finish" },
|
|
988
|
+
],
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
expect(toolPart()).toMatchObject({
|
|
992
|
+
state: "approval-requested",
|
|
993
|
+
approval: { id: "approval-1", descriptor },
|
|
994
|
+
});
|
|
995
|
+
expect(approval()).toEqual({
|
|
996
|
+
id: "approval-1",
|
|
997
|
+
prompt: "Which environment?",
|
|
998
|
+
display: "select",
|
|
999
|
+
options: [{ id: "once", kind: "allow-once", label: "Staging once" }],
|
|
1000
|
+
descriptor,
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
await act(() => part().respondToToolApproval({ optionId: "once" }));
|
|
1004
|
+
|
|
1005
|
+
expect(handler).toHaveBeenCalledWith(
|
|
1006
|
+
{ approvalId: "approval-1", approved: true, optionId: "once" },
|
|
1007
|
+
expect.objectContaining({ toolCallId: "tool-1", toolName: "deploy" }),
|
|
1008
|
+
);
|
|
1009
|
+
expect(approval()).toMatchObject({ approved: true, optionId: "once" });
|
|
1010
|
+
expect(toolPart()).toMatchObject({ state: "approval-requested" });
|
|
1011
|
+
});
|
|
1012
|
+
|
|
133
1013
|
it("keeps a host answer out of the chat's automatic sends", async () => {
|
|
134
1014
|
const { chat, sendMessages, respond } = await setup(() => async () => {}, {
|
|
135
1015
|
request: approvalStep([
|
|
@@ -244,8 +1124,53 @@ describe("useAISDKRuntime tool approvals with a Chat", () => {
|
|
|
244
1124
|
expect(sendMessages).toHaveBeenCalledTimes(2);
|
|
245
1125
|
});
|
|
246
1126
|
|
|
247
|
-
it("
|
|
248
|
-
const {
|
|
1127
|
+
it("cancels an unanswered approval when a staged message follows it", async () => {
|
|
1128
|
+
const { thread, approval, part, toolPart } = await setup(
|
|
1129
|
+
() =>
|
|
1130
|
+
(_response, { respondViaAISDK }) =>
|
|
1131
|
+
respondViaAISDK(),
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
await act(() =>
|
|
1135
|
+
thread().append({
|
|
1136
|
+
role: "user",
|
|
1137
|
+
content: [{ type: "text", text: "later" }],
|
|
1138
|
+
startRun: false,
|
|
1139
|
+
}),
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
expect(toolPart()).toMatchObject({ state: "approval-requested" });
|
|
1143
|
+
expect(approval()).toMatchObject({
|
|
1144
|
+
id: "approval-1",
|
|
1145
|
+
resolution: "cancelled",
|
|
1146
|
+
});
|
|
1147
|
+
expect(part().getState().status).toEqual({
|
|
1148
|
+
type: "incomplete",
|
|
1149
|
+
reason: "cancelled",
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
it("preserves a host answer when a staged message follows it", async () => {
|
|
1154
|
+
const { thread, approval, respond } = await setup(() => async () => {});
|
|
1155
|
+
|
|
1156
|
+
await respond();
|
|
1157
|
+
await act(() =>
|
|
1158
|
+
thread().append({
|
|
1159
|
+
role: "user",
|
|
1160
|
+
content: [{ type: "text", text: "later" }],
|
|
1161
|
+
startRun: false,
|
|
1162
|
+
}),
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
expect(approval()).toMatchObject({
|
|
1166
|
+
id: "approval-1",
|
|
1167
|
+
approved: true,
|
|
1168
|
+
});
|
|
1169
|
+
expect(approval()).not.toHaveProperty("resolution");
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it("rejects a stale approval even when consecutive assistant messages are joined", async () => {
|
|
1173
|
+
const { approval, part, toolPart, sendMessages, respond } = await setup(
|
|
249
1174
|
() =>
|
|
250
1175
|
(_response, { respondViaAISDK }) =>
|
|
251
1176
|
respondViaAISDK(),
|
|
@@ -278,10 +1203,705 @@ describe("useAISDKRuntime tool approvals with a Chat", () => {
|
|
|
278
1203
|
},
|
|
279
1204
|
);
|
|
280
1205
|
|
|
281
|
-
await respond();
|
|
282
|
-
|
|
283
1206
|
expect(toolPart()).toMatchObject({ state: "approval-requested" });
|
|
284
|
-
expect(approval()).
|
|
1207
|
+
expect(approval()).toMatchObject({
|
|
1208
|
+
id: "approval-1",
|
|
1209
|
+
resolution: "cancelled",
|
|
1210
|
+
});
|
|
1211
|
+
expect(part().getState().status).toEqual({
|
|
1212
|
+
type: "incomplete",
|
|
1213
|
+
reason: "cancelled",
|
|
1214
|
+
});
|
|
1215
|
+
expect(() => respond()).toThrow("Tool call has no pending approval");
|
|
1216
|
+
expect(sendMessages).not.toHaveBeenCalled();
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
it("cancels the approval chunk before a voice assistant message", async () => {
|
|
1220
|
+
const { thread } = await setup(undefined, {
|
|
1221
|
+
messages: [
|
|
1222
|
+
userMessage,
|
|
1223
|
+
approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1224
|
+
{
|
|
1225
|
+
id: "voice-1",
|
|
1226
|
+
role: "assistant",
|
|
1227
|
+
parts: [{ type: "text", text: "Hello" }],
|
|
1228
|
+
metadata: { modality: "voice" },
|
|
1229
|
+
},
|
|
1230
|
+
],
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
expect(thread().getMessageByIndex(1).getState().status).toEqual({
|
|
1234
|
+
type: "incomplete",
|
|
1235
|
+
reason: "cancelled",
|
|
1236
|
+
});
|
|
1237
|
+
expect(thread().getMessageByIndex(2).getState().status).toMatchObject({
|
|
1238
|
+
type: "complete",
|
|
1239
|
+
});
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
it("keeps a joined message answerable when its last approval is open", async () => {
|
|
1243
|
+
const handler = vi.fn<ApprovalHandler>(async () => {});
|
|
1244
|
+
const { thread } = await setup(() => handler, {
|
|
1245
|
+
messages: [
|
|
1246
|
+
userMessage,
|
|
1247
|
+
approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1248
|
+
approvalMessage("assistant-2", "tool-2", "approval-2"),
|
|
1249
|
+
],
|
|
1250
|
+
});
|
|
1251
|
+
const message = () => thread().getMessageByIndex(1);
|
|
1252
|
+
const earlier = () => message().getMessagePartByToolCallId("tool-1");
|
|
1253
|
+
const last = () => message().getMessagePartByToolCallId("tool-2");
|
|
1254
|
+
|
|
1255
|
+
expect(message().getState().status).toMatchObject({
|
|
1256
|
+
type: "requires-action",
|
|
1257
|
+
});
|
|
1258
|
+
expect(
|
|
1259
|
+
(earlier().getState() as { approval?: Record<string, unknown> }).approval,
|
|
1260
|
+
).toMatchObject({
|
|
1261
|
+
resolution: "cancelled",
|
|
1262
|
+
});
|
|
1263
|
+
await act(() => last().respondToToolApproval({ approved: true }));
|
|
1264
|
+
expect(handler).toHaveBeenCalledWith(
|
|
1265
|
+
{ approvalId: "approval-2", approved: true },
|
|
1266
|
+
expect.objectContaining({ toolCallId: "tool-2" }),
|
|
1267
|
+
);
|
|
1268
|
+
expect(
|
|
1269
|
+
(last().getState() as { approval?: Record<string, unknown> }).approval,
|
|
1270
|
+
).toMatchObject({
|
|
1271
|
+
approved: true,
|
|
1272
|
+
});
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("keeps a joined human tool call open after an earlier approval is superseded", async () => {
|
|
1276
|
+
const { thread } = await setup(undefined, {
|
|
1277
|
+
messages: [
|
|
1278
|
+
userMessage,
|
|
1279
|
+
approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1280
|
+
{
|
|
1281
|
+
id: "assistant-2",
|
|
1282
|
+
role: "assistant",
|
|
1283
|
+
parts: [
|
|
1284
|
+
{
|
|
1285
|
+
type: "tool-lookup",
|
|
1286
|
+
toolCallId: "tool-2",
|
|
1287
|
+
state: "input-available",
|
|
1288
|
+
input: {},
|
|
1289
|
+
},
|
|
1290
|
+
],
|
|
1291
|
+
},
|
|
1292
|
+
],
|
|
1293
|
+
});
|
|
1294
|
+
const message = thread().getMessageByIndex(1);
|
|
1295
|
+
|
|
1296
|
+
expect(
|
|
1297
|
+
(
|
|
1298
|
+
message.getMessagePartByToolCallId("tool-1").getState() as {
|
|
1299
|
+
approval?: Record<string, unknown>;
|
|
1300
|
+
}
|
|
1301
|
+
).approval,
|
|
1302
|
+
).toMatchObject({ resolution: "cancelled" });
|
|
1303
|
+
expect(message.getState().status).toMatchObject({
|
|
1304
|
+
type: "requires-action",
|
|
1305
|
+
});
|
|
1306
|
+
expect(
|
|
1307
|
+
message.getMessagePartByToolCallId("tool-2").getState().status,
|
|
1308
|
+
).toMatchObject({
|
|
1309
|
+
type: "requires-action",
|
|
1310
|
+
});
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
it("keeps a joined host-answered approval open after an earlier approval is superseded", async () => {
|
|
1314
|
+
const handler = vi.fn<ApprovalHandler>(async () => {});
|
|
1315
|
+
const { thread } = await setup(() => handler, {
|
|
1316
|
+
messages: [
|
|
1317
|
+
userMessage,
|
|
1318
|
+
approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1319
|
+
approvalMessage("assistant-2", "tool-2", "approval-2"),
|
|
1320
|
+
],
|
|
1321
|
+
});
|
|
1322
|
+
const message = () => thread().getMessageByIndex(1);
|
|
1323
|
+
const last = () => message().getMessagePartByToolCallId("tool-2");
|
|
1324
|
+
|
|
1325
|
+
await act(() => last().respondToToolApproval({ approved: true }));
|
|
1326
|
+
|
|
1327
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1328
|
+
expect(
|
|
1329
|
+
(
|
|
1330
|
+
message().getMessagePartByToolCallId("tool-1").getState() as {
|
|
1331
|
+
approval?: Record<string, unknown>;
|
|
1332
|
+
}
|
|
1333
|
+
).approval,
|
|
1334
|
+
).toMatchObject({ resolution: "cancelled" });
|
|
1335
|
+
expect(message().getState().status).toMatchObject({
|
|
1336
|
+
type: "requires-action",
|
|
1337
|
+
});
|
|
1338
|
+
expect(last().getState()).toMatchObject({
|
|
1339
|
+
approval: { approved: true },
|
|
1340
|
+
status: { type: "requires-action" },
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it("keeps the last joined message running while the chat streams", async () => {
|
|
1345
|
+
let stream!: ReadableStreamDefaultController<UIMessageChunk>;
|
|
1346
|
+
const { chat, thread } = await setup(undefined, {
|
|
1347
|
+
messages: [
|
|
1348
|
+
userMessage,
|
|
1349
|
+
approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1350
|
+
],
|
|
1351
|
+
continuation: () =>
|
|
1352
|
+
new ReadableStream({
|
|
1353
|
+
start(controller) {
|
|
1354
|
+
stream = controller;
|
|
1355
|
+
},
|
|
1356
|
+
}),
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
act(() => {
|
|
1360
|
+
void chat().sendMessage();
|
|
1361
|
+
});
|
|
1362
|
+
await act(async () => {
|
|
1363
|
+
stream.enqueue({ type: "start", messageId: "assistant-2" });
|
|
1364
|
+
stream.enqueue({ type: "text-start", id: "text-2" });
|
|
1365
|
+
stream.enqueue({ type: "text-delta", id: "text-2", delta: "working" });
|
|
1366
|
+
});
|
|
1367
|
+
await waitFor(() => expect(chat().status).toBe("streaming"));
|
|
1368
|
+
expect(thread().getMessageByIndex(1).getState().status).toMatchObject({
|
|
1369
|
+
type: "running",
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
await act(async () => {
|
|
1373
|
+
stream.enqueue({ type: "text-end", id: "text-2" });
|
|
1374
|
+
stream.enqueue({ type: "finish" });
|
|
1375
|
+
stream.close();
|
|
1376
|
+
});
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
it.each([
|
|
1380
|
+
{
|
|
1381
|
+
situation: "without superseded approvals",
|
|
1382
|
+
middle: {
|
|
1383
|
+
id: "assistant-1",
|
|
1384
|
+
role: "assistant",
|
|
1385
|
+
parts: [{ type: "text", text: "Earlier reply" }],
|
|
1386
|
+
} as UIMessage,
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
situation: "with unchanged superseded approval ids",
|
|
1390
|
+
middle: approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1391
|
+
},
|
|
1392
|
+
])(
|
|
1393
|
+
"keeps earlier messages stable while a reply streams $situation",
|
|
1394
|
+
async ({ middle }) => {
|
|
1395
|
+
let stream!: ReadableStreamDefaultController<UIMessageChunk>;
|
|
1396
|
+
const { chat, thread } = await setup(undefined, {
|
|
1397
|
+
messages: [
|
|
1398
|
+
userMessage,
|
|
1399
|
+
middle,
|
|
1400
|
+
{
|
|
1401
|
+
id: "user-2",
|
|
1402
|
+
role: "user",
|
|
1403
|
+
parts: [{ type: "text", text: "Continue" }],
|
|
1404
|
+
},
|
|
1405
|
+
],
|
|
1406
|
+
continuation: () =>
|
|
1407
|
+
new ReadableStream({
|
|
1408
|
+
start(controller) {
|
|
1409
|
+
stream = controller;
|
|
1410
|
+
},
|
|
1411
|
+
}),
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
act(() => {
|
|
1415
|
+
void chat().sendMessage();
|
|
1416
|
+
});
|
|
1417
|
+
await act(async () => {
|
|
1418
|
+
stream.enqueue({ type: "start", messageId: "assistant-2" });
|
|
1419
|
+
stream.enqueue({ type: "text-start", id: "text-2" });
|
|
1420
|
+
stream.enqueue({ type: "text-delta", id: "text-2", delta: "One" });
|
|
1421
|
+
});
|
|
1422
|
+
await waitFor(() => expect(chat().status).toBe("streaming"));
|
|
1423
|
+
const earlierMessage = thread().getState().messages[1];
|
|
1424
|
+
|
|
1425
|
+
await act(async () => {
|
|
1426
|
+
stream.enqueue({ type: "text-delta", id: "text-2", delta: " two" });
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
expect(chat().messages.at(-1)?.parts).toMatchObject([
|
|
1430
|
+
{ type: "text", text: "One two" },
|
|
1431
|
+
]);
|
|
1432
|
+
expect(thread().getState().messages[1]).toBe(earlierMessage);
|
|
1433
|
+
|
|
1434
|
+
await act(async () => {
|
|
1435
|
+
stream.enqueue({ type: "text-end", id: "text-2" });
|
|
1436
|
+
stream.enqueue({ type: "finish" });
|
|
1437
|
+
stream.close();
|
|
1438
|
+
});
|
|
1439
|
+
},
|
|
1440
|
+
);
|
|
1441
|
+
|
|
1442
|
+
it.each(["host", "AI SDK"] as const)(
|
|
1443
|
+
"rejects a direct response to a superseded approval through the %s path",
|
|
1444
|
+
async (path) => {
|
|
1445
|
+
const handler = vi.fn<ApprovalHandler>(async () => {});
|
|
1446
|
+
const { chat, thread } = await setup(
|
|
1447
|
+
path === "host" ? () => handler : undefined,
|
|
1448
|
+
{
|
|
1449
|
+
messages: [
|
|
1450
|
+
userMessage,
|
|
1451
|
+
approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1452
|
+
{
|
|
1453
|
+
id: "user-2",
|
|
1454
|
+
role: "user",
|
|
1455
|
+
parts: [{ type: "text", text: "later" }],
|
|
1456
|
+
},
|
|
1457
|
+
],
|
|
1458
|
+
},
|
|
1459
|
+
);
|
|
1460
|
+
const addToolApprovalResponse = vi.spyOn(
|
|
1461
|
+
chat(),
|
|
1462
|
+
"addToolApprovalResponse",
|
|
1463
|
+
);
|
|
1464
|
+
|
|
1465
|
+
expect(
|
|
1466
|
+
(
|
|
1467
|
+
thread()
|
|
1468
|
+
.getMessageByIndex(1)
|
|
1469
|
+
.getMessagePartByToolCallId("tool-1")
|
|
1470
|
+
.getState() as { approval?: Record<string, unknown> }
|
|
1471
|
+
).approval,
|
|
1472
|
+
).toMatchObject({ resolution: "cancelled" });
|
|
1473
|
+
const threadCore = (
|
|
1474
|
+
thread() as unknown as {
|
|
1475
|
+
__internal_threadBinding: { getState(): ThreadRuntimeCore };
|
|
1476
|
+
}
|
|
1477
|
+
).__internal_threadBinding.getState();
|
|
1478
|
+
await expect(
|
|
1479
|
+
threadCore.respondToToolApproval({
|
|
1480
|
+
approvalId: "approval-1",
|
|
1481
|
+
approved: true,
|
|
1482
|
+
}),
|
|
1483
|
+
).rejects.toThrow(
|
|
1484
|
+
"Tool approval approval-1 is not waiting for a response.",
|
|
1485
|
+
);
|
|
1486
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1487
|
+
expect(addToolApprovalResponse).not.toHaveBeenCalled();
|
|
1488
|
+
},
|
|
1489
|
+
);
|
|
1490
|
+
|
|
1491
|
+
it('cancels only the approval message with joinStrategy "none"', async () => {
|
|
1492
|
+
const { thread } = await setup(undefined, {
|
|
1493
|
+
joinStrategy: "none",
|
|
1494
|
+
messages: [
|
|
1495
|
+
userMessage,
|
|
1496
|
+
approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1497
|
+
{
|
|
1498
|
+
id: "assistant-2",
|
|
1499
|
+
role: "assistant",
|
|
1500
|
+
parts: [{ type: "text", text: "Later" }],
|
|
1501
|
+
},
|
|
1502
|
+
],
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
expect(thread().getMessageByIndex(1).getState().status).toEqual({
|
|
1506
|
+
type: "incomplete",
|
|
1507
|
+
reason: "cancelled",
|
|
1508
|
+
});
|
|
1509
|
+
expect(thread().getMessageByIndex(2).getState().status).toMatchObject({
|
|
1510
|
+
type: "complete",
|
|
1511
|
+
});
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it("restores a superseded approval through external history", async () => {
|
|
1515
|
+
historyState.remoteId = "remote-thread";
|
|
1516
|
+
onTestFinished(() => {
|
|
1517
|
+
historyState.remoteId = undefined;
|
|
1518
|
+
});
|
|
1519
|
+
const load = vi.fn(async () => ({
|
|
1520
|
+
headId: "voice-1",
|
|
1521
|
+
messages: [
|
|
1522
|
+
{ parentId: null, message: userMessage },
|
|
1523
|
+
{
|
|
1524
|
+
parentId: "user-1",
|
|
1525
|
+
message: approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1526
|
+
},
|
|
1527
|
+
{
|
|
1528
|
+
parentId: "assistant-1",
|
|
1529
|
+
message: {
|
|
1530
|
+
id: "voice-1",
|
|
1531
|
+
role: "assistant" as const,
|
|
1532
|
+
parts: [{ type: "text" as const, text: "Hello" }],
|
|
1533
|
+
metadata: { modality: "voice" },
|
|
1534
|
+
},
|
|
1535
|
+
},
|
|
1536
|
+
],
|
|
1537
|
+
}));
|
|
1538
|
+
const history = {
|
|
1539
|
+
load: vi.fn(),
|
|
1540
|
+
append: vi.fn(),
|
|
1541
|
+
withFormat: vi.fn().mockReturnValue({ load, append: vi.fn() }),
|
|
1542
|
+
} as unknown as ThreadHistoryAdapter;
|
|
1543
|
+
const { chat, thread } = await setup(undefined, { history });
|
|
1544
|
+
|
|
1545
|
+
await waitFor(() => expect(chat().messages).toHaveLength(3));
|
|
1546
|
+
expect(history.withFormat).toHaveBeenCalled();
|
|
1547
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
1548
|
+
expect(thread().getMessageByIndex(1).getState().status).toEqual({
|
|
1549
|
+
type: "incomplete",
|
|
1550
|
+
reason: "cancelled",
|
|
1551
|
+
});
|
|
1552
|
+
expect(thread().getMessageByIndex(2).getState().status).toMatchObject({
|
|
1553
|
+
type: "complete",
|
|
1554
|
+
});
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
it("keeps a host answer when another approval in the same message is superseded", async () => {
|
|
1558
|
+
const handler = vi.fn<ApprovalHandler>(async () => {});
|
|
1559
|
+
const { chat, thread } = await setup(() => handler, {
|
|
1560
|
+
messages: [
|
|
1561
|
+
userMessage,
|
|
1562
|
+
{
|
|
1563
|
+
id: "assistant-1",
|
|
1564
|
+
role: "assistant",
|
|
1565
|
+
parts: [
|
|
1566
|
+
...approvalMessage("assistant-1", "tool-1", "approval-1").parts,
|
|
1567
|
+
...approvalMessage("assistant-1", "tool-2", "approval-2").parts,
|
|
1568
|
+
],
|
|
1569
|
+
},
|
|
1570
|
+
],
|
|
1571
|
+
});
|
|
1572
|
+
const message = () => thread().getMessageByIndex(1);
|
|
1573
|
+
const first = () => message().getMessagePartByToolCallId("tool-1");
|
|
1574
|
+
const second = () => message().getMessagePartByToolCallId("tool-2");
|
|
1575
|
+
|
|
1576
|
+
await act(() => first().respondToToolApproval({ approved: true }));
|
|
1577
|
+
act(() =>
|
|
1578
|
+
chat().setMessages([
|
|
1579
|
+
...chat().messages,
|
|
1580
|
+
{
|
|
1581
|
+
id: "user-2",
|
|
1582
|
+
role: "user",
|
|
1583
|
+
parts: [{ type: "text", text: "later" }],
|
|
1584
|
+
},
|
|
1585
|
+
]),
|
|
1586
|
+
);
|
|
1587
|
+
|
|
1588
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
1589
|
+
expect(
|
|
1590
|
+
(first().getState() as { approval?: Record<string, unknown> }).approval,
|
|
1591
|
+
).toMatchObject({
|
|
1592
|
+
approved: true,
|
|
1593
|
+
});
|
|
1594
|
+
expect(
|
|
1595
|
+
(second().getState() as { approval?: Record<string, unknown> }).approval,
|
|
1596
|
+
).toMatchObject({
|
|
1597
|
+
resolution: "cancelled",
|
|
1598
|
+
});
|
|
1599
|
+
expect(message().getState().status).toEqual({
|
|
1600
|
+
type: "incomplete",
|
|
1601
|
+
reason: "cancelled",
|
|
1602
|
+
});
|
|
1603
|
+
expect(chat().messages[1]?.parts).toMatchObject([
|
|
1604
|
+
{ state: "approval-requested" },
|
|
1605
|
+
{ state: "approval-requested" },
|
|
1606
|
+
]);
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
it("settles a staged approval with cancellation on send disabled", async () => {
|
|
1610
|
+
const handler = vi.fn<ApprovalHandler>(async () => {});
|
|
1611
|
+
const { thread, approval, toolPart, sendMessages } = await setup(
|
|
1612
|
+
() => handler,
|
|
1613
|
+
{
|
|
1614
|
+
cancelPendingToolCallsOnSend: false,
|
|
1615
|
+
},
|
|
1616
|
+
);
|
|
1617
|
+
|
|
1618
|
+
await act(() =>
|
|
1619
|
+
thread().append({
|
|
1620
|
+
role: "user",
|
|
1621
|
+
content: [{ type: "text", text: "later" }],
|
|
1622
|
+
startRun: false,
|
|
1623
|
+
}),
|
|
1624
|
+
);
|
|
1625
|
+
|
|
1626
|
+
expect(approval()).toMatchObject({ resolution: "cancelled" });
|
|
1627
|
+
expect(thread().getMessageByIndex(1).getState().status).toEqual({
|
|
1628
|
+
type: "incomplete",
|
|
1629
|
+
reason: "cancelled",
|
|
1630
|
+
});
|
|
1631
|
+
expect(toolPart()).toMatchObject({
|
|
1632
|
+
state: "approval-requested",
|
|
1633
|
+
});
|
|
1634
|
+
expect(handler).not.toHaveBeenCalled();
|
|
1635
|
+
expect(sendMessages).toHaveBeenCalledTimes(1);
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
it("uses the AI SDK response path without a host handler", async () => {
|
|
1639
|
+
const { chat, thread, toolPart, respond, sendMessages } =
|
|
1640
|
+
await setup(undefined);
|
|
1641
|
+
|
|
1642
|
+
await respond();
|
|
1643
|
+
await waitFor(() => expect(sendMessages).toHaveBeenCalledTimes(2));
|
|
1644
|
+
await waitFor(() =>
|
|
1645
|
+
expect(toolPart()).toMatchObject({
|
|
1646
|
+
state: "output-available",
|
|
1647
|
+
approval: { id: "approval-1", approved: true },
|
|
1648
|
+
}),
|
|
1649
|
+
);
|
|
1650
|
+
|
|
1651
|
+
act(() =>
|
|
1652
|
+
chat().setMessages([
|
|
1653
|
+
userMessage,
|
|
1654
|
+
approvalMessage("assistant-1", "tool-1", "approval-1"),
|
|
1655
|
+
{
|
|
1656
|
+
id: "user-2",
|
|
1657
|
+
role: "user",
|
|
1658
|
+
parts: [{ type: "text", text: "later" }],
|
|
1659
|
+
},
|
|
1660
|
+
]),
|
|
1661
|
+
);
|
|
1662
|
+
expect(thread().getMessageByIndex(1).getState().status).toEqual({
|
|
1663
|
+
type: "incomplete",
|
|
1664
|
+
reason: "cancelled",
|
|
1665
|
+
});
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
it("stores a late successful tool result in its original message without sending", async () => {
|
|
1669
|
+
const { chat, part, toolPart, sendMessages, sendAutomaticallyWhen } =
|
|
1670
|
+
await setup(() => async () => {}, {
|
|
1671
|
+
messages: [
|
|
1672
|
+
{
|
|
1673
|
+
id: "user-1",
|
|
1674
|
+
role: "user",
|
|
1675
|
+
parts: [{ type: "text", text: "deploy" }],
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
id: "assistant-1",
|
|
1679
|
+
role: "assistant",
|
|
1680
|
+
parts: [
|
|
1681
|
+
{
|
|
1682
|
+
type: "tool-deploy",
|
|
1683
|
+
toolCallId: "tool-1",
|
|
1684
|
+
state: "input-available",
|
|
1685
|
+
input: {},
|
|
1686
|
+
},
|
|
1687
|
+
],
|
|
1688
|
+
},
|
|
1689
|
+
{
|
|
1690
|
+
id: "user-2",
|
|
1691
|
+
role: "user",
|
|
1692
|
+
parts: [{ type: "text", text: "later" }],
|
|
1693
|
+
},
|
|
1694
|
+
],
|
|
1695
|
+
});
|
|
1696
|
+
const automaticSendChecks = sendAutomaticallyWhen.mock.calls.length;
|
|
1697
|
+
|
|
1698
|
+
act(() => part().addToolResult("deployed"));
|
|
1699
|
+
|
|
1700
|
+
await waitFor(() =>
|
|
1701
|
+
expect(toolPart()).toMatchObject({
|
|
1702
|
+
state: "output-available",
|
|
1703
|
+
output: "deployed",
|
|
1704
|
+
}),
|
|
1705
|
+
);
|
|
1706
|
+
expect(chat().messages[2]).toMatchObject({
|
|
1707
|
+
id: "user-2",
|
|
1708
|
+
parts: [{ type: "text", text: "later" }],
|
|
1709
|
+
});
|
|
1710
|
+
expect(sendAutomaticallyWhen).toHaveBeenCalledTimes(automaticSendChecks);
|
|
1711
|
+
expect(sendMessages).not.toHaveBeenCalled();
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
it("clears a preliminary marker when a late final tool result is stored", async () => {
|
|
1715
|
+
const { part, toolPart, sendMessages, sendAutomaticallyWhen } = await setup(
|
|
1716
|
+
() => async () => {},
|
|
1717
|
+
{
|
|
1718
|
+
messages: [
|
|
1719
|
+
{
|
|
1720
|
+
id: "user-1",
|
|
1721
|
+
role: "user",
|
|
1722
|
+
parts: [{ type: "text", text: "deploy" }],
|
|
1723
|
+
},
|
|
1724
|
+
{
|
|
1725
|
+
id: "assistant-1",
|
|
1726
|
+
role: "assistant",
|
|
1727
|
+
parts: [
|
|
1728
|
+
{
|
|
1729
|
+
type: "tool-deploy",
|
|
1730
|
+
toolCallId: "tool-1",
|
|
1731
|
+
state: "output-available",
|
|
1732
|
+
input: {},
|
|
1733
|
+
output: "preview",
|
|
1734
|
+
preliminary: true,
|
|
1735
|
+
},
|
|
1736
|
+
],
|
|
1737
|
+
},
|
|
1738
|
+
{
|
|
1739
|
+
id: "user-2",
|
|
1740
|
+
role: "user",
|
|
1741
|
+
parts: [{ type: "text", text: "later" }],
|
|
1742
|
+
},
|
|
1743
|
+
],
|
|
1744
|
+
},
|
|
1745
|
+
);
|
|
1746
|
+
const automaticSendChecks = sendAutomaticallyWhen.mock.calls.length;
|
|
1747
|
+
|
|
1748
|
+
act(() => part().addToolResult("deployed"));
|
|
1749
|
+
|
|
1750
|
+
await waitFor(() =>
|
|
1751
|
+
expect(toolPart()).toMatchObject({
|
|
1752
|
+
state: "output-available",
|
|
1753
|
+
output: "deployed",
|
|
1754
|
+
}),
|
|
1755
|
+
);
|
|
1756
|
+
expect(toolPart()).not.toHaveProperty("preliminary");
|
|
1757
|
+
expect(part().getState()).not.toHaveProperty("isPreliminary");
|
|
1758
|
+
expect(sendAutomaticallyWhen).toHaveBeenCalledTimes(automaticSendChecks);
|
|
1759
|
+
expect(sendMessages).not.toHaveBeenCalled();
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
it("stores a late tool result in the correct raw message when assistant messages are joined", async () => {
|
|
1763
|
+
const { chat, part, sendMessages, sendAutomaticallyWhen } = await setup(
|
|
1764
|
+
() => async () => {},
|
|
1765
|
+
{
|
|
1766
|
+
messages: [
|
|
1767
|
+
{
|
|
1768
|
+
id: "user-1",
|
|
1769
|
+
role: "user",
|
|
1770
|
+
parts: [{ type: "text", text: "deploy" }],
|
|
1771
|
+
},
|
|
1772
|
+
{
|
|
1773
|
+
id: "assistant-1",
|
|
1774
|
+
role: "assistant",
|
|
1775
|
+
parts: [{ type: "text", text: "Preparing." }],
|
|
1776
|
+
},
|
|
1777
|
+
{
|
|
1778
|
+
id: "assistant-2",
|
|
1779
|
+
role: "assistant",
|
|
1780
|
+
parts: [
|
|
1781
|
+
{
|
|
1782
|
+
type: "tool-deploy",
|
|
1783
|
+
toolCallId: "tool-1",
|
|
1784
|
+
state: "input-available",
|
|
1785
|
+
input: {},
|
|
1786
|
+
},
|
|
1787
|
+
],
|
|
1788
|
+
},
|
|
1789
|
+
{
|
|
1790
|
+
id: "user-2",
|
|
1791
|
+
role: "user",
|
|
1792
|
+
parts: [{ type: "text", text: "later" }],
|
|
1793
|
+
},
|
|
1794
|
+
],
|
|
1795
|
+
},
|
|
1796
|
+
);
|
|
1797
|
+
const automaticSendChecks = sendAutomaticallyWhen.mock.calls.length;
|
|
1798
|
+
|
|
1799
|
+
act(() => part().addToolResult("deployed"));
|
|
1800
|
+
|
|
1801
|
+
await waitFor(() =>
|
|
1802
|
+
expect(
|
|
1803
|
+
chat()
|
|
1804
|
+
.messages.find((message) => message.id === "assistant-2")
|
|
1805
|
+
?.parts.find(
|
|
1806
|
+
(candidate) =>
|
|
1807
|
+
candidate.type === "tool-deploy" &&
|
|
1808
|
+
candidate.toolCallId === "tool-1",
|
|
1809
|
+
),
|
|
1810
|
+
).toMatchObject({
|
|
1811
|
+
state: "output-available",
|
|
1812
|
+
output: "deployed",
|
|
1813
|
+
}),
|
|
1814
|
+
);
|
|
1815
|
+
expect(sendAutomaticallyWhen).toHaveBeenCalledTimes(automaticSendChecks);
|
|
1816
|
+
expect(sendMessages).not.toHaveBeenCalled();
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
it("stores a late failed tool result in its original message without sending", async () => {
|
|
1820
|
+
const { part, toolPart, sendMessages, sendAutomaticallyWhen } = await setup(
|
|
1821
|
+
() => async () => {},
|
|
1822
|
+
{
|
|
1823
|
+
messages: [
|
|
1824
|
+
{
|
|
1825
|
+
id: "user-1",
|
|
1826
|
+
role: "user",
|
|
1827
|
+
parts: [{ type: "text", text: "deploy" }],
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
id: "assistant-1",
|
|
1831
|
+
role: "assistant",
|
|
1832
|
+
parts: [
|
|
1833
|
+
{
|
|
1834
|
+
type: "tool-deploy",
|
|
1835
|
+
toolCallId: "tool-1",
|
|
1836
|
+
state: "input-available",
|
|
1837
|
+
input: {},
|
|
1838
|
+
},
|
|
1839
|
+
],
|
|
1840
|
+
},
|
|
1841
|
+
{
|
|
1842
|
+
id: "user-2",
|
|
1843
|
+
role: "user",
|
|
1844
|
+
parts: [{ type: "text", text: "later" }],
|
|
1845
|
+
},
|
|
1846
|
+
],
|
|
1847
|
+
},
|
|
1848
|
+
);
|
|
1849
|
+
const automaticSendChecks = sendAutomaticallyWhen.mock.calls.length;
|
|
1850
|
+
|
|
1851
|
+
act(() =>
|
|
1852
|
+
part().addToolResult(
|
|
1853
|
+
new ToolResponse({ result: "deploy failed", isError: true }),
|
|
1854
|
+
),
|
|
1855
|
+
);
|
|
1856
|
+
|
|
1857
|
+
await waitFor(() =>
|
|
1858
|
+
expect(toolPart()).toMatchObject({
|
|
1859
|
+
state: "output-error",
|
|
1860
|
+
errorText: "deploy failed",
|
|
1861
|
+
}),
|
|
1862
|
+
);
|
|
1863
|
+
expect(sendAutomaticallyWhen).toHaveBeenCalledTimes(automaticSendChecks);
|
|
1864
|
+
expect(sendMessages).not.toHaveBeenCalled();
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
it("keeps a settled tool result in an earlier message when a late result arrives", async () => {
|
|
1868
|
+
const cancelled = "User cancelled tool call by sending a new message.";
|
|
1869
|
+
const { part, toolPart, sendMessages } = await setup(() => async () => {}, {
|
|
1870
|
+
messages: [
|
|
1871
|
+
{
|
|
1872
|
+
id: "user-1",
|
|
1873
|
+
role: "user",
|
|
1874
|
+
parts: [{ type: "text", text: "deploy" }],
|
|
1875
|
+
},
|
|
1876
|
+
{
|
|
1877
|
+
id: "assistant-1",
|
|
1878
|
+
role: "assistant",
|
|
1879
|
+
parts: [
|
|
1880
|
+
{
|
|
1881
|
+
type: "tool-deploy",
|
|
1882
|
+
toolCallId: "tool-1",
|
|
1883
|
+
state: "output-error",
|
|
1884
|
+
input: {},
|
|
1885
|
+
errorText: cancelled,
|
|
1886
|
+
},
|
|
1887
|
+
],
|
|
1888
|
+
},
|
|
1889
|
+
{
|
|
1890
|
+
id: "user-2",
|
|
1891
|
+
role: "user",
|
|
1892
|
+
parts: [{ type: "text", text: "later" }],
|
|
1893
|
+
},
|
|
1894
|
+
],
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
await act(async () => {
|
|
1898
|
+
await part().addToolResult("deployed");
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
expect(toolPart()).toMatchObject({
|
|
1902
|
+
state: "output-error",
|
|
1903
|
+
errorText: cancelled,
|
|
1904
|
+
});
|
|
285
1905
|
expect(sendMessages).not.toHaveBeenCalled();
|
|
286
1906
|
});
|
|
287
1907
|
});
|