@convex-dev/agent 0.6.0-alpha.0 → 0.6.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +153 -0
- package/dist/UIMessages.d.ts.map +1 -1
- package/dist/UIMessages.js +88 -0
- package/dist/UIMessages.js.map +1 -1
- package/dist/client/createTool.d.ts +18 -21
- package/dist/client/createTool.d.ts.map +1 -1
- package/dist/client/createTool.js +3 -2
- package/dist/client/createTool.js.map +1 -1
- package/dist/client/definePlaygroundAPI.d.ts +31 -31
- package/dist/client/index.d.ts +70 -23
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +126 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/messages.d.ts +9 -9
- package/dist/client/mockModel.d.ts.map +1 -1
- package/dist/client/mockModel.js +9 -2
- package/dist/client/mockModel.js.map +1 -1
- package/dist/client/search.d.ts +9 -9
- package/dist/client/search.d.ts.map +1 -1
- package/dist/client/search.js +12 -2
- package/dist/client/search.js.map +1 -1
- package/dist/client/start.d.ts +1 -1
- package/dist/client/start.d.ts.map +1 -1
- package/dist/client/start.js +29 -15
- package/dist/client/start.js.map +1 -1
- package/dist/client/streamText.d.ts.map +1 -1
- package/dist/client/streamText.js +37 -3
- package/dist/client/streamText.js.map +1 -1
- package/dist/client/streaming.d.ts +78 -67
- package/dist/client/streaming.d.ts.map +1 -1
- package/dist/client/streaming.js +64 -28
- package/dist/client/streaming.js.map +1 -1
- package/dist/client/types.d.ts +13 -12
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +1 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/messages.d.ts +107 -106
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +13 -3
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +40 -40
- package/dist/component/streams.d.ts +4 -4
- package/dist/component/threads.d.ts +17 -17
- package/dist/component/users.d.ts +3 -3
- package/dist/component/vector/index.d.ts +1 -1
- package/dist/deltas.d.ts.map +1 -1
- package/dist/deltas.js +0 -1
- package/dist/deltas.js.map +1 -1
- package/dist/mapping.d.ts +22 -0
- package/dist/mapping.d.ts.map +1 -1
- package/dist/mapping.js +99 -5
- package/dist/mapping.js.map +1 -1
- package/dist/react/useDeltaStreams.d.ts.map +1 -1
- package/dist/react/useDeltaStreams.js +5 -0
- package/dist/react/useDeltaStreams.js.map +1 -1
- package/dist/validators.d.ts +13 -13
- package/package.json +4 -2
- package/src/UIMessages.ts +126 -0
- package/src/client/approval.test.ts +494 -0
- package/src/client/createTool.ts +50 -52
- package/src/client/index.ts +170 -1
- package/src/client/mockModel.ts +9 -2
- package/src/client/search.test.ts +4 -5
- package/src/client/search.ts +18 -2
- package/src/client/start.ts +42 -25
- package/src/client/streamText.ts +36 -3
- package/src/client/streaming.integration.test.ts +1206 -0
- package/src/client/streaming.ts +67 -31
- package/src/client/types.ts +14 -12
- package/src/component/_generated/component.ts +53 -64
- package/src/component/messages.ts +12 -2
- package/src/deltas.ts +0 -1
- package/src/mapping.test.ts +143 -1
- package/src/mapping.ts +131 -6
- package/src/react/useDeltaStreams.ts +6 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { Agent, createTool } from "./index.js";
|
|
3
|
+
import type {
|
|
4
|
+
DataModelFromSchemaDefinition,
|
|
5
|
+
ApiFromModules,
|
|
6
|
+
ActionBuilder,
|
|
7
|
+
MutationBuilder,
|
|
8
|
+
} from "convex/server";
|
|
9
|
+
import { anyApi, actionGeneric, mutationGeneric } from "convex/server";
|
|
10
|
+
import { v } from "convex/values";
|
|
11
|
+
import { defineSchema } from "convex/server";
|
|
12
|
+
import { stepCountIs, type LanguageModelUsage } from "ai";
|
|
13
|
+
import { components, initConvexTest } from "./setup.test.js";
|
|
14
|
+
import { z } from "zod/v4";
|
|
15
|
+
import { mockModel } from "./mockModel.js";
|
|
16
|
+
import type { UsageHandler } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const schema = defineSchema({});
|
|
19
|
+
type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
|
20
|
+
const action = actionGeneric as ActionBuilder<DataModel, "public">;
|
|
21
|
+
const mutation = mutationGeneric as MutationBuilder<DataModel, "public">;
|
|
22
|
+
|
|
23
|
+
// Tool that always requires approval
|
|
24
|
+
const deleteFileTool = createTool({
|
|
25
|
+
description: "Delete a file",
|
|
26
|
+
inputSchema: z.object({ filename: z.string() }),
|
|
27
|
+
needsApproval: () => true,
|
|
28
|
+
execute: async (_ctx, input) => `Deleted: ${input.filename}`,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Track usage handler calls to verify the full flow is exercised
|
|
32
|
+
const usageCalls: LanguageModelUsage[] = [];
|
|
33
|
+
const testUsageHandler: UsageHandler = async (_ctx, args) => {
|
|
34
|
+
usageCalls.push(args.usage);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function getApprovalIdFromSavedMessages(
|
|
38
|
+
savedMessages:
|
|
39
|
+
| Array<{
|
|
40
|
+
message?: { content: unknown };
|
|
41
|
+
}>
|
|
42
|
+
| undefined,
|
|
43
|
+
): string {
|
|
44
|
+
const approvalRequest = savedMessages
|
|
45
|
+
?.flatMap((savedMessage) =>
|
|
46
|
+
Array.isArray(savedMessage.message?.content)
|
|
47
|
+
? savedMessage.message.content
|
|
48
|
+
: [],
|
|
49
|
+
)
|
|
50
|
+
.find((part) => {
|
|
51
|
+
const maybeApproval = part as { type?: unknown };
|
|
52
|
+
return maybeApproval.type === "tool-approval-request";
|
|
53
|
+
}) as { approvalId?: unknown } | undefined;
|
|
54
|
+
if (typeof approvalRequest?.approvalId !== "string") {
|
|
55
|
+
throw new Error("No approval request found in saved messages");
|
|
56
|
+
}
|
|
57
|
+
return approvalRequest.approvalId;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Second tool that also requires approval
|
|
61
|
+
const renameFileTool = createTool({
|
|
62
|
+
description: "Rename a file",
|
|
63
|
+
inputSchema: z.object({
|
|
64
|
+
oldName: z.string(),
|
|
65
|
+
newName: z.string(),
|
|
66
|
+
}),
|
|
67
|
+
needsApproval: () => true,
|
|
68
|
+
execute: async (_ctx, input) => `Renamed: ${input.oldName} → ${input.newName}`,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// --- Agents (separate mock model instances to avoid shared callIndex) ---
|
|
72
|
+
|
|
73
|
+
const approvalAgent = new Agent(components.agent, {
|
|
74
|
+
name: "approval-test",
|
|
75
|
+
instructions: "You delete files when asked.",
|
|
76
|
+
tools: { deleteFile: deleteFileTool },
|
|
77
|
+
languageModel: mockModel({
|
|
78
|
+
contentSteps: [
|
|
79
|
+
// Step 1: model makes a tool call (LanguageModelV3 uses `input` as JSON string)
|
|
80
|
+
[
|
|
81
|
+
{
|
|
82
|
+
type: "tool-call",
|
|
83
|
+
toolCallId: "tc-approve",
|
|
84
|
+
toolName: "deleteFile",
|
|
85
|
+
input: JSON.stringify({ filename: "test.txt" }),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
// Step 2: after tool execution, model responds with text
|
|
89
|
+
[{ type: "text", text: "Done! I deleted test.txt." }],
|
|
90
|
+
],
|
|
91
|
+
}),
|
|
92
|
+
stopWhen: stepCountIs(5),
|
|
93
|
+
usageHandler: testUsageHandler,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const denialAgent = new Agent(components.agent, {
|
|
97
|
+
name: "denial-test",
|
|
98
|
+
instructions: "You delete files when asked.",
|
|
99
|
+
tools: { deleteFile: deleteFileTool },
|
|
100
|
+
languageModel: mockModel({
|
|
101
|
+
contentSteps: [
|
|
102
|
+
[
|
|
103
|
+
{
|
|
104
|
+
type: "tool-call",
|
|
105
|
+
toolCallId: "tc-deny",
|
|
106
|
+
toolName: "deleteFile",
|
|
107
|
+
input: JSON.stringify({ filename: "secret.txt" }),
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
[{ type: "text", text: "OK, I won't delete that file." }],
|
|
111
|
+
],
|
|
112
|
+
}),
|
|
113
|
+
stopWhen: stepCountIs(5),
|
|
114
|
+
usageHandler: testUsageHandler,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// --- Test helpers ---
|
|
118
|
+
|
|
119
|
+
export const testApproveFlow = action({
|
|
120
|
+
args: {},
|
|
121
|
+
handler: async (ctx) => {
|
|
122
|
+
const { thread } = await approvalAgent.createThread(ctx, { userId: "u1" });
|
|
123
|
+
|
|
124
|
+
// Step 1: Generate text — model returns tool call, SDK sees needsApproval → stops
|
|
125
|
+
const result1 = await thread.generateText({
|
|
126
|
+
prompt: "Delete test.txt",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const approvalId = getApprovalIdFromSavedMessages(result1.savedMessages);
|
|
130
|
+
|
|
131
|
+
// Step 2: Approve the tool call
|
|
132
|
+
const { messageId } = await ctx.runMutation(
|
|
133
|
+
anyApi["approval.test"].submitApprovalForApprovalAgent,
|
|
134
|
+
{ threadId: thread.threadId, approvalId },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Step 3: Continue generation — SDK executes tool, model responds
|
|
138
|
+
const result2 = await thread.generateText({
|
|
139
|
+
promptMessageId: messageId,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Verify thread has all messages persisted
|
|
143
|
+
const allMessages = await approvalAgent.listMessages(ctx, {
|
|
144
|
+
threadId: thread.threadId,
|
|
145
|
+
paginationOpts: { cursor: null, numItems: 20 },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
approvalId,
|
|
150
|
+
firstText: result1.text,
|
|
151
|
+
secondText: result2.text,
|
|
152
|
+
firstSavedCount: result1.savedMessages?.length ?? 0,
|
|
153
|
+
secondSavedCount: result2.savedMessages?.length ?? 0,
|
|
154
|
+
totalThreadMessages: allMessages.page.length,
|
|
155
|
+
threadMessageRoles: allMessages.page.map((m) => m.message?.role),
|
|
156
|
+
usageCallCount: usageCalls.length,
|
|
157
|
+
// Verify usage data includes detail fields (AI SDK v6)
|
|
158
|
+
lastUsage: usageCalls.at(-1),
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const testDenyFlow = action({
|
|
164
|
+
args: {},
|
|
165
|
+
handler: async (ctx) => {
|
|
166
|
+
const { thread } = await denialAgent.createThread(ctx, { userId: "u2" });
|
|
167
|
+
|
|
168
|
+
// Step 1: Generate — model returns tool call, approval requested
|
|
169
|
+
const result1 = await thread.generateText({
|
|
170
|
+
prompt: "Delete secret.txt",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const approvalId = getApprovalIdFromSavedMessages(result1.savedMessages);
|
|
174
|
+
|
|
175
|
+
// Step 2: Deny the tool call
|
|
176
|
+
const { messageId } = await ctx.runMutation(
|
|
177
|
+
anyApi["approval.test"].submitDenialForDenialAgent,
|
|
178
|
+
{
|
|
179
|
+
threadId: thread.threadId,
|
|
180
|
+
approvalId,
|
|
181
|
+
reason: "This file is important",
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Step 3: Continue generation — SDK creates execution-denied, model responds
|
|
186
|
+
const result2 = await thread.generateText({
|
|
187
|
+
promptMessageId: messageId,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Verify thread state
|
|
191
|
+
const allMessages = await denialAgent.listMessages(ctx, {
|
|
192
|
+
threadId: thread.threadId,
|
|
193
|
+
paginationOpts: { cursor: null, numItems: 20 },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
approvalId,
|
|
198
|
+
firstText: result1.text,
|
|
199
|
+
secondText: result2.text,
|
|
200
|
+
totalThreadMessages: allMessages.page.length,
|
|
201
|
+
threadMessageRoles: allMessages.page.map((m) => m.message?.role),
|
|
202
|
+
usageCallCount: usageCalls.length,
|
|
203
|
+
lastUsage: usageCalls.at(-1),
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
export const testApproveFlowWithInterveningMessage = action({
|
|
209
|
+
args: {},
|
|
210
|
+
handler: async (ctx) => {
|
|
211
|
+
const { thread } = await approvalAgent.createThread(ctx, { userId: "u3" });
|
|
212
|
+
|
|
213
|
+
const result1 = await thread.generateText({
|
|
214
|
+
prompt: "Delete test.txt",
|
|
215
|
+
});
|
|
216
|
+
const approvalId = getApprovalIdFromSavedMessages(result1.savedMessages);
|
|
217
|
+
|
|
218
|
+
const approvalRequest = (
|
|
219
|
+
await approvalAgent.listMessages(ctx, {
|
|
220
|
+
threadId: thread.threadId,
|
|
221
|
+
paginationOpts: { cursor: null, numItems: 20 },
|
|
222
|
+
})
|
|
223
|
+
).page.find((m) => {
|
|
224
|
+
const content = m.message?.content;
|
|
225
|
+
return (
|
|
226
|
+
Array.isArray(content) &&
|
|
227
|
+
content.some(
|
|
228
|
+
(p) =>
|
|
229
|
+
p.type === "tool-approval-request" && p.approvalId === approvalId,
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
if (!approvalRequest) {
|
|
234
|
+
throw new Error("Approval request message not found");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const intervening = await approvalAgent.saveMessage(ctx, {
|
|
238
|
+
threadId: thread.threadId,
|
|
239
|
+
prompt: "Intervening user message",
|
|
240
|
+
skipEmbeddings: true,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const { messageId } = await ctx.runMutation(
|
|
244
|
+
anyApi["approval.test"].submitApprovalForApprovalAgent,
|
|
245
|
+
{ threadId: thread.threadId, approvalId },
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const result2 = await thread.generateText({
|
|
249
|
+
promptMessageId: messageId,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const allMessages = await approvalAgent.listMessages(ctx, {
|
|
253
|
+
threadId: thread.threadId,
|
|
254
|
+
paginationOpts: { cursor: null, numItems: 40 },
|
|
255
|
+
});
|
|
256
|
+
const approvalResponse = allMessages.page.find((m) => m._id === messageId);
|
|
257
|
+
if (!approvalResponse) {
|
|
258
|
+
throw new Error("Saved approval response message not found");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
secondText: result2.text,
|
|
263
|
+
approvalResponseOrder: approvalResponse.order,
|
|
264
|
+
approvalRequestId: approvalRequest._id,
|
|
265
|
+
approvalRequestOrder: approvalRequest.order,
|
|
266
|
+
interveningOrder: intervening.message.order,
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Agent that calls two tools in one step, both needing approval
|
|
272
|
+
const multiToolAgent = new Agent(components.agent, {
|
|
273
|
+
name: "multi-tool-test",
|
|
274
|
+
instructions: "You manage files.",
|
|
275
|
+
tools: { deleteFile: deleteFileTool, renameFile: renameFileTool },
|
|
276
|
+
languageModel: mockModel({
|
|
277
|
+
contentSteps: [
|
|
278
|
+
// Step 1: model calls two tools at once
|
|
279
|
+
[
|
|
280
|
+
{
|
|
281
|
+
type: "tool-call",
|
|
282
|
+
toolCallId: "tc-multi-1",
|
|
283
|
+
toolName: "deleteFile",
|
|
284
|
+
input: JSON.stringify({ filename: "old.txt" }),
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
type: "tool-call",
|
|
288
|
+
toolCallId: "tc-multi-2",
|
|
289
|
+
toolName: "renameFile",
|
|
290
|
+
input: JSON.stringify({ oldName: "a.txt", newName: "b.txt" }),
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
// Step 2: after both tools execute, model responds
|
|
294
|
+
[{ type: "text", text: "Done! Deleted old.txt and renamed a.txt to b.txt." }],
|
|
295
|
+
],
|
|
296
|
+
}),
|
|
297
|
+
stopWhen: stepCountIs(5),
|
|
298
|
+
usageHandler: testUsageHandler,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
export const testMultiToolApproveFlow = action({
|
|
302
|
+
args: {},
|
|
303
|
+
handler: async (ctx) => {
|
|
304
|
+
const { thread } = await multiToolAgent.createThread(ctx, {
|
|
305
|
+
userId: "u-multi",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Step 1: Generate — model calls two tools, both need approval
|
|
309
|
+
const result1 = await thread.generateText({
|
|
310
|
+
prompt: "Delete old.txt and rename a.txt to b.txt",
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Extract both approval IDs
|
|
314
|
+
const approvalParts = result1.savedMessages
|
|
315
|
+
?.flatMap((m) =>
|
|
316
|
+
Array.isArray(m.message?.content)
|
|
317
|
+
? (m.message.content as unknown[])
|
|
318
|
+
: [],
|
|
319
|
+
)
|
|
320
|
+
.filter(
|
|
321
|
+
(
|
|
322
|
+
p,
|
|
323
|
+
): p is {
|
|
324
|
+
type: "tool-approval-request";
|
|
325
|
+
approvalId: string;
|
|
326
|
+
toolCallId: string;
|
|
327
|
+
} => (p as { type?: string }).type === "tool-approval-request",
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
if (!approvalParts || approvalParts.length !== 2) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`Expected 2 approval requests, got ${approvalParts?.length ?? 0}`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Approve both tool calls
|
|
337
|
+
const { messageId: _msgId1 } = await ctx.runMutation(
|
|
338
|
+
anyApi["approval.test"].submitApprovalForMultiToolAgent,
|
|
339
|
+
{ threadId: thread.threadId, approvalId: approvalParts[0].approvalId },
|
|
340
|
+
);
|
|
341
|
+
const { messageId: msgId2 } = await ctx.runMutation(
|
|
342
|
+
anyApi["approval.test"].submitApprovalForMultiToolAgent,
|
|
343
|
+
{ threadId: thread.threadId, approvalId: approvalParts[1].approvalId },
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// Continue generation with the last approval message
|
|
347
|
+
const result2 = await thread.generateText({
|
|
348
|
+
promptMessageId: msgId2,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const allMessages = await multiToolAgent.listMessages(ctx, {
|
|
352
|
+
threadId: thread.threadId,
|
|
353
|
+
paginationOpts: { cursor: null, numItems: 40 },
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
approvalCount: approvalParts.length,
|
|
358
|
+
firstText: result1.text,
|
|
359
|
+
secondText: result2.text,
|
|
360
|
+
threadMessageRoles: allMessages.page.map((m) => m.message?.role),
|
|
361
|
+
// Check that both approvals were merged into one tool message
|
|
362
|
+
toolMessageCount: allMessages.page.filter(
|
|
363
|
+
(m) => m.message?.role === "tool",
|
|
364
|
+
).length,
|
|
365
|
+
};
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
export const submitApprovalForMultiToolAgent = mutation({
|
|
370
|
+
args: {
|
|
371
|
+
threadId: v.string(),
|
|
372
|
+
approvalId: v.string(),
|
|
373
|
+
},
|
|
374
|
+
handler: async (ctx, { threadId, approvalId }) => {
|
|
375
|
+
return multiToolAgent.approveToolCall(ctx, { threadId, approvalId });
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
export const submitApprovalForApprovalAgent = mutation({
|
|
380
|
+
args: { threadId: v.string(), approvalId: v.string(), reason: v.optional(v.string()) },
|
|
381
|
+
handler: async (ctx, { threadId, approvalId, reason }) => {
|
|
382
|
+
return approvalAgent.approveToolCall(ctx, { threadId, approvalId, reason });
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
export const submitDenialForDenialAgent = mutation({
|
|
387
|
+
args: { threadId: v.string(), approvalId: v.string(), reason: v.optional(v.string()) },
|
|
388
|
+
handler: async (ctx, { threadId, approvalId, reason }) => {
|
|
389
|
+
return denialAgent.denyToolCall(ctx, { threadId, approvalId, reason });
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const testApi: ApiFromModules<{
|
|
394
|
+
fns: {
|
|
395
|
+
testApproveFlow: typeof testApproveFlow;
|
|
396
|
+
testDenyFlow: typeof testDenyFlow;
|
|
397
|
+
testApproveFlowWithInterveningMessage: typeof testApproveFlowWithInterveningMessage;
|
|
398
|
+
testMultiToolApproveFlow: typeof testMultiToolApproveFlow;
|
|
399
|
+
submitApprovalForApprovalAgent: typeof submitApprovalForApprovalAgent;
|
|
400
|
+
submitApprovalForMultiToolAgent: typeof submitApprovalForMultiToolAgent;
|
|
401
|
+
submitDenialForDenialAgent: typeof submitDenialForDenialAgent;
|
|
402
|
+
};
|
|
403
|
+
}>["fns"] = anyApi["approval.test"] as any;
|
|
404
|
+
|
|
405
|
+
describe("Tool Approval Workflow", () => {
|
|
406
|
+
test("approve: generate → approval request → approve → tool executes → final text", async () => {
|
|
407
|
+
usageCalls.length = 0;
|
|
408
|
+
const t = initConvexTest(schema);
|
|
409
|
+
const result = await t.action(testApi.testApproveFlow, {});
|
|
410
|
+
|
|
411
|
+
expect(result.approvalId).toBeDefined();
|
|
412
|
+
// First call produces no text (just a tool call)
|
|
413
|
+
expect(result.firstText).toBe("");
|
|
414
|
+
// Second call produces the final text
|
|
415
|
+
expect(result.secondText).toBe("Done! I deleted test.txt.");
|
|
416
|
+
// First call: user message + assistant (tool-call + approval-request)
|
|
417
|
+
expect(result.firstSavedCount).toBeGreaterThanOrEqual(2);
|
|
418
|
+
// Second call: tool-result + assistant text
|
|
419
|
+
expect(result.secondSavedCount).toBeGreaterThanOrEqual(1);
|
|
420
|
+
// Thread should have (ascending): user, assistant(tool-call+approval),
|
|
421
|
+
// tool(approval-response), tool(tool-result), assistant(text)
|
|
422
|
+
// listMessages returns descending order:
|
|
423
|
+
expect(result.threadMessageRoles).toEqual([
|
|
424
|
+
"assistant", // final text
|
|
425
|
+
"tool", // tool-result
|
|
426
|
+
"tool", // approval-response
|
|
427
|
+
"assistant", // tool-call + approval-request
|
|
428
|
+
"user", // prompt
|
|
429
|
+
]);
|
|
430
|
+
// Usage handler should be called for each generateText call
|
|
431
|
+
expect(result.usageCallCount).toBeGreaterThanOrEqual(2);
|
|
432
|
+
// Usage data should include AI SDK v6 detail fields
|
|
433
|
+
expect(result.lastUsage).toBeDefined();
|
|
434
|
+
expect(result.lastUsage!.inputTokenDetails).toBeDefined();
|
|
435
|
+
expect(result.lastUsage!.outputTokenDetails).toBeDefined();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("deny: generate → approval request → deny → model acknowledges denial", async () => {
|
|
439
|
+
usageCalls.length = 0;
|
|
440
|
+
const t = initConvexTest(schema);
|
|
441
|
+
const result = await t.action(testApi.testDenyFlow, {});
|
|
442
|
+
|
|
443
|
+
expect(result.approvalId).toBeDefined();
|
|
444
|
+
expect(result.firstText).toBe("");
|
|
445
|
+
expect(result.secondText).toBe("OK, I won't delete that file.");
|
|
446
|
+
// Same message ordering as approve flow:
|
|
447
|
+
// user, assistant(tool-call+approval), tool(denial-response),
|
|
448
|
+
// tool(execution-denied result), assistant(text)
|
|
449
|
+
expect(result.threadMessageRoles).toEqual([
|
|
450
|
+
"assistant",
|
|
451
|
+
"tool",
|
|
452
|
+
"tool",
|
|
453
|
+
"assistant",
|
|
454
|
+
"user",
|
|
455
|
+
]);
|
|
456
|
+
// Usage handler exercised
|
|
457
|
+
expect(result.usageCallCount).toBeGreaterThanOrEqual(2);
|
|
458
|
+
expect(result.lastUsage!.inputTokenDetails).toBeDefined();
|
|
459
|
+
expect(result.lastUsage!.outputTokenDetails).toBeDefined();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("multi-tool: approve two tool calls from the same step", async () => {
|
|
463
|
+
usageCalls.length = 0;
|
|
464
|
+
const t = initConvexTest(schema);
|
|
465
|
+
const result = await t.action(testApi.testMultiToolApproveFlow, {});
|
|
466
|
+
|
|
467
|
+
expect(result.approvalCount).toBe(2);
|
|
468
|
+
expect(result.firstText).toBe("");
|
|
469
|
+
expect(result.secondText).toBe(
|
|
470
|
+
"Done! Deleted old.txt and renamed a.txt to b.txt.",
|
|
471
|
+
);
|
|
472
|
+
// Both approval responses should be merged into one tool message
|
|
473
|
+
// (write-time merge in respondToToolCallApproval via findApprovalContext)
|
|
474
|
+
// Thread: user, assistant(2 tool-calls + 2 approvals),
|
|
475
|
+
// tool(2 approval-responses merged), tool(2 tool-results), assistant(text)
|
|
476
|
+
expect(result.threadMessageRoles).toEqual([
|
|
477
|
+
"assistant", // final text
|
|
478
|
+
"tool", // tool-results
|
|
479
|
+
"tool", // approval-responses (merged)
|
|
480
|
+
"assistant", // tool-calls + approval-requests
|
|
481
|
+
"user", // prompt
|
|
482
|
+
]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("approve remains valid with an intervening thread message", async () => {
|
|
486
|
+
usageCalls.length = 0;
|
|
487
|
+
const t = initConvexTest(schema);
|
|
488
|
+
const result = await t.action(testApi.testApproveFlowWithInterveningMessage, {});
|
|
489
|
+
|
|
490
|
+
expect(result.secondText).toBe("Done! I deleted test.txt.");
|
|
491
|
+
expect(result.approvalResponseOrder).toBe(result.approvalRequestOrder);
|
|
492
|
+
expect(result.interveningOrder).toBeGreaterThan(result.approvalResponseOrder);
|
|
493
|
+
});
|
|
494
|
+
});
|
package/src/client/createTool.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { GenericActionCtx, GenericDataModel } from "convex/server";
|
|
|
11
11
|
import type { ProviderOptions } from "../validators.js";
|
|
12
12
|
import type { Agent } from "./index.js";
|
|
13
13
|
|
|
14
|
-
const MIGRATION_URL = "
|
|
14
|
+
const MIGRATION_URL = "node_modules/@convex-dev/agent/MIGRATION.md";
|
|
15
15
|
const warnedDeprecations = new Set<string>();
|
|
16
16
|
function warnDeprecation(key: string, message: string) {
|
|
17
17
|
if (!warnedDeprecations.has(key)) {
|
|
@@ -72,60 +72,57 @@ type NeverOptional<N, T> = 0 extends 1 & N
|
|
|
72
72
|
? Partial<Record<keyof T, undefined>>
|
|
73
73
|
: T;
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Error message type for deprecated 'handler' property.
|
|
77
|
+
* Using a string literal type causes TypeScript to show this message in errors.
|
|
78
|
+
*/
|
|
79
|
+
type HANDLER_REMOVED_ERROR =
|
|
80
|
+
"⚠️ 'handler' was removed in @convex-dev/agent v0.6.0. Rename to 'execute'. See: node_modules/@convex-dev/agent/MIGRATION.md";
|
|
81
|
+
|
|
75
82
|
export type ToolOutputPropertiesCtx<
|
|
76
83
|
INPUT,
|
|
77
84
|
OUTPUT,
|
|
78
85
|
Ctx extends ToolCtx = ToolCtx,
|
|
79
86
|
> = NeverOptional<
|
|
80
87
|
OUTPUT,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
outputSchema?: FlexibleSchema<OUTPUT>;
|
|
97
|
-
execute?: never;
|
|
98
|
-
}
|
|
99
|
-
| {
|
|
100
|
-
outputSchema: FlexibleSchema<OUTPUT>;
|
|
101
|
-
execute?: never;
|
|
102
|
-
handler?: never;
|
|
103
|
-
}
|
|
88
|
+
{
|
|
89
|
+
/**
|
|
90
|
+
* An async function that is called with the arguments from the tool call and produces a result.
|
|
91
|
+
* If `execute` is not provided, the tool will not be executed automatically.
|
|
92
|
+
*
|
|
93
|
+
* @param input - The input of the tool call.
|
|
94
|
+
* @param options.abortSignal - A signal that can be used to abort the tool call.
|
|
95
|
+
*/
|
|
96
|
+
execute?: ToolExecuteFunctionCtx<INPUT, OUTPUT, Ctx>;
|
|
97
|
+
outputSchema?: FlexibleSchema<OUTPUT>;
|
|
98
|
+
/**
|
|
99
|
+
* @deprecated Removed in v0.6.0. Use `execute` instead.
|
|
100
|
+
*/
|
|
101
|
+
handler?: HANDLER_REMOVED_ERROR;
|
|
102
|
+
}
|
|
104
103
|
>;
|
|
105
104
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
inputSchema?: never;
|
|
128
|
-
};
|
|
105
|
+
/**
|
|
106
|
+
* Error message type for deprecated 'args' property.
|
|
107
|
+
* Using a string literal type causes TypeScript to show this message in errors.
|
|
108
|
+
*/
|
|
109
|
+
type ARGS_REMOVED_ERROR =
|
|
110
|
+
"⚠️ 'args' was removed in @convex-dev/agent v0.6.0. Rename to 'inputSchema'. See: node_modules/@convex-dev/agent/MIGRATION.md";
|
|
111
|
+
|
|
112
|
+
export type ToolInputProperties<INPUT> = {
|
|
113
|
+
/**
|
|
114
|
+
* The schema of the input that the tool expects.
|
|
115
|
+
* The language model will use this to generate the input.
|
|
116
|
+
* It is also used to validate the output of the language model.
|
|
117
|
+
*
|
|
118
|
+
* You can use descriptions on the schema properties to make the input understandable for the language model.
|
|
119
|
+
*/
|
|
120
|
+
inputSchema: FlexibleSchema<INPUT>;
|
|
121
|
+
/**
|
|
122
|
+
* @deprecated Removed in v0.6.0. Use `inputSchema` instead.
|
|
123
|
+
*/
|
|
124
|
+
args?: ARGS_REMOVED_ERROR;
|
|
125
|
+
};
|
|
129
126
|
|
|
130
127
|
/**
|
|
131
128
|
* This is a wrapper around the ai.tool function that adds extra context to the
|
|
@@ -238,24 +235,25 @@ export function createTool<INPUT, OUTPUT, Ctx extends ToolCtx = ToolCtx>(
|
|
|
238
235
|
) => ToolResultOutput | PromiseLike<ToolResultOutput>;
|
|
239
236
|
},
|
|
240
237
|
): Tool<INPUT, OUTPUT> {
|
|
241
|
-
|
|
238
|
+
// Runtime backwards compat - types will show errors but runtime still works
|
|
239
|
+
const inputSchema = def.inputSchema ?? (def as any).args;
|
|
242
240
|
if (!inputSchema)
|
|
243
|
-
throw new Error("To use a Convex tool, you must provide an `inputSchema`
|
|
241
|
+
throw new Error("To use a Convex tool, you must provide an `inputSchema`");
|
|
244
242
|
|
|
245
|
-
if (def.args && !def.inputSchema) {
|
|
243
|
+
if ((def as any).args && !def.inputSchema) {
|
|
246
244
|
warnDeprecation(
|
|
247
245
|
"createTool.args",
|
|
248
246
|
"createTool: 'args' is deprecated. Use 'inputSchema' instead.",
|
|
249
247
|
);
|
|
250
248
|
}
|
|
251
|
-
if (def.handler && !def.execute) {
|
|
249
|
+
if ((def as any).handler && !def.execute) {
|
|
252
250
|
warnDeprecation(
|
|
253
251
|
"createTool.handler",
|
|
254
252
|
"createTool: 'handler' is deprecated. Use 'execute' instead.",
|
|
255
253
|
);
|
|
256
254
|
}
|
|
257
255
|
|
|
258
|
-
const executeHandler = def.execute ?? def.handler;
|
|
256
|
+
const executeHandler = def.execute ?? (def as any).handler;
|
|
259
257
|
if (!executeHandler && !def.outputSchema)
|
|
260
258
|
throw new Error(
|
|
261
259
|
"To use a Convex tool, you must either provide an execute" +
|