@convex-dev/agent 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/dist/UIMessages.d.ts +46 -0
  2. package/dist/UIMessages.d.ts.map +1 -0
  3. package/dist/UIMessages.js +634 -0
  4. package/dist/UIMessages.js.map +1 -0
  5. package/dist/client/createTool.d.ts +167 -0
  6. package/dist/client/createTool.d.ts.map +1 -0
  7. package/dist/client/createTool.js +116 -0
  8. package/dist/client/createTool.js.map +1 -0
  9. package/dist/client/defaultComponent.d.ts +11 -0
  10. package/dist/client/defaultComponent.d.ts.map +1 -0
  11. package/dist/client/defaultComponent.js +7 -0
  12. package/dist/client/defaultComponent.js.map +1 -0
  13. package/dist/client/definePlaygroundAPI.d.ts +1725 -0
  14. package/dist/client/definePlaygroundAPI.d.ts.map +1 -0
  15. package/dist/client/definePlaygroundAPI.js +271 -0
  16. package/dist/client/definePlaygroundAPI.js.map +1 -0
  17. package/dist/client/files.d.ts +69 -0
  18. package/dist/client/files.d.ts.map +1 -0
  19. package/dist/client/files.js +181 -0
  20. package/dist/client/files.js.map +1 -0
  21. package/dist/client/index.d.ts +2091 -0
  22. package/dist/client/index.d.ts.map +1 -0
  23. package/dist/client/index.js +929 -0
  24. package/dist/client/index.js.map +1 -0
  25. package/dist/client/messages.d.ts +461 -0
  26. package/dist/client/messages.d.ts.map +1 -0
  27. package/dist/client/messages.js +106 -0
  28. package/dist/client/messages.js.map +1 -0
  29. package/dist/client/mockModel.d.ts +42 -0
  30. package/dist/client/mockModel.d.ts.map +1 -0
  31. package/dist/client/mockModel.js +182 -0
  32. package/dist/client/mockModel.js.map +1 -0
  33. package/dist/client/saveInputMessages.d.ts +20 -0
  34. package/dist/client/saveInputMessages.d.ts.map +1 -0
  35. package/dist/client/saveInputMessages.js +58 -0
  36. package/dist/client/saveInputMessages.js.map +1 -0
  37. package/dist/client/search.d.ts +493 -0
  38. package/dist/client/search.d.ts.map +1 -0
  39. package/dist/client/search.js +435 -0
  40. package/dist/client/search.js.map +1 -0
  41. package/dist/client/start.d.ts +84 -0
  42. package/dist/client/start.d.ts.map +1 -0
  43. package/dist/client/start.js +185 -0
  44. package/dist/client/start.js.map +1 -0
  45. package/dist/client/streamText.d.ts +46 -0
  46. package/dist/client/streamText.d.ts.map +1 -0
  47. package/dist/client/streamText.js +127 -0
  48. package/dist/client/streamText.js.map +1 -0
  49. package/dist/client/streaming.d.ts +3778 -0
  50. package/dist/client/streaming.d.ts.map +1 -0
  51. package/dist/client/streaming.js +330 -0
  52. package/dist/client/streaming.js.map +1 -0
  53. package/dist/client/threads.d.ts +46 -0
  54. package/dist/client/threads.d.ts.map +1 -0
  55. package/dist/client/threads.js +49 -0
  56. package/dist/client/threads.js.map +1 -0
  57. package/dist/client/types.d.ts +461 -0
  58. package/dist/client/types.d.ts.map +1 -0
  59. package/dist/client/types.js +2 -0
  60. package/dist/client/types.js.map +1 -0
  61. package/dist/client/utils.d.ts +4 -0
  62. package/dist/client/utils.d.ts.map +1 -0
  63. package/dist/client/utils.js +21 -0
  64. package/dist/client/utils.js.map +1 -0
  65. package/dist/component/_generated/api.d.ts +48 -0
  66. package/dist/component/_generated/api.d.ts.map +1 -0
  67. package/dist/component/_generated/api.js +31 -0
  68. package/dist/component/_generated/api.js.map +1 -0
  69. package/dist/component/_generated/component.d.ts +3120 -0
  70. package/dist/component/_generated/component.d.ts.map +1 -0
  71. package/dist/component/_generated/component.js +11 -0
  72. package/dist/component/_generated/component.js.map +1 -0
  73. package/dist/component/_generated/dataModel.d.ts +46 -0
  74. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  75. package/dist/component/_generated/dataModel.js +11 -0
  76. package/dist/component/_generated/dataModel.js.map +1 -0
  77. package/dist/component/_generated/server.d.ts +121 -0
  78. package/dist/component/_generated/server.d.ts.map +1 -0
  79. package/dist/component/_generated/server.js +78 -0
  80. package/dist/component/_generated/server.js.map +1 -0
  81. package/dist/component/apiKeys.d.ts +11 -0
  82. package/dist/component/apiKeys.d.ts.map +1 -0
  83. package/dist/component/apiKeys.js +69 -0
  84. package/dist/component/apiKeys.js.map +1 -0
  85. package/dist/component/convex.config.d.ts +3 -0
  86. package/dist/component/convex.config.d.ts.map +1 -0
  87. package/dist/component/convex.config.js +3 -0
  88. package/dist/component/convex.config.js.map +1 -0
  89. package/dist/component/files.d.ts +97 -0
  90. package/dist/component/files.d.ts.map +1 -0
  91. package/dist/component/files.js +190 -0
  92. package/dist/component/files.js.map +1 -0
  93. package/dist/component/messages.d.ts +3851 -0
  94. package/dist/component/messages.d.ts.map +1 -0
  95. package/dist/component/messages.js +757 -0
  96. package/dist/component/messages.js.map +1 -0
  97. package/dist/component/schema.d.ts +8029 -0
  98. package/dist/component/schema.d.ts.map +1 -0
  99. package/dist/component/schema.js +147 -0
  100. package/dist/component/schema.js.map +1 -0
  101. package/dist/component/streams.d.ts +128 -0
  102. package/dist/component/streams.d.ts.map +1 -0
  103. package/dist/component/streams.js +413 -0
  104. package/dist/component/streams.js.map +1 -0
  105. package/dist/component/threads.d.ts +115 -0
  106. package/dist/component/threads.d.ts.map +1 -0
  107. package/dist/component/threads.js +208 -0
  108. package/dist/component/threads.js.map +1 -0
  109. package/dist/component/users.d.ts +52 -0
  110. package/dist/component/users.d.ts.map +1 -0
  111. package/dist/component/users.js +229 -0
  112. package/dist/component/users.js.map +1 -0
  113. package/dist/component/vector/index.d.ts +61 -0
  114. package/dist/component/vector/index.d.ts.map +1 -0
  115. package/dist/component/vector/index.js +146 -0
  116. package/dist/component/vector/index.js.map +1 -0
  117. package/dist/component/vector/tables.d.ts +58 -0
  118. package/dist/component/vector/tables.d.ts.map +1 -0
  119. package/dist/component/vector/tables.js +56 -0
  120. package/dist/component/vector/tables.js.map +1 -0
  121. package/dist/deltas.d.ts +43 -0
  122. package/dist/deltas.d.ts.map +1 -0
  123. package/dist/deltas.js +446 -0
  124. package/dist/deltas.js.map +1 -0
  125. package/dist/mapping.d.ts +76 -0
  126. package/dist/mapping.d.ts.map +1 -0
  127. package/dist/mapping.js +720 -0
  128. package/dist/mapping.js.map +1 -0
  129. package/dist/react/SmoothText.d.ts +5 -0
  130. package/dist/react/SmoothText.d.ts.map +1 -0
  131. package/dist/react/SmoothText.js +6 -0
  132. package/dist/react/SmoothText.js.map +1 -0
  133. package/dist/react/index.d.ts +25 -0
  134. package/dist/react/index.d.ts.map +1 -0
  135. package/dist/react/index.js +70 -0
  136. package/dist/react/index.js.map +1 -0
  137. package/dist/react/optimisticallySendMessage.d.ts +42 -0
  138. package/dist/react/optimisticallySendMessage.d.ts.map +1 -0
  139. package/dist/react/optimisticallySendMessage.js +74 -0
  140. package/dist/react/optimisticallySendMessage.js.map +1 -0
  141. package/dist/react/types.d.ts +12 -0
  142. package/dist/react/types.d.ts.map +1 -0
  143. package/dist/react/types.js +2 -0
  144. package/dist/react/types.js.map +1 -0
  145. package/dist/react/useDeltaStreams.d.ts +10 -0
  146. package/dist/react/useDeltaStreams.d.ts.map +1 -0
  147. package/dist/react/useDeltaStreams.js +106 -0
  148. package/dist/react/useDeltaStreams.js.map +1 -0
  149. package/dist/react/useSmoothText.d.ts +27 -0
  150. package/dist/react/useSmoothText.d.ts.map +1 -0
  151. package/dist/react/useSmoothText.js +68 -0
  152. package/dist/react/useSmoothText.js.map +1 -0
  153. package/dist/react/useStreamingUIMessages.d.ts +22 -0
  154. package/dist/react/useStreamingUIMessages.d.ts.map +1 -0
  155. package/dist/react/useStreamingUIMessages.js +92 -0
  156. package/dist/react/useStreamingUIMessages.js.map +1 -0
  157. package/dist/react/useThreadMessages.d.ts +104 -0
  158. package/dist/react/useThreadMessages.d.ts.map +1 -0
  159. package/dist/react/useThreadMessages.js +148 -0
  160. package/dist/react/useThreadMessages.js.map +1 -0
  161. package/dist/react/useUIMessages.d.ts +96 -0
  162. package/dist/react/useUIMessages.d.ts.map +1 -0
  163. package/dist/react/useUIMessages.js +108 -0
  164. package/dist/react/useUIMessages.js.map +1 -0
  165. package/dist/shared.d.ts +26 -0
  166. package/dist/shared.d.ts.map +1 -0
  167. package/dist/shared.js +67 -0
  168. package/dist/shared.js.map +1 -0
  169. package/dist/validators.d.ts +24516 -0
  170. package/dist/validators.d.ts.map +1 -0
  171. package/dist/validators.js +475 -0
  172. package/dist/validators.js.map +1 -0
  173. package/package.json +39 -39
  174. package/src/UIMessages.ts +0 -9
  175. package/src/client/approval.test.ts +144 -0
  176. package/src/client/index.ts +17 -9
  177. package/src/client/search.ts +0 -29
  178. package/src/client/start.ts +2 -2
  179. package/src/client/streamText.ts +9 -0
  180. package/src/client/streaming.ts +30 -23
  181. package/src/component/_generated/dataModel.ts +1 -1
  182. package/src/component/_generated/server.ts +0 -5
  183. package/src/mapping.ts +38 -22
  184. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "email": "support@convex.dev",
