@convex-dev/agent 0.6.0-alpha.0 → 0.6.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 (192) hide show
  1. package/MIGRATION.md +153 -0
  2. package/package.json +4 -2
  3. package/src/UIMessages.ts +135 -0
  4. package/src/client/approval.test.ts +350 -0
  5. package/src/client/createTool.ts +50 -52
  6. package/src/client/index.ts +162 -1
  7. package/src/client/mockModel.ts +9 -2
  8. package/src/client/search.test.ts +4 -5
  9. package/src/client/search.ts +47 -2
  10. package/src/client/start.ts +41 -24
  11. package/src/client/streamText.ts +27 -3
  12. package/src/client/streaming.integration.test.ts +1206 -0
  13. package/src/client/streaming.ts +41 -12
  14. package/src/client/types.ts +14 -12
  15. package/src/component/_generated/component.ts +53 -64
  16. package/src/component/messages.ts +12 -2
  17. package/src/deltas.ts +0 -1
  18. package/src/mapping.test.ts +143 -1
  19. package/src/mapping.ts +115 -6
  20. package/src/react/useDeltaStreams.ts +6 -0
  21. package/dist/UIMessages.d.ts +0 -46
  22. package/dist/UIMessages.d.ts.map +0 -1
  23. package/dist/UIMessages.js +0 -546
  24. package/dist/UIMessages.js.map +0 -1
  25. package/dist/client/createTool.d.ts +0 -170
  26. package/dist/client/createTool.d.ts.map +0 -1
  27. package/dist/client/createTool.js +0 -115
  28. package/dist/client/createTool.js.map +0 -1
  29. package/dist/client/defaultComponent.d.ts +0 -11
  30. package/dist/client/defaultComponent.d.ts.map +0 -1
  31. package/dist/client/defaultComponent.js +0 -7
  32. package/dist/client/defaultComponent.js.map +0 -1
  33. package/dist/client/definePlaygroundAPI.d.ts +0 -1725
  34. package/dist/client/definePlaygroundAPI.d.ts.map +0 -1
  35. package/dist/client/definePlaygroundAPI.js +0 -271
  36. package/dist/client/definePlaygroundAPI.js.map +0 -1
  37. package/dist/client/files.d.ts +0 -69
  38. package/dist/client/files.d.ts.map +0 -1
  39. package/dist/client/files.js +0 -181
  40. package/dist/client/files.js.map +0 -1
  41. package/dist/client/index.d.ts +0 -2044
  42. package/dist/client/index.d.ts.map +0 -1
  43. package/dist/client/index.js +0 -804
  44. package/dist/client/index.js.map +0 -1
  45. package/dist/client/messages.d.ts +0 -461
  46. package/dist/client/messages.d.ts.map +0 -1
  47. package/dist/client/messages.js +0 -106
  48. package/dist/client/messages.js.map +0 -1
  49. package/dist/client/mockModel.d.ts +0 -42
  50. package/dist/client/mockModel.d.ts.map +0 -1
  51. package/dist/client/mockModel.js +0 -175
  52. package/dist/client/mockModel.js.map +0 -1
  53. package/dist/client/saveInputMessages.d.ts +0 -20
  54. package/dist/client/saveInputMessages.d.ts.map +0 -1
  55. package/dist/client/saveInputMessages.js +0 -58
  56. package/dist/client/saveInputMessages.js.map +0 -1
  57. package/dist/client/search.d.ts +0 -493
  58. package/dist/client/search.d.ts.map +0 -1
  59. package/dist/client/search.js +0 -425
  60. package/dist/client/search.js.map +0 -1
  61. package/dist/client/start.d.ts +0 -84
  62. package/dist/client/start.d.ts.map +0 -1
  63. package/dist/client/start.js +0 -171
  64. package/dist/client/start.js.map +0 -1
  65. package/dist/client/streamText.d.ts +0 -46
  66. package/dist/client/streamText.d.ts.map +0 -1
  67. package/dist/client/streamText.js +0 -93
  68. package/dist/client/streamText.js.map +0 -1
  69. package/dist/client/streaming.d.ts +0 -3767
  70. package/dist/client/streaming.d.ts.map +0 -1
  71. package/dist/client/streaming.js +0 -294
  72. package/dist/client/streaming.js.map +0 -1
  73. package/dist/client/threads.d.ts +0 -46
  74. package/dist/client/threads.d.ts.map +0 -1
  75. package/dist/client/threads.js +0 -49
  76. package/dist/client/threads.js.map +0 -1
  77. package/dist/client/types.d.ts +0 -460
  78. package/dist/client/types.d.ts.map +0 -1
  79. package/dist/client/types.js +0 -2
  80. package/dist/client/types.js.map +0 -1
  81. package/dist/client/utils.d.ts +0 -4
  82. package/dist/client/utils.d.ts.map +0 -1
  83. package/dist/client/utils.js +0 -21
  84. package/dist/client/utils.js.map +0 -1
  85. package/dist/component/_generated/api.d.ts +0 -48
  86. package/dist/component/_generated/api.d.ts.map +0 -1
  87. package/dist/component/_generated/api.js +0 -31
  88. package/dist/component/_generated/api.js.map +0 -1
  89. package/dist/component/_generated/component.d.ts +0 -3119
  90. package/dist/component/_generated/component.d.ts.map +0 -1
  91. package/dist/component/_generated/component.js +0 -11
  92. package/dist/component/_generated/component.js.map +0 -1
  93. package/dist/component/_generated/dataModel.d.ts +0 -46
  94. package/dist/component/_generated/dataModel.d.ts.map +0 -1
  95. package/dist/component/_generated/dataModel.js +0 -11
  96. package/dist/component/_generated/dataModel.js.map +0 -1
  97. package/dist/component/_generated/server.d.ts +0 -121
  98. package/dist/component/_generated/server.d.ts.map +0 -1
  99. package/dist/component/_generated/server.js +0 -78
  100. package/dist/component/_generated/server.js.map +0 -1
  101. package/dist/component/apiKeys.d.ts +0 -11
  102. package/dist/component/apiKeys.d.ts.map +0 -1
  103. package/dist/component/apiKeys.js +0 -69
  104. package/dist/component/apiKeys.js.map +0 -1
  105. package/dist/component/convex.config.d.ts +0 -3
  106. package/dist/component/convex.config.d.ts.map +0 -1
  107. package/dist/component/convex.config.js +0 -3
  108. package/dist/component/convex.config.js.map +0 -1
  109. package/dist/component/files.d.ts +0 -97
  110. package/dist/component/files.d.ts.map +0 -1
  111. package/dist/component/files.js +0 -190
  112. package/dist/component/files.js.map +0 -1
  113. package/dist/component/messages.d.ts +0 -3850
  114. package/dist/component/messages.d.ts.map +0 -1
  115. package/dist/component/messages.js +0 -747
  116. package/dist/component/messages.js.map +0 -1
  117. package/dist/component/schema.d.ts +0 -8026
  118. package/dist/component/schema.d.ts.map +0 -1
  119. package/dist/component/schema.js +0 -147
  120. package/dist/component/schema.js.map +0 -1
  121. package/dist/component/streams.d.ts +0 -128
  122. package/dist/component/streams.d.ts.map +0 -1
  123. package/dist/component/streams.js +0 -413
  124. package/dist/component/streams.js.map +0 -1
  125. package/dist/component/threads.d.ts +0 -115
  126. package/dist/component/threads.d.ts.map +0 -1
  127. package/dist/component/threads.js +0 -208
  128. package/dist/component/threads.js.map +0 -1
  129. package/dist/component/users.d.ts +0 -52
  130. package/dist/component/users.d.ts.map +0 -1
  131. package/dist/component/users.js +0 -229
  132. package/dist/component/users.js.map +0 -1
  133. package/dist/component/vector/index.d.ts +0 -61
  134. package/dist/component/vector/index.d.ts.map +0 -1
  135. package/dist/component/vector/index.js +0 -146
  136. package/dist/component/vector/index.js.map +0 -1
  137. package/dist/component/vector/tables.d.ts +0 -58
  138. package/dist/component/vector/tables.d.ts.map +0 -1
  139. package/dist/component/vector/tables.js +0 -56
  140. package/dist/component/vector/tables.js.map +0 -1
  141. package/dist/deltas.d.ts +0 -43
  142. package/dist/deltas.d.ts.map +0 -1
  143. package/dist/deltas.js +0 -447
  144. package/dist/deltas.js.map +0 -1
  145. package/dist/mapping.d.ts +0 -54
  146. package/dist/mapping.d.ts.map +0 -1
  147. package/dist/mapping.js +0 -626
  148. package/dist/mapping.js.map +0 -1
  149. package/dist/react/SmoothText.d.ts +0 -5
  150. package/dist/react/SmoothText.d.ts.map +0 -1
  151. package/dist/react/SmoothText.js +0 -6
  152. package/dist/react/SmoothText.js.map +0 -1
  153. package/dist/react/index.d.ts +0 -25
  154. package/dist/react/index.d.ts.map +0 -1
  155. package/dist/react/index.js +0 -70
  156. package/dist/react/index.js.map +0 -1
  157. package/dist/react/optimisticallySendMessage.d.ts +0 -42
  158. package/dist/react/optimisticallySendMessage.d.ts.map +0 -1
  159. package/dist/react/optimisticallySendMessage.js +0 -74
  160. package/dist/react/optimisticallySendMessage.js.map +0 -1
  161. package/dist/react/types.d.ts +0 -12
  162. package/dist/react/types.d.ts.map +0 -1
  163. package/dist/react/types.js +0 -2
  164. package/dist/react/types.js.map +0 -1
  165. package/dist/react/useDeltaStreams.d.ts +0 -10
  166. package/dist/react/useDeltaStreams.d.ts.map +0 -1
  167. package/dist/react/useDeltaStreams.js +0 -101
  168. package/dist/react/useDeltaStreams.js.map +0 -1
  169. package/dist/react/useSmoothText.d.ts +0 -27
  170. package/dist/react/useSmoothText.d.ts.map +0 -1
  171. package/dist/react/useSmoothText.js +0 -68
  172. package/dist/react/useSmoothText.js.map +0 -1
  173. package/dist/react/useStreamingUIMessages.d.ts +0 -22
  174. package/dist/react/useStreamingUIMessages.d.ts.map +0 -1
  175. package/dist/react/useStreamingUIMessages.js +0 -92
  176. package/dist/react/useStreamingUIMessages.js.map +0 -1
  177. package/dist/react/useThreadMessages.d.ts +0 -104
  178. package/dist/react/useThreadMessages.d.ts.map +0 -1
  179. package/dist/react/useThreadMessages.js +0 -148
  180. package/dist/react/useThreadMessages.js.map +0 -1
  181. package/dist/react/useUIMessages.d.ts +0 -96
  182. package/dist/react/useUIMessages.d.ts.map +0 -1
  183. package/dist/react/useUIMessages.js +0 -108
  184. package/dist/react/useUIMessages.js.map +0 -1
  185. package/dist/shared.d.ts +0 -26
  186. package/dist/shared.d.ts.map +0 -1
  187. package/dist/shared.js +0 -67
  188. package/dist/shared.js.map +0 -1
  189. package/dist/validators.d.ts +0 -24516
  190. package/dist/validators.d.ts.map +0 -1
  191. package/dist/validators.js +0 -475
  192. package/dist/validators.js.map +0 -1
