@convex-dev/agent 0.6.0-alpha.1 → 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.
Files changed (44) hide show
  1. package/dist/UIMessages.d.ts.map +1 -1
  2. package/dist/UIMessages.js +88 -0
  3. package/dist/UIMessages.js.map +1 -1
  4. package/dist/client/definePlaygroundAPI.d.ts +17 -17
  5. package/dist/client/index.d.ts +44 -44
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +54 -20
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/client/messages.d.ts +3 -3
  10. package/dist/client/search.d.ts +3 -3
  11. package/dist/client/search.d.ts.map +1 -1
  12. package/dist/client/search.js +14 -4
  13. package/dist/client/search.js.map +1 -1
  14. package/dist/client/start.js +2 -2
  15. package/dist/client/start.js.map +1 -1
  16. package/dist/client/streamText.d.ts.map +1 -1
  17. package/dist/client/streamText.js +10 -0
  18. package/dist/client/streamText.js.map +1 -1
  19. package/dist/client/streaming.d.ts +47 -47
  20. package/dist/client/streaming.d.ts.map +1 -1
  21. package/dist/client/streaming.js +37 -21
  22. package/dist/client/streaming.js.map +1 -1
  23. package/dist/component/messages.d.ts +47 -47
  24. package/dist/component/schema.d.ts +40 -40
  25. package/dist/component/streams.d.ts +2 -2
  26. package/dist/component/threads.d.ts +6 -6
  27. package/dist/component/vector/index.d.ts +1 -1
  28. package/dist/mapping.d.ts +19 -15
  29. package/dist/mapping.d.ts.map +1 -1
  30. package/dist/mapping.js +90 -47
  31. package/dist/mapping.js.map +1 -1
  32. package/dist/validators.d.ts +13 -13
  33. package/package.json +1 -1
  34. package/src/UIMessages.ts +126 -0
  35. package/src/client/approval.test.ts +144 -0
  36. package/src/client/index.ts +73 -23
  37. package/src/client/search.test.ts +4 -5
  38. package/src/client/search.ts +16 -4
  39. package/src/client/start.ts +2 -2
  40. package/src/client/streamText.ts +9 -0
  41. package/src/client/streaming.integration.test.ts +1206 -0
  42. package/src/client/streaming.ts +35 -21
  43. package/src/mapping.test.ts +136 -71
  44. package/src/mapping.ts +119 -50
@@ -57,6 +57,17 @@ function getApprovalIdFromSavedMessages(
57
57
  return approvalRequest.approvalId;
58
58
  }
59
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
+
60
71
  // --- Agents (separate mock model instances to avoid shared callIndex) ---
61
72
 