8
8
  "url": "https://github.com/get-convex/agent/issues"
9
9
  },
10
- "version": "0.6.0",
10
+ "version": "0.6.1",
11
11
  "license": "Apache-2.0",
12
12
  "keywords": [
13
13
  "convex",
@@ -84,66 +84,66 @@
84
84
  }
85
85
  },
86
86
  "devDependencies": {
87
- "@ai-sdk/anthropic": "^3.0.13",
88
- "@ai-sdk/google": "^3.0.30",
89
- "@ai-sdk/groq": "^3.0.8",
90
- "@ai-sdk/openai": "^3.0.10",
91
- "@ai-sdk/provider": "^3.0.3",
92
- "@ai-sdk/provider-utils": "^4.0.6",
93
- "@convex-dev/rag": "0.7.0",
94
- "@convex-dev/rate-limiter": "0.3.0",
95
- "@convex-dev/workflow": "0.3.2",
87
+ "@ai-sdk/anthropic": "3.0.13",
88
+ "@ai-sdk/google": "3.0.30",
89
+ "@ai-sdk/groq": "3.0.8",
90
+ "@ai-sdk/openai": "3.0.10",
91
+ "@ai-sdk/provider": "3.0.3",
92
+ "@ai-sdk/provider-utils": "4.0.6",
93
+ "@convex-dev/rag": "0.7.2",
94
+ "@convex-dev/rate-limiter": "0.3.2",
95
+ "@convex-dev/workflow": "0.3.6",
96
96
  "@edge-runtime/vm": "5.0.0",
97
- "@eslint/js": "9.38.0",
97
+ "@eslint/js": "9.39.4",
98
98
  "@hookform/resolvers": "5.2.2",
99
99
  "@langchain/textsplitters": "0.1.0",
100
100
  "@radix-ui/react-accordion": "1.2.12",
101
101
  "@radix-ui/react-checkbox": "1.3.3",
102
- "@radix-ui/react-label": "2.1.7",
102
+ "@radix-ui/react-label": "2.1.8",
103
103
  "@radix-ui/react-select": "2.2.6",
104
- "@radix-ui/react-slot": "1.2.3",
104
+ "@radix-ui/react-slot": "1.2.4",
105
105
  "@radix-ui/react-toast": "1.2.15",
106
106
  "@tailwindcss/typography": "0.5.19",
107
- "@types/node": "20.19.24",
108
- "@types/react": "19.2.2",
109
- "@types/react-dom": "19.2.2",
107
+ "@types/node": "20.19.37",
108
+ "@types/react": "19.2.14",
109
+ "@types/react-dom": "19.2.3",
110
110
  "@vitejs/plugin-react": "4.7.0",
111
- "ai": "^6.0.35",
112
- "autoprefixer": "10.4.21",
111
+ "ai": "6.0.35",
112
+ "autoprefixer": "10.4.27",
113
113
  "chokidar-cli": "3.0.0",
114
114
  "class-variance-authority": "0.7.1",
115
115
  "clsx": "2.1.1",
116
- "convex": "1.29.3",
117
- "convex-helpers": "0.1.104",
118
- "convex-test": "0.0.38",
116
+ "convex": "1.33.1",
117
+ "convex-helpers": "0.1.114",
118
+ "convex-test": "0.0.41",
119
119
  "cpy-cli": "5.0.0",
120
- "dayjs": "1.11.18",
120
+ "dayjs": "1.11.20",
121
121
  "dotenv": "16.6.1",
122
- "eslint": "9.38.0",
122
+ "eslint": "9.39.4",
123
123
  "eslint-plugin-jsx-a11y": "6.10.2",
124
124
  "eslint-plugin-react": "7.37.5",
125
- "eslint-plugin-react-hooks": "^6",
126
- "eslint-plugin-react-refresh": "0.4.24",
127
- "globals": "16.4.0",
128
- "lucide-react": "0.548.0",
125
+ "eslint-plugin-react-hooks": "6.1.1",
126
+ "eslint-plugin-react-refresh": "0.5.2",
127
+ "globals": "16.5.0",
128
+ "lucide-react": "0.577.0",
129
129
  "npm-run-all2": "8.0.4",
130
- "ollama-ai-provider": "^1.2.0",
130
+ "ollama-ai-provider": "1.2.0",
131
131
  "openai": "5.23.2",
132
- "path-exists-cli": "^2.0.0",
133
- "pkg-pr-new": "0.0.60",
134
- "postcss": "8.5.6",
135
- "prettier": "3.6.2",
136
- "react": "19.2.3",
137
- "react-dom": "19.2.3",
138
- "react-hook-form": "7.65.0",
132
+ "path-exists-cli": "2.0.0",
133
+ "pkg-pr-new": "0.0.66",
134
+ "postcss": "8.5.8",
135
+ "prettier": "3.8.1",
136
+ "react": "19.2.4",
137
+ "react-dom": "19.2.4",
138
+ "react-hook-form": "7.71.2",
139
139
  "react-markdown": "10.1.0",
140
- "react-router-dom": "7.9.5",
140
+ "react-router-dom": "7.13.1",
141
141
  "readline": "1.3.0",
142
- "tailwind-merge": "3.3.1",
143
- "tailwindcss": "3.4.18",
142
+ "tailwind-merge": "3.5.0",
143
+ "tailwindcss": "3.4.19",
144
144
  "tailwindcss-animate": "1.0.7",
145
145
  "typescript": "5.9.3",
146
- "typescript-eslint": "8.46.2",
146
+ "typescript-eslint": "8.57.1",
147
147
  "vite": "6.4.1",
148
148
  "vitest": "3.2.4",
149
149
  "zod": "3.25.76"
package/src/UIMessages.ts CHANGED
@@ -396,8 +396,6 @@ function createAssistantUIMessage<
396
396
  ? ("streaming" as const)
397
397
  : lastMessage.status;
398
398
 
399
- <<<<<<< Updated upstream
400
- =======
401
399
  // Extract approval parts from raw message content for UI rendering
402
400
  type ApprovalPart =
403
401
  | { type: "tool-approval-request"; approvalId: string; toolCallId: string }
@@ -443,7 +441,6 @@ function createAssistantUIMessage<
443
441
  }