package/MIGRATION.md ADDED
@@ -0,0 +1,153 @@
1
+ # Migration Guide: v0.3.x to v0.6.0 (AI SDK v6)
2
+
3
+ This guide helps you upgrade from @convex-dev/agent v0.3.x to v0.6.0.
4
+
5
+ ## Step 1: Update dependencies
6
+
7
+ Update all AI SDK packages **together** to avoid peer dependency conflicts:
8
+
9
+ ```bash
10
+ npm install @convex-dev/agent@^0.6.0 ai@^6.0.35 @ai-sdk/provider-utils@^4.0.6
11
+ ```
12
+
13
+ ### Official AI SDK providers
14
+
15
+ Update your AI SDK provider packages to v3.x:
16
+ ```bash
17
+ # For OpenAI
18
+ npm install @ai-sdk/openai@^3.0.10
19
+
20
+ # For Anthropic
21
+ npm install @ai-sdk/anthropic@^3.0.13
22
+
23
+ # For Groq
24
+ npm install @ai-sdk/groq@^3.0.8
25
+
26
+ # For Google (Gemini)
27
+ npm install @ai-sdk/google@^3.0.8
28
+ ```
29
+
30
+ ### Third-party providers
31
+
32
+ Third-party providers also need updates to be compatible with AI SDK v6:
33
+
34
+ ```bash
35
+ # For OpenRouter
36
+ npm install @openrouter/ai-sdk-provider@^2.0.0
37
+
38
+ # For other providers, check their documentation for AI SDK v6 compatibility
39
+ ```
40
+
41
+ ### Handling dependency conflicts
42
+
43
+ If you see peer dependency warnings or errors, try updating all packages at once:
44
+
45
+ ```bash
46
+ npm install @convex-dev/agent@^0.6.0 ai@^6.0.35 @ai-sdk/openai@^3.0.10 @openrouter/ai-sdk-provider@^2.0.0
47
+ ```
48
+
49
+ If you still have conflicts, you can use `--force` as a last resort:
50
+
51
+ ```bash
52
+ npm install @convex-dev/agent@^0.6.0 --force
53
+ ```
54
+
55
+ > **Note**: Using `--force` can lead to inconsistent dependency trees. After using it, verify your app works correctly and consider running `npm dedupe` to clean up.
56
+
57
+ ## Step 2: Update tool definitions
58
+
59
+ Replace `parameters` with `inputSchema`:
60
+
61
+ ```typescript
62
+ // Before (v5)
63
+ const myTool = createTool({
64
+ description: "My tool",
65
+ parameters: z.object({ query: z.string() }),
66
+ execute: async (ctx, args) => { ... }
67
+ })
68
+
69
+ // After (v6)
70
+ const myTool = createTool({
71
+ description: "My tool",
72
+ inputSchema: z.object({ query: z.string() }),
73
+ execute: async (ctx, input, options) => { ... }
74
+ })
75
+ ```
76
+
77
+ ## Step 3: Update Agent config (if using embeddings)
78
+
79
+ ```typescript
80
+ // Before
81
+ new Agent(components.agent, {
82
+ textEmbeddingModel: openai.embedding("text-embedding-3-small")
83
+ })
84
+
85
+ // After
86
+ new Agent(components.agent, {
87
+ embeddingModel: openai.embedding("text-embedding-3-small")
88
+ })
89
+ ```
90
+
91
+ ## Step 4: Update maxSteps (optional)
92
+
93
+ ```typescript
94
+ // Before
95
+ await agent.generateText(ctx, { threadId }, {
96
+ prompt: "...",
97
+ maxSteps: 5
98
+ })
99
+
100
+ // After (maxSteps still works, but stopWhen is preferred)
101
+ import { stepCountIs } from "ai"
102
+ await agent.generateText(ctx, { threadId }, {
103
+ prompt: "...",
104
+ stopWhen: stepCountIs(5)
105
+ })
106
+ ```
107
+
108
+ ## Step 5: Verify
109
+
110
+ ```bash
111
+ npm run typecheck
112
+ npm test
113
+ ```
114
+
115
+ ## Common Issues
116
+
117
+ ### EmbeddingModelV2 vs EmbeddingModelV3 type errors
118
+ Ensure all `@ai-sdk/*` packages are updated to v3.x. Older versions use AI SDK v5 types.
119
+
120
+ ### Tool `args` vs `input`
121
+ AI SDK v6 renamed `args` to `input` in tool calls. The library maintains backwards compatibility, but you may see this in types.
122
+
123
+ ### `mimeType` vs `mediaType`
124
+ AI SDK v6 renamed `mimeType` to `mediaType`. Backwards compatibility is maintained.
125
+
126
+ ### Peer dependency conflicts
127
+
128
+ If you see errors like:
129
+ ```
130
+ npm error ERESOLVE unable to resolve dependency tree
131
+ npm error peer ai@"^5.0.0" from @openrouter/ai-sdk-provider@1.0.3
132
+ ```
133
+
134
+ This means a third-party provider needs updating. Common solutions:
135
+
136
+ 1. **Update the provider** to a version compatible with AI SDK v6
137
+ 2. **Check npm** for the latest version: `npm view @openrouter/ai-sdk-provider versions`
138
+ 3. **Use `--force`** if a compatible version isn't available yet (temporary workaround)
139
+
140
+ ### Third-party provider compatibility
141
+
142
+ | Provider | AI SDK v5 (ai@5.x) | AI SDK v6 (ai@6.x) |
143
+ |----------|-------------------|-------------------|
144
+ | @openrouter/ai-sdk-provider | v1.x | v2.x |
145
+ | @ai-sdk/openai | v1.x-v2.x | v3.x |
146
+ | @ai-sdk/anthropic | v1.x-v2.x | v3.x |
147
+ | @ai-sdk/groq | v1.x-v2.x | v3.x |
148
+ | @ai-sdk/google | v1.x-v2.x | v3.x |
149
+
150
+ ## More Information
151
+
152
+ - [AI SDK v6 Migration Guide](https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0)
153
+ - [Convex Agent Documentation](https://docs.convex.dev/agents)
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-alpha.0",
10
+ "version": "0.6.0",
11
11
  "license": "Apache-2.0",
12
12
  "keywords": [
13
13
  "convex",
@@ -41,7 +41,8 @@
41
41
  },
42
42
  "files": [
43
43
  "dist",
44
- "src"
44
+ "src",
45
+ "MIGRATION.md"
45
46
  ],
46
47
  "exports": {
47
48
  "./package.json": "./package.json",
@@ -84,6 +85,7 @@
84
85
  },
85
86
  "devDependencies": {
86
87
  "@ai-sdk/anthropic": "^3.0.13",
88
+ "@ai-sdk/google": "^3.0.30",
87
89
  "@ai-sdk/groq": "^3.0.8",
88
90
  "@ai-sdk/openai": "^3.0.10",
89
91
  "@ai-sdk/provider": "^3.0.3",
package/src/UIMessages.ts CHANGED
@@ -396,6 +396,54 @@ function createAssistantUIMessage<
396
396
  ? ("streaming" as const)
397
397
  : lastMessage.status;
398
398
 
399
+ <<<<<<< Updated upstream
400
+ =======
401
+ // Extract approval parts from raw message content for UI rendering
402
+ type ApprovalPart =
403
+ | { type: "tool-approval-request"; approvalId: string; toolCallId: string }
404
+ | {
405
+ type: "tool-approval-response";
406
+ approvalId: string;
407
+ approved: boolean;
408
+ reason?: string;
409
+ };
410
+ const approvalParts: ApprovalPart[] = [];
411
+
412
+ // Extract execution-denied tool results from raw content for UI rendering
413
+ // (these are converted to text format for provider compatibility in start.ts)
414
+ type ExecutionDeniedInfo = {
415
+ toolCallId: string;
416
+ reason?: string;
417
+ };
418
+ const executionDeniedResults: ExecutionDeniedInfo[] = [];
419
+
420
+ for (const message of group) {
421
+ const rawContent = message.message?.content;
422
+ if (Array.isArray(rawContent)) {
423
+ for (const part of rawContent) {
424
+ if (
425
+ part.type === "tool-approval-request" ||
426
+ part.type === "tool-approval-response"
427
+ ) {
428
+ approvalParts.push(part as ApprovalPart);
429
+ }
430
+ // Check for execution-denied in tool-result outputs
431
+ if (
432
+ part.type === "tool-result" &&
433
+ typeof part.output === "object" &&
434
+ part.output !== null &&
435
+ (part.output as { type?: string }).type === "execution-denied"
436
+ ) {
437
+ executionDeniedResults.push({
438
+ toolCallId: part.toolCallId as string,
439
+ reason: (part.output as { reason?: string }).reason,
440
+ });
441
+ }
442
+ }
443
+ }
444
+ }
445
+
446
+ >>>>>>> Stashed changes
399
447
  // Collect all parts from all messages
400
448
  const allParts: UIMessage<METADATA, DATA_PARTS, TOOLS>["parts"] = [];
401
449
 
@@ -477,6 +525,12 @@ function createAssistantUIMessage<
477
525
  break;
478
526
  }
479
527
  case "tool-result": {
528
+ <<<<<<< Updated upstream
529
+ =======
530
+ // Note: execution-denied outputs are handled separately via pre-extraction
531
+ // from raw content (converted to text format for providers in start.ts).
532
+ // See executionDeniedResults processing at the end of this function.
533
+ >>>>>>> Stashed changes
480
534
  const typedPart = contentPart as unknown as ToolResultPart & {
481
535
  output: { type: string; value?: unknown; reason?: string };
482
536
  };
@@ -642,6 +696,87 @@ function createAssistantUIMessage<
642
696
  }
643
697
  }