62
73
  const approvalAgent = new Agent(components.agent, {
@@ -257,6 +268,114 @@ export const testApproveFlowWithInterveningMessage = action({
257
268
  },
258
269
  });
259
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
+
260
379
  export const submitApprovalForApprovalAgent = mutation({
261
380
  args: { threadId: v.string(), approvalId: v.string(), reason: v.optional(v.string()) },
262
381
  handler: async (ctx, { threadId, approvalId, reason }) => {
@@ -276,7 +395,9 @@ const testApi: ApiFromModules<{
276
395
  testApproveFlow: typeof testApproveFlow;
277
396
  testDenyFlow: typeof testDenyFlow;
278
397
  testApproveFlowWithInterveningMessage: typeof testApproveFlowWithInterveningMessage;
398
+ testMultiToolApproveFlow: typeof testMultiToolApproveFlow;
279
399
  submitApprovalForApprovalAgent: typeof submitApprovalForApprovalAgent;
400
+ submitApprovalForMultiToolAgent: typeof submitApprovalForMultiToolAgent;
280
401
  submitDenialForDenialAgent: typeof submitDenialForDenialAgent;
281
402
  };
282
403
  }>["fns"] = anyApi["approval.test"] as any;
@@ -338,6 +459,29 @@ describe("Tool Approval Workflow", () => {
338
459
  expect(result.lastUsage!.outputTokenDetails).toBeDefined();
339
460
  });
340
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
+
341
485
  test("approve remains valid with an intervening thread message", async () => {
342
486
  usageCalls.length = 0;
343
487
  const t = initConvexTest(schema);
@@ -1070,10 +1070,35 @@ export class Agent<
1070
1070
  reason?: string;
1071
1071
  },
1072
1072
  ): Promise<{ messageId: string }> {
1073
- const promptMessageId = await this.getApprovalRequestMessageId(ctx, {
1074
- threadId: args.threadId,
1073
+ const { promptMessageId, existingResponseMessage } =
1074
+ await this.findApprovalContext(ctx, {
1075
+ threadId: args.threadId,
1076
+ approvalId: args.approvalId,
1077
+ });
1078
+
1079
+ const newPart = {
1080
+ type: "tool-approval-response" as const,
1075
1081
  approvalId: args.approvalId,
1076
- });
1082
+ approved: args.approved,
1083
+ reason: args.reason,
1084
+ };
1085
+
1086
+ // Merge into an existing approval-response message for this step
1087
+ // so the AI SDK sees a single tool message per step.
1088
+ if (existingResponseMessage) {
1089
+ const existingContent = existingResponseMessage.message?.content;
1090
+ const mergedContent = Array.isArray(existingContent)
1091
+ ? [...(existingContent as any[]), newPart]
1092
+ : [newPart];
1093
+ await this.updateMessage(ctx, {
1094
+ messageId: existingResponseMessage._id,
1095
+ patch: {
1096
+ message: { role: "tool", content: mergedContent },
1097
+ status: "success",
1098
+ },
1099
+ });
1100
+ return { messageId: existingResponseMessage._id };
1101
+ }
1077
1102
 
1078
1103
  const { messageId } = await this.saveMessage(ctx, {
1079
1104
  threadId: args.threadId,
@@ -1081,36 +1106,55 @@ export class Agent<
1081
1106
  skipEmbeddings: true,
1082
1107
  message: {
1083
1108
  role: "tool",
1084
- content: [
1085
- {
1086
- type: "tool-approval-response",
1087
- approvalId: args.approvalId,
1088
- approved: args.approved,
1089
- reason: args.reason,
1090
- },
1091
- ],
1109
+ content: [newPart],
1092
1110
  },
1093
1111
  });
1094
1112
  return { messageId };
1095
1113
  }
1096
1114
 
1097
- private async getApprovalRequestMessageId(
1115
+ private async findApprovalContext(
1098
1116
  ctx: MutationCtx,
1099
1117
  args: { threadId: string; approvalId: string },
1100
- ): Promise<string> {
1118
+ ): Promise<{
1119
+ promptMessageId: string;
1120
+ existingResponseMessage: MessageDoc | undefined;
1121
+ }> {
1101
1122
  // NOTE: This pagination returns messages in descending order (newest first).
1102
1123
  // The "already handled" check (tool-approval-response) relies on seeing
1103
1124
  // responses before their corresponding requests. If the pagination order
1104
1125
  // changes, this logic will need to be updated.
1105
- let cursor: string | null = null;
1106
- do {
1107
- const page = await this.listMessages(ctx, {
1108
- threadId: args.threadId,
1109
- paginationOpts: { cursor, numItems: 100 },
1110
- });
1126
+ let existingResponseMessage: MessageDoc | undefined;
1127
+ // Limit the search to the most recent messages. Approvals should always
1128
+ // be near the end of the thread.
1129
+ const page = await this.listMessages(ctx, {
1130
+ threadId: args.threadId,
1131
+ paginationOpts: { cursor: null, numItems: 100 },
1132
+ });
1133
+ {
1111
1134
  for (const message of page.page) {
1112
1135
  const content = message.message?.content;
1113
1136
  if (!Array.isArray(content)) continue;
1137
+ // Check if this assistant message starts a different approval step.
1138
+ // If so, any response message we've seen so far belongs to a newer
1139
+ // step — reset it so we don't merge across step boundaries.
1140
+ // Only reset if the target approval is NOT in this message (i.e.,
1141
+ // this is a genuinely different step, not the same step with
1142
+ // multiple tool calls).
1143
+ if (
1144
+ message.message?.role === "assistant" &&
1145
+ content.some(
1146
+ (p: any) =>
1147
+ p.type === "tool-approval-request" &&
1148
+ p.approvalId !== args.approvalId,
1149
+ ) &&
1150
+ !content.some(
1151
+ (p: any) =>
1152
+ p.type === "tool-approval-request" &&
1153
+ p.approvalId === args.approvalId,
1154
+ )
1155
+ ) {
1156
+ existingResponseMessage = undefined;
1157
+ }
1114
1158
  for (const part of content) {
1115
1159
  const typedPart = part as { type?: unknown; approvalId?: unknown };
1116
1160
  if (
@@ -1119,19 +1163,25 @@ export class Agent<
1119
1163
  ) {
1120
1164
  throw new Error(`Approval ${args.approvalId} was already handled`);
1121
1165
  }
1166
+ // Track the most recent tool-approval-response message for merging
1167
+ if (
1168
+ typedPart.type === "tool-approval-response" &&
1169
+ !existingResponseMessage
1170
+ ) {
1171
+ existingResponseMessage = message;
1172
+ }
1122
1173
  if (
1123
1174
  typedPart.type === "tool-approval-request" &&
1124
1175
  typedPart.approvalId === args.approvalId
1125
1176
  ) {
1126
- return message._id;
1177
+ return { promptMessageId: message._id, existingResponseMessage };
1127
1178
  }
1128
1179
  }
1129
1180
  }
1130
- cursor = page.isDone ? null : page.continueCursor;
1131
- } while (cursor !== null);
1181
+ }
1132
1182
 
1133
1183
  throw new Error(
1134
- `Approval request ${args.approvalId} was not found in thread ${args.threadId}`,
1184
+ `Approval request ${args.approvalId} was not found in the last 100 messages of thread ${args.threadId}`,
1135
1185
  );
1136
1186
  }
1137
1187
 
@@ -292,7 +292,7 @@ describe("search.ts", () => {
292
292
  expect(result[1]._id).toBe("2");
293
293
  });
294
294
 
295
- it("should filter out tool calls with approval request but NO approval response", () => {
295
+ it("should keep tool calls with approval request but NO approval response (auto-deny handles them)", () => {
296
296
  const messages: MessageDoc[] = [
297
297
  {
298
298
  _id: "1",
@@ -321,19 +321,18 @@ describe("search.ts", () => {
321
321
 
322
322
  const result = filterOutOrphanedToolMessages(messages);
323
323
  expect(result).toHaveLength(1);
324
- // The assistant message should have the tool-call filtered out
324
+ // The assistant message should keep the tool-call (auto-deny resolves it downstream)
325
325
  const assistantContent = result[0].message?.content;
326
326
  expect(Array.isArray(assistantContent)).toBe(true);
327
327
  if (Array.isArray(assistantContent)) {
328
- // Text and approval-request should remain, but tool-call should be filtered
329
- expect(assistantContent).toHaveLength(2);
328
+ expect(assistantContent).toHaveLength(3);
330
329
  expect(assistantContent.find((p) => p.type === "text")).toBeDefined();
331
330
  expect(
332
331
  assistantContent.find((p) => p.type === "tool-approval-request"),
333
332
  ).toBeDefined();
334
333
  expect(
335
334
  assistantContent.find((p) => p.type === "tool-call"),
336
- ).toBeUndefined();
335
+ ).toBeDefined();
337
336
  }
338
337
  });
339
338
 
@@ -30,8 +30,8 @@ import type {
30
30
  } from "./types.js";
31
31
  import { inlineMessagesFiles } from "./files.js";
32
32
  import {
33
+ autoDenyUnresolvedApprovals,
33
34
  docsToModelMessages,
34
- mergeApprovalResponseMessages,
35
35
  toModelMessage,
36
36
  } from "../mapping.js";
37
37
 
@@ -289,6 +289,12 @@ export function filterOutOrphanedToolMessages(docs: MessageDoc[]) {
289
289
  return approvalId !== undefined && approvalResponseIds.has(approvalId);
290
290
  };
291
291
 
292
+ // Helper: check if tool call has a pending approval request
293
+ // (auto-deny handles these downstream, so they must survive the filter)
294
+ const hasApprovalRequest = (toolCallId: string) => {
295
+ return approvalRequestsByToolCallId.has(toolCallId);
296
+ };
297
+
292
298
  for (const doc of docs) {
293
299
  if (
294
300
  doc.message?.role === "assistant" &&
@@ -298,7 +304,8 @@ export function filterOutOrphanedToolMessages(docs: MessageDoc[]) {
298
304
  (p) =>
299
305
  p.type !== "tool-call" ||
300
306
  toolResultIds.has(p.toolCallId) ||
301
- hasApprovalResponse(p.toolCallId),
307
+ hasApprovalResponse(p.toolCallId) ||
308
+ hasApprovalRequest(p.toolCallId),
302
309
  );
303
310
  if (content.length) {
304
311
  result.push({
@@ -641,13 +648,13 @@ export async function fetchContextWithPrompt(
641
648
  const inputPrompt = promptArray.map(toModelMessage);
642
649
  const existingResponses = docsToModelMessages(existingResponseDocs);
643
650
 
644
- const allMessages = mergeApprovalResponseMessages([
651
+ const allMessages = [
645
652
  ...search,
646
653
  ...recent,
647
654
  ...inputMessages,
648
655
  ...inputPrompt,
649
656
  ...existingResponses,
650
- ]);
657
+ ];
651
658
  let processedMessages = args.contextHandler
652
659
  ? await args.contextHandler(ctx, {
653
660
  allMessages,
@@ -661,6 +668,11 @@ export async function fetchContextWithPrompt(
661
668
  })
662
669
  : allMessages;
663
670
 
671
+ // Post-process: auto-deny unresolved approvals so the AI SDK sees a
672
+ // complete history. Applied after contextHandler so custom handlers
673
+ // don't need to handle this.
674
+ processedMessages = autoDenyUnresolvedApprovals(processedMessages);
675
+
664
676
  // Process messages to inline localhost files (if not, file urls pointing to localhost will be sent to LLM providers)
665
677
  if (process.env.CONVEX_CLOUD_URL?.startsWith("http://127.0.0.1")) {
666
678
  processedMessages = await inlineMessagesFiles(processedMessages);
@@ -10,7 +10,7 @@ import {
10
10
  type ToolSet,
11
11
  } from "ai";
12
12
  import {
13
- serializeNewMessagesInStep,
13
+ serializeResponseMessages,
14
14
  serializeObjectResult,
15
15
  } from "../mapping.js";
16
16
  import { embedMessages, fetchContextWithPrompt } from "./search.js";
@@ -252,7 +252,7 @@ export async function startGeneration<
252
252
  previousResponseMessageCount,
253
253
  );
254
254
  previousResponseMessageCount = allResponseMessages.length;
255
- serialized = await serializeNewMessagesInStep(
255
+ serialized = await serializeResponseMessages(
256
256
  ctx,
257
257
  component,
258
258
  toSave.step,
@@ -168,6 +168,15 @@ export async function streamText<
168
168
  // finish() was never called, leaving the streaming message stuck in
169
169
  // "streaming" state. Clean it up by marking it as aborted.
170
170
  await streamer?.fail(e instanceof Error ? e.message : String(e));
171
+ // Save the deferred final step if it was already generated but not yet persisted
172
+ if (pendingFinalStep) {
173
+ try {
174
+ await call.save({ step: pendingFinalStep }, false);
175
+ } catch (saveError) {
176
+ console.error("Failed to save deferred final step:", saveError);
177
+ }
178
+ pendingFinalStep = undefined;
179
+ }
171
180
  throw e;
172
181
  }
173
182
  }