444
442
  }
445
443
 
446
- >>>>>>> Stashed changes
447
444
  // Collect all parts from all messages
448
445
  const allParts: UIMessage<METADATA, DATA_PARTS, TOOLS>["parts"] = [];
449
446
 
@@ -525,12 +522,9 @@ function createAssistantUIMessage<
525
522
  break;
526
523
  }
527
524
  case "tool-result": {
528
- <<<<<<< Updated upstream
529
- =======
530
525
  // Note: execution-denied outputs are handled separately via pre-extraction
531
526
  // from raw content (converted to text format for providers in start.ts).
532
527
  // See executionDeniedResults processing at the end of this function.
533
- >>>>>>> Stashed changes
534
528
  const typedPart = contentPart as unknown as ToolResultPart & {
535
529
  output: { type: string; value?: unknown; reason?: string };
536
530
  };
@@ -696,8 +690,6 @@ function createAssistantUIMessage<
696
690
  }
697
691
  }
698
692
 
699
- <<<<<<< Updated upstream
700
- =======
701
693
  // Final output states that should not be overwritten by approval processing
702
694
  const finalStates = new Set([
703
695
  "output-available",
@@ -776,7 +768,6 @@ function createAssistantUIMessage<
776
768
  }
777
769
  }
778
770
 