644
698
 
699
+ <<<<<<< Updated upstream
700
+ =======
701
+ // Final output states that should not be overwritten by approval processing
702
+ const finalStates = new Set([
703
+ "output-available",
704
+ "output-error",
705
+ "output-denied",
706
+ ]);
707
+
708
+ // Process approval parts to update tool call states
709
+ for (const approvalPart of approvalParts) {
710
+ if (approvalPart.type === "tool-approval-request") {
711
+ const toolCallPart = allParts.find(
712
+ (part) =>
713
+ "toolCallId" in part && part.toolCallId === approvalPart.toolCallId,
714
+ ) as ToolUIPart | undefined;
715
+
716
+ if (toolCallPart) {
717
+ // Always set approval info (needed for response matching), but only
718
+ // update state if not in a final state
719
+ (toolCallPart as ToolUIPart & { approval?: object }).approval = {
720
+ id: approvalPart.approvalId,
721
+ };
722
+ if (!finalStates.has(toolCallPart.state)) {
723
+ toolCallPart.state = "approval-requested";
724
+ }
725
+ }
726
+ } else if (approvalPart.type === "tool-approval-response") {
727
+ const toolCallPart = allParts.find(
728
+ (part) =>
729
+ "approval" in part &&
730
+ (part as ToolUIPart & { approval?: { id: string } }).approval?.id ===
731
+ approvalPart.approvalId,
732
+ ) as ToolUIPart | undefined;
733
+
734
+ if (toolCallPart) {
735
+ // Always update approval info, but only update state if not in a final state
736
+ (toolCallPart as ToolUIPart & { approval?: object }).approval = {
737
+ id: approvalPart.approvalId,
738
+ approved: approvalPart.approved,
739
+ reason: approvalPart.reason,
740
+ };
741
+ if (!finalStates.has(toolCallPart.state)) {
742
+ if (approvalPart.approved) {
743
+ toolCallPart.state = "approval-responded";
744
+ } else {
745
+ toolCallPart.state = "output-denied";
746
+ }
747
+ }
748
+ }
749
+ }
750
+ }
751
+
752
+ // Process execution-denied results to update tool call states
753
+ for (const denied of executionDeniedResults) {
754
+ const toolCallPart = allParts.find(
755
+ (part) =>
756
+ "toolCallId" in part && part.toolCallId === denied.toolCallId,
757
+ ) as ToolUIPart | undefined;
758
+
759
+ if (toolCallPart) {
760
+ toolCallPart.state = "output-denied";
761
+ if (!("approval" in toolCallPart) || !toolCallPart.approval) {
762
+ (toolCallPart as ToolUIPart & { approval?: object }).approval = {
763
+ id: "",
764
+ approved: false,
765
+ reason: denied.reason,
766
+ };
767
+ } else {
768
+ const approval = (
769
+ toolCallPart as ToolUIPart & {
770
+ approval: { approved?: boolean; reason?: string };
771
+ }
772
+ ).approval;
773
+ approval.approved = false;
774
+ approval.reason = denied.reason;
775
+ }
776
+ }
777
+ }
778
+
779
+ >>>>>>> Stashed changes
645
780
  return {
646
781
  ...common,
647
782
  role: "assistant",
@@ -0,0 +1,350 @@
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
+ // --- Agents (separate mock model instances to avoid shared callIndex) ---
61
+
62
+ const approvalAgent = new Agent(components.agent, {
63
+ name: "approval-test",
64
+ instructions: "You delete files when asked.",
65
+ tools: { deleteFile: deleteFileTool },
66
+ languageModel: mockModel({
67
+ contentSteps: [
68
+ // Step 1: model makes a tool call (LanguageModelV3 uses `input` as JSON string)
69
+ [
70
+ {
71
+ type: "tool-call",
72
+ toolCallId: "tc-approve",
73
+ toolName: "deleteFile",
74
+ input: JSON.stringify({ filename: "test.txt" }),
75
+ },
76
+ ],
77
+ // Step 2: after tool execution, model responds with text
78
+ [{ type: "text", text: "Done! I deleted test.txt." }],
79
+ ],
80
+ }),
81
+ stopWhen: stepCountIs(5),
82
+ usageHandler: testUsageHandler,
83
+ });
84
+
85
+ const denialAgent = new Agent(components.agent, {
86
+ name: "denial-test",
87
+ instructions: "You delete files when asked.",
88
+ tools: { deleteFile: deleteFileTool },
89
+ languageModel: mockModel({
90
+ contentSteps: [
91
+ [
92
+ {
93
+ type: "tool-call",
94
+ toolCallId: "tc-deny",
95
+ toolName: "deleteFile",
96
+ input: JSON.stringify({ filename: "secret.txt" }),
97
+ },
98
+ ],
99
+ [{ type: "text", text: "OK, I won't delete that file." }],
100
+ ],
101
+ }),
102
+ stopWhen: stepCountIs(5),
103
+ usageHandler: testUsageHandler,
104
+ });
105
+
106
+ // --- Test helpers ---
107
+
108
+ export const testApproveFlow = action({
109
+ args: {},
110
+ handler: async (ctx) => {
111
+ const { thread } = await approvalAgent.createThread(ctx, { userId: "u1" });
112
+
113
+ // Step 1: Generate text — model returns tool call, SDK sees needsApproval → stops
114
+ const result1 = await thread.generateText({
115
+ prompt: "Delete test.txt",
116
+ });
117
+
118
+ const approvalId = getApprovalIdFromSavedMessages(result1.savedMessages);
119
+
120
+ // Step 2: Approve the tool call
121
+ const { messageId } = await ctx.runMutation(
122
+ anyApi["approval.test"].submitApprovalForApprovalAgent,
123
+ { threadId: thread.threadId, approvalId },
124
+ );
125
+
126
+ // Step 3: Continue generation — SDK executes tool, model responds
127
+ const result2 = await thread.generateText({
128
+ promptMessageId: messageId,
129
+ });
130
+
131
+ // Verify thread has all messages persisted
132
+ const allMessages = await approvalAgent.listMessages(ctx, {
133
+ threadId: thread.threadId,
134
+ paginationOpts: { cursor: null, numItems: 20 },
135
+ });
136
+
137
+ return {
138
+ approvalId,
139
+ firstText: result1.text,
140
+ secondText: result2.text,
141
+ firstSavedCount: result1.savedMessages?.length ?? 0,
142
+ secondSavedCount: result2.savedMessages?.length ?? 0,
143
+ totalThreadMessages: allMessages.page.length,
144
+ threadMessageRoles: allMessages.page.map((m) => m.message?.role),
145
+ usageCallCount: usageCalls.length,
146
+ // Verify usage data includes detail fields (AI SDK v6)
147
+ lastUsage: usageCalls.at(-1),
148
+ };
149
+ },
150
+ });
151
+
152
+ export const testDenyFlow = action({
153
+ args: {},
154
+ handler: async (ctx) => {
155
+ const { thread } = await denialAgent.createThread(ctx, { userId: "u2" });
156
+
157
+ // Step 1: Generate — model returns tool call, approval requested
158
+ const result1 = await thread.generateText({
159
+ prompt: "Delete secret.txt",
160
+ });
161
+
162
+ const approvalId = getApprovalIdFromSavedMessages(result1.savedMessages);
163
+
164
+ // Step 2: Deny the tool call
165
+ const { messageId } = await ctx.runMutation(
166
+ anyApi["approval.test"].submitDenialForDenialAgent,
167
+ {
168
+ threadId: thread.threadId,
169
+ approvalId,
170
+ reason: "This file is important",
171
+ },
172
+ );
173
+
174
+ // Step 3: Continue generation — SDK creates execution-denied, model responds
175
+ const result2 = await thread.generateText({
176
+ promptMessageId: messageId,
177
+ });
178
+
179
+ // Verify thread state
180
+ const allMessages = await denialAgent.listMessages(ctx, {
181
+ threadId: thread.threadId,
182
+ paginationOpts: { cursor: null, numItems: 20 },
183
+ });
184
+
185
+ return {
186
+ approvalId,
187
+ firstText: result1.text,
188
+ secondText: result2.text,
189
+ totalThreadMessages: allMessages.page.length,
190
+ threadMessageRoles: allMessages.page.map((m) => m.message?.role),
191
+ usageCallCount: usageCalls.length,
192
+ lastUsage: usageCalls.at(-1),
193
+ };
194
+ },
195
+ });
196
+
197
+ export const testApproveFlowWithInterveningMessage = action({
198
+ args: {},
199
+ handler: async (ctx) => {
200
+ const { thread } = await approvalAgent.createThread(ctx, { userId: "u3" });
201
+
202
+ const result1 = await thread.generateText({
203
+ prompt: "Delete test.txt",
204
+ });
205
+ const approvalId = getApprovalIdFromSavedMessages(result1.savedMessages);
206
+
207
+ const approvalRequest = (
208
+ await approvalAgent.listMessages(ctx, {
209
+ threadId: thread.threadId,
210
+ paginationOpts: { cursor: null, numItems: 20 },
211
+ })
212
+ ).page.find((m) => {
213
+ const content = m.message?.content;
214
+ return (
215
+ Array.isArray(content) &&
216
+ content.some(
217
+ (p) =>
218
+ p.type === "tool-approval-request" && p.approvalId === approvalId,
219
+ )
220
+ );
221
+ });
222
+ if (!approvalRequest) {
223
+ throw new Error("Approval request message not found");
224
+ }
225
+
226
+ const intervening = await approvalAgent.saveMessage(ctx, {
227
+ threadId: thread.threadId,
228
+ prompt: "Intervening user message",
229
+ skipEmbeddings: true,
230
+ });
231
+
232
+ const { messageId } = await ctx.runMutation(
233
+ anyApi["approval.test"].submitApprovalForApprovalAgent,
234
+ { threadId: thread.threadId, approvalId },
235
+ );
236
+
237
+ const result2 = await thread.generateText({
238
+ promptMessageId: messageId,
239
+ });
240
+
241
+ const allMessages = await approvalAgent.listMessages(ctx, {
242
+ threadId: thread.threadId,
243
+ paginationOpts: { cursor: null, numItems: 40 },
244
+ });
245
+ const approvalResponse = allMessages.page.find((m) => m._id === messageId);
246
+ if (!approvalResponse) {
247
+ throw new Error("Saved approval response message not found");
248
+ }
249
+
250
+ return {
251
+ secondText: result2.text,
252
+ approvalResponseOrder: approvalResponse.order,
253
+ approvalRequestId: approvalRequest._id,
254
+ approvalRequestOrder: approvalRequest.order,
255
+ interveningOrder: intervening.message.order,
256
+ };
257
+ },
258
+ });
259
+
260
+ export const submitApprovalForApprovalAgent = mutation({
261
+ args: { threadId: v.string(), approvalId: v.string(), reason: v.optional(v.string()) },
262
+ handler: async (ctx, { threadId, approvalId, reason }) => {
263
+ return approvalAgent.approveToolCall(ctx, { threadId, approvalId, reason });
264
+ },
265
+ });
266
+
267
+ export const submitDenialForDenialAgent = mutation({
268
+ args: { threadId: v.string(), approvalId: v.string(), reason: v.optional(v.string()) },
269
+ handler: async (ctx, { threadId, approvalId, reason }) => {
270
+ return denialAgent.denyToolCall(ctx, { threadId, approvalId, reason });
271
+ },
272
+ });
273
+
274
+ const testApi: ApiFromModules<{
275
+ fns: {
276
+ testApproveFlow: typeof testApproveFlow;
277
+ testDenyFlow: typeof testDenyFlow;
278
+ testApproveFlowWithInterveningMessage: typeof testApproveFlowWithInterveningMessage;
279
+ submitApprovalForApprovalAgent: typeof submitApprovalForApprovalAgent;
280
+ submitDenialForDenialAgent: typeof submitDenialForDenialAgent;
281
+ };
282
+ }>["fns"] = anyApi["approval.test"] as any;
283
+
284
+ describe("Tool Approval Workflow", () => {
285
+ test("approve: generate → approval request → approve → tool executes → final text", async () => {
286
+ usageCalls.length = 0;
287
+ const t = initConvexTest(schema);
288
+ const result = await t.action(testApi.testApproveFlow, {});
289
+
290
+ expect(result.approvalId).toBeDefined();
291
+ // First call produces no text (just a tool call)
292
+ expect(result.firstText).toBe("");
293
+ // Second call produces the final text
294
+ expect(result.secondText).toBe("Done! I deleted test.txt.");
295
+ // First call: user message + assistant (tool-call + approval-request)
296
+ expect(result.firstSavedCount).toBeGreaterThanOrEqual(2);
297
+ // Second call: tool-result + assistant text
298
+ expect(result.secondSavedCount).toBeGreaterThanOrEqual(1);
299
+ // Thread should have (ascending): user, assistant(tool-call+approval),
300
+ // tool(approval-response), tool(tool-result), assistant(text)
301
+ // listMessages returns descending order:
302
+ expect(result.threadMessageRoles).toEqual([
303
+ "assistant", // final text
304
+ "tool", // tool-result
305
+ "tool", // approval-response
306
+ "assistant", // tool-call + approval-request
307
+ "user", // prompt
308
+ ]);
309
+ // Usage handler should be called for each generateText call
310
+ expect(result.usageCallCount).toBeGreaterThanOrEqual(2);
311
+ // Usage data should include AI SDK v6 detail fields
312
+ expect(result.lastUsage).toBeDefined();
313
+ expect(result.lastUsage!.inputTokenDetails).toBeDefined();
314
+ expect(result.lastUsage!.outputTokenDetails).toBeDefined();
315
+ });
316
+
317
+ test("deny: generate → approval request → deny → model acknowledges denial", async () => {
318
+ usageCalls.length = 0;
319
+ const t = initConvexTest(schema);
320
+ const result = await t.action(testApi.testDenyFlow, {});
321
+
322
+ expect(result.approvalId).toBeDefined();
323
+ expect(result.firstText).toBe("");
324
+ expect(result.secondText).toBe("OK, I won't delete that file.");
325
+ // Same message ordering as approve flow:
326
+ // user, assistant(tool-call+approval), tool(denial-response),
327
+ // tool(execution-denied result), assistant(text)
328
+ expect(result.threadMessageRoles).toEqual([
329
+ "assistant",
330
+ "tool",
331
+ "tool",
332
+ "assistant",
333
+ "user",
334
+ ]);
335
+ // Usage handler exercised
336
+ expect(result.usageCallCount).toBeGreaterThanOrEqual(2);
337
+ expect(result.lastUsage!.inputTokenDetails).toBeDefined();
338
+ expect(result.lastUsage!.outputTokenDetails).toBeDefined();
339
+ });
340
+
341
+ test("approve remains valid with an intervening thread message", async () => {
342
+ usageCalls.length = 0;
343
+ const t = initConvexTest(schema);
344
+ const result = await t.action(testApi.testApproveFlowWithInterveningMessage, {});
345
+
346
+ expect(result.secondText).toBe("Done! I deleted test.txt.");
347
+ expect(result.approvalResponseOrder).toBe(result.approvalRequestOrder);
348
+ expect(result.interveningOrder).toBeGreaterThan(result.approvalResponseOrder);
349
+ });
350
+ });