779
- >>>>>>> Stashed changes
780
771
  return {
781
772
  ...common,
782
773
  role: "assistant",
@@ -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);
@@ -1123,25 +1123,34 @@ export class Agent<
1123
1123
  // The "already handled" check (tool-approval-response) relies on seeing
1124
1124
  // responses before their corresponding requests. If the pagination order
1125
1125
  // changes, this logic will need to be updated.
1126
- let cursor: string | null = null;
1127
1126
  let existingResponseMessage: MessageDoc | undefined;
1128
- do {
1129
- const page = await this.listMessages(ctx, {
1130
- threadId: args.threadId,
1131
- paginationOpts: { cursor, numItems: 100 },
1132
- });
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
+ {
1133
1134
  for (const message of page.page) {
1134
1135
  const content = message.message?.content;
1135
1136
  if (!Array.isArray(content)) continue;
1136
1137
  // Check if this assistant message starts a different approval step.
1137
1138
  // If so, any response message we've seen so far belongs to a newer
1138
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).
1139
1143
  if (
1140
1144
  message.message?.role === "assistant" &&
1141
1145
  content.some(
1142
1146
  (p: any) =>
1143
1147
  p.type === "tool-approval-request" &&
1144
1148
  p.approvalId !== args.approvalId,
1149
+ ) &&
1150
+ !content.some(
1151
+ (p: any) =>
1152
+ p.type === "tool-approval-request" &&
1153
+ p.approvalId === args.approvalId,
1145
1154
  )
1146
1155
  ) {
1147
1156
  existingResponseMessage = undefined;
@@ -1169,11 +1178,10 @@ export class Agent<
1169
1178
  }
1170
1179
  }
1171
1180
  }
1172
- cursor = page.isDone ? null : page.continueCursor;
1173
- } while (cursor !== null);
1181
+ }
1174
1182
 
1175
1183
  throw new Error(
1176
- `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}`,
1177
1185
  );
1178
1186
  }
1179
1187
 
@@ -35,28 +35,6 @@ import {
35
35
  toModelMessage,
36
36
  } from "../mapping.js";
37
37
 
38
- /**
39
- * Filter out approval parts from messages before sending to AI providers.
40
- * Providers like Anthropic don't understand tool-approval-request and
41
- * tool-approval-response parts and will error if they are included.
42
- */
43
- function filterApprovalPartsForProvider(messages: ModelMessage[]): ModelMessage[] {
44
- return messages.map((message) => {
45
- if (!Array.isArray(message.content)) {
46
- return message;
47
- }
48
- const filteredContent = message.content.filter(
49
- (part) =>
50
- (part as { type: string }).type !== "tool-approval-request" &&
51
- (part as { type: string }).type !== "tool-approval-response",
52
- );
53
- return {
54
- ...message,
55
- content: filteredContent,
56
- } as typeof message;
57
- });
58
- }
59
-
60
38
  const DEFAULT_VECTOR_SCORE_THRESHOLD = 0.0;
61
39
  // 10k characters should be more than enough for most cases, and stays under
62
40
  // the 8k token limit for some models.
@@ -256,11 +234,7 @@ export async function fetchRecentAndSearchMessages(
256
234
  searchMessages = filterOutOrphanedToolMessages(
257
235
  sorted(searchResults.filter((m) => !included?.has(m._id))),
258
236
  );
259
- searchMessages = filterApprovalPartsForProvider(searchMessages);
260
237
  }
261
-
262
- // filter out approval parts before returning
263
- searchMessages = filterApprovalPartsForProvider(searchMessages);
264
238
  // Ensure we don't include tool messages without a corresponding tool call
265
239
  return { recentMessages, searchMessages };
266
240
  }
@@ -704,9 +678,6 @@ export async function fetchContextWithPrompt(
704
678
  processedMessages = await inlineMessagesFiles(processedMessages);
705
679
  }
706
680
 
707
- // Filter out approval parts before sending to AI providers
708
- processedMessages = filterApprovalPartsForProvider(processedMessages);
709
-
710
681
  return {
711
682
  messages: processedMessages,
712
683
  order: promptMessage?.order,
@@ -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
  }
@@ -244,16 +244,25 @@ export class DeltaStreamer<T> {
244
244
  this.abortController = new AbortController();
245
245
  if (config.abortSignal) {
246
246
  config.abortSignal.addEventListener("abort", async () => {
247
- if (this.abortController.signal.aborted) {
248
- return;
249
- }
250
- if (this.streamId) {
247
+ try {
248
+ if (this.abortController.signal.aborted) {
249
+ return;
250
+ }
251
251
  this.abortController.abort();
252
- await this.#ongoingWrite;
253
- await this.ctx.runMutation(this.component.streams.abort, {
254
- streamId: this.streamId,
255
- reason: "abortSignal",
256
- });
252
+ // Wait for in-flight stream creation before trying to abort it
253
+ if (this.#creatingStreamIdPromise) {
254
+ await this.#creatingStreamIdPromise;
255
+ }
256
+ if (this.streamId) {
257
+ await this.#ongoingWrite;
258
+ await this.ctx.runMutation(this.component.streams.abort, {
259
+ streamId: this.streamId,
260
+ reason: "abortSignal",
261
+ });
262
+ }
263
+ } catch {
264
+ // Best-effort cleanup — the stream will be garbage-collected
265
+ // by the 10-minute timeout if this fails.
257
266
  }
258
267
  });
259
268
  }
@@ -293,7 +302,9 @@ export class DeltaStreamer<T> {
293
302
  await this.addParts([chunk]);
294
303
  }
295
304
  // Skip finish if it will be handled externally (atomically with message save)
296
- // or if the stream was aborted (e.g., due to a failed delta write)
305
+ // or if the stream was aborted (e.g., due to a failed delta write).
306
+ // Aborted streams are cleaned up via streams.abort (called by the abort
307
+ // signal handler), so we don't need to call finish() for them.
297
308
  if (!this.#finishedExternally && !this.abortController.signal.aborted) {
298
309
  await this.finish();
299
310
  }
@@ -376,10 +387,7 @@ export class DeltaStreamer<T> {
376
387
  return;
377
388
  }
378
389
  await this.#ongoingWrite;
379
- if (this.abortController.signal.aborted) {
380
- return;
381
- }
382
- await this.#sendDelta();
390
+ await this.#sendDelta(); // #sendDelta checks aborted internally
383
391
  if (this.abortController.signal.aborted) {
384
392
  return;
385
393
  }
@@ -439,16 +447,15 @@ export function compressTextStreamParts(
439
447
  } else {
440
448
  compressed.push(part);
441
449
  }
450
+ } else if (part.type === "file") {
451
+ compressed.push({
452
+ type: "file",
453
+ file: {
454
+ ...part.file,
455
+ uint8Array: undefined as unknown as Uint8Array,
456
+ },
457
+ });
442
458
  } else {
443
- if (part.type === "file") {
444
- compressed.push({
445
- type: "file",
446
- file: {
447
- ...part.file,
448
- uint8Array: undefined as unknown as Uint8Array,
449
- },
450
- });
451
- }
452
459
  compressed.push(part);
453
460
  }
454
461
  }
@@ -38,7 +38,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
38
38
  * Convex documents are uniquely identified by their `Id`, which is accessible
39
39
  * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40
40
  *
41
- * Documents can be loaded using `db.get(id)` in query and mutation functions.
41
+ * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
42
42
  *
43
43
  * IDs are just strings at runtime, but this type can be used to distinguish them from other
44
44
  * strings when type checking.
@@ -107,11 +107,6 @@ export const internalAction: ActionBuilder<DataModel, "internal"> =
107
107
  */
108
108
  export const httpAction: HttpActionBuilder = httpActionGeneric;
109
109
 
110
- type GenericCtx =
111
- | GenericActionCtx<DataModel>
112
- | GenericMutationCtx<DataModel>
113
- | GenericQueryCtx<DataModel>;
114
-
115
110
  /**
116
111
  * A set of services for use within Convex query functions.
117
112
  *
package/src/mapping.ts CHANGED
@@ -297,21 +297,49 @@ export function toModelMessageWarnings(
297
297
  }) as any;
298
298
  }
299
299
 
300
+ /**
301
+ * Serialize explicitly provided response messages for a step.
302
+ * Used by the streaming/generation loop where the caller tracks which
303
+ * messages are new via slicing.
304
+ */
305
+ export async function serializeResponseMessages<TOOLS extends ToolSet>(
306
+ ctx: ActionCtx,
307
+ component: AgentComponent,
308
+ step: StepResult<TOOLS>,
309
+ model: ModelOrMetadata | undefined,
310
+ responseMessages: ModelMessage[],
311
+ ): Promise<{ messages: MessageWithMetadata[] }> {
312
+ return serializeStepMessages(ctx, component, step, model, responseMessages);
313
+ }
314
+
315
+ /**
316
+ * Serialize the new messages from a step using a heuristic to determine
317
+ * which response messages are new (last 1-2 messages).
318
+ */
300
319
  export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
301
320
  ctx: ActionCtx,
302
321
  component: AgentComponent,
303
322
  step: StepResult<TOOLS>,
304
323
  model: ModelOrMetadata | undefined,
305
- /**
306
- * If provided, these are the new response messages for this step
307
- * (pre-sliced by the caller). When not provided, falls back to the
308
- * existing heuristic of slicing the last 1-2 messages.
309
- *
310
- * This is needed for tool approval flows where the SDK adds extra
311
- * messages (e.g. approval tool-results) at the beginning of
312
- * responseMessages that the old slice(-1/-2) logic would miss.
313
- */
314
- newResponseMessages?: ModelMessage[],
324
+ ): Promise<{ messages: MessageWithMetadata[] }> {
325
+ const hasToolMessage = step.response.messages.at(-1)?.role === "tool";
326
+ let messagesToSerialize: ModelMessage[];
327
+ if (hasToolMessage) {
328
+ messagesToSerialize = step.response.messages.slice(-2);
329
+ } else if (step.content.length) {
330
+ messagesToSerialize = step.response.messages.slice(-1);
331
+ } else {
332
+ messagesToSerialize = [{ role: "assistant" as const, content: [] }];
333
+ }
334
+ return serializeStepMessages(ctx, component, step, model, messagesToSerialize);
335
+ }
336
+
337
+ async function serializeStepMessages<TOOLS extends ToolSet>(
338
+ ctx: ActionCtx,
339
+ component: AgentComponent,
340
+ step: StepResult<TOOLS>,
341
+ model: ModelOrMetadata | undefined,
342
+ messagesToSerialize: ModelMessage[],
315
343
  ): Promise<{ messages: MessageWithMetadata[] }> {
316
344
  // If there are tool results, there's another message with the tool results
317
345
  // ref: https://github.com/vercel/ai/blob/main/packages/ai/src/generate-text/to-response-messages.ts#L120
@@ -330,18 +358,6 @@ export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
330
358
  } satisfies Omit<MessageWithMetadata, "message" | "text" | "fileIds">;
331
359
  const toolFields = { sources: step.sources };
332
360
 
333
- // Determine which messages to serialize for this step
334
- let messagesToSerialize: ModelMessage[];
335
- if (newResponseMessages) {
336
- messagesToSerialize = newResponseMessages;
337
- } else if (hasToolMessage) {
338
- messagesToSerialize = step.response.messages.slice(-2);
339
- } else if (step.content.length) {
340
- messagesToSerialize = step.response.messages.slice(-1);
341
- } else {
342
- messagesToSerialize = [{ role: "assistant" as const, content: [] }];
343
- }
344
-
345
361
  const messages: MessageWithMetadata[] = await Promise.all(
346
362
  messagesToSerialize.map(async (msg): Promise<MessageWithMetadata> => {
347
363
  const { message, fileIds } = await serializeMessage(ctx, component, msg);
@@ -0,0 +1 @@
1
+ {"version":"3.2.4","results":[[":client/index.test.ts",{"duration":129.9694999999997,"failed":false}],[":component/messages.test.ts",{"duration":191.73391600000014,"failed":false}],[":component/users.test.ts",{"duration":545.7272500000001,"failed":false}],[":mapping.test.ts",{"duration":28.127082999999402,"failed":false}],[":react/deltas.test.ts",{"duration":5.8497500000000855,"failed":false}],[":react/toUIMessages.test.ts",{"duration":6.495208000000048,"failed":false}],[":validators.test.ts",{"duration":1.0037499999999682,"failed":false}],[":deltas.test.ts",{"duration":0,"failed":false}],[":client/streaming.test.ts",{"duration":177.87866699695587,"failed":false}]]}