diff --git a/.cursor/rules/convex_rules.mdc b/.cursor/rules/convex_rules.mdc index 1d984804..546ca29a 100644 --- a/.cursor/rules/convex_rules.mdc +++ b/.cursor/rules/convex_rules.mdc @@ -159,7 +159,7 @@ export const listWithExtraArg = query({ handler: async (ctx, args) => { return await ctx.db .query("messages") - .filter((q) => q.eq(q.field("author"), args.author)) + .withIndex("by_author", (q) => q.eq("author", args.author)) .order("desc") .paginate(args.paginationOpts); }, @@ -180,7 +180,7 @@ Note: `paginationOpts` is an object with the following properties: ## Schema guidelines - Always define your schema in `convex/schema.ts`. -- Always import the schema definition functions from `convex/server`: +- Always import the schema definition functions from `convex/server`. - System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. - Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". - Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. @@ -198,7 +198,7 @@ export const exampleQuery = query({ handler: async (ctx, args) => { const idToUsername: Record, string> = {}; for (const userId of args.userIds) { - const user = await ctx.db.get(userId); + const user = await ctx.db.get("users", userId); if (user) { idToUsername[user._id] = user.username; } @@ -236,8 +236,8 @@ const messages = await ctx.db ## Mutation guidelines -- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. -- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. +- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` +- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })` ## Action guidelines - Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. @@ -307,7 +307,7 @@ export const exampleQuery = query({ args: { fileId: v.id("_storage") }, returns: v.null(), handler: async (ctx, args) => { - const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId); + const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId); console.log(metadata); return null; }, @@ -434,7 +434,7 @@ Internal Functions: "description": "This example shows how to build a chat app without authentication.", "version": "1.0.0", "dependencies": { - "convex": "^1.17.4", + "convex": "^1.31.2", "openai": "^4.79.0" }, "devDependencies": { @@ -667,6 +667,35 @@ export default defineSchema({ }); ``` +#### convex/tsconfig.json +```typescript +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2021", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} +``` + #### src/App.tsx ```typescript export default function App() { diff --git a/CLAUDE.md b/CLAUDE.md index 19fa3569..ab8dfbd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,7 @@ npm run typecheck # Full TypeScript validation including example/convex - **Message-centric design** - All operations revolve around message persistence/retrieval - **Component-based encapsulation** - Uses Convex Components pattern - **Multi-user support** - Threads have optional `userId` for ownership +- **Tool approval flow** - Tools can require human approval via `needsApproval`, with `agent.approveToolCall()` and `agent.denyToolCall()` methods ### Database Tables (Convex Component) @@ -87,82 +88,138 @@ Follow the rules in `.cursor/rules/convex_rules.mdc`: - `@convex-dev/agent/validators` - Convex validators for integration - `@convex-dev/agent/test` - Testing utilities -## Upgrading to v0.6.0 (AI SDK v6) +## AI Guidance: Helping Users Upgrade to v0.6.0 -When helping users upgrade from @convex-dev/agent v0.3.x to v0.6.0, follow these steps: +**IMPORTANT: v0.6.0 requires AI SDK v6 (ai@^6.0.0)** -### Step 1: Update dependencies in package.json +When helping users upgrade from @convex-dev/agent v0.3.x (AI SDK v5) to v0.6.0 (AI SDK v6): + +### Step 1: Update Dependencies First + +Update all AI SDK packages together to avoid peer dependency conflicts: ```bash npm install @convex-dev/agent@^0.6.0 ai@^6.0.35 @ai-sdk/provider-utils@^4.0.6 +npm install @ai-sdk/openai@^3.0.10 # or whichever provider ``` -Also update any AI SDK provider packages: -```bash -npm install @ai-sdk/openai@^3.0.10 @ai-sdk/anthropic@^3.0.13 -``` +**Compatible sibling packages:** +- `@convex-dev/rag@^0.7.0` (v0.6.0 has type conflicts with AI SDK v6) +- `@convex-dev/workflow@^0.3.2` + +### Step 2: Detect v5 Patterns -### Step 2: Update tool definitions +Search for these patterns indicating v5 usage: +- `createTool({ args:` - should be `inputSchema` +- `createTool({ handler:` - should be `execute` +- `textEmbeddingModel:` - should be `embeddingModel` +- `maxSteps:` in generateText/streamText - should be `stopWhen: stepCountIs(N)` +- `mode: "json"` in generateObject - removed in v6 +- `@ai-sdk/*` packages at v1.x or v2.x - should be v3.x +- Type imports: `LanguageModelV2` → `LanguageModelV3`, `EmbeddingModel` → `EmbeddingModelV3` -Replace `parameters` with `inputSchema`: +### Step 3: Apply Transformations +**Tool definitions:** ```typescript -// Before (v5) +// BEFORE (v5) const myTool = createTool({ description: "...", parameters: z.object({ query: z.string() }), - execute: async (ctx, args) => { ... } + handler: async (ctx, args) => { + return args.query.toUpperCase(); + } }) -// After (v6) +// AFTER (v6) const myTool = createTool({ description: "...", inputSchema: z.object({ query: z.string() }), - execute: async (ctx, input, options) => { ... } + execute: async (ctx, input, options) => { + return input.query.toUpperCase(); + } }) ``` -### Step 3: Update maxSteps usage (if applicable) +**Agent embedding config:** +```typescript +// BEFORE +new Agent(components.agent, { + textEmbeddingModel: openai.embedding("text-embedding-3-small") +}) + +// AFTER +new Agent(components.agent, { + embeddingModel: openai.embedding("text-embedding-3-small") +}) +``` +**Step limits:** ```typescript -// Before (v5) +// BEFORE await agent.generateText(ctx, { threadId }, { prompt: "...", maxSteps: 5 }) -// After (v6) - maxSteps still works but stopWhen is preferred -import { stepCountIs } from "ai" +// AFTER +import { stepCountIs } from "@convex-dev/agent" await agent.generateText(ctx, { threadId }, { prompt: "...", stopWhen: stepCountIs(5) }) ``` -### Step 4: Update embedding model config (optional) +**Type imports:** +```typescript +// BEFORE (v5) +import type { LanguageModelV2 } from "@ai-sdk/provider"; +import type { EmbeddingModel } from "ai"; +let model: LanguageModelV2; +let embedder: EmbeddingModel; + +// AFTER (v6) +import type { LanguageModelV3, EmbeddingModelV3 } from "@ai-sdk/provider"; +let model: LanguageModelV3; +let embedder: EmbeddingModelV3; +``` +**generateObject (remove mode: "json"):** ```typescript -// Before -new Agent(components.agent, { - textEmbeddingModel: openai.embedding("text-embedding-3-small") +// BEFORE (v5) +await generateObject({ + model, + mode: "json", + schema: z.object({ ... }), + prompt: "..." }) -// After (textEmbeddingModel still works but embeddingModel is preferred) -new Agent(components.agent, { - embeddingModel: openai.embedding("text-embedding-3-small") +// AFTER (v6) - mode: "json" removed, just use schema +await generateObject({ + model, + schema: z.object({ ... }), + prompt: "..." }) ``` -### Step 5: Verify the upgrade +### Step 4: Verify ```bash npm run typecheck -npm run lint npm test ``` -### Common issues +### Common Issues + +- **EmbeddingModelV2 vs V3 errors**: Ensure all `@ai-sdk/*` packages are v3.x +- **Tool `args` vs `input`**: v6 uses `input` in execute signature (2nd param) +- **`mimeType` vs `mediaType`**: v6 prefers `mediaType` (backwards compat maintained) +- **Type import errors**: `LanguageModelV2` is now `LanguageModelV3`, `EmbeddingModel` is now `EmbeddingModelV3` (no longer generic) +- **generateObject mode errors**: `mode: "json"` was removed in v6 - just remove the mode option + +### New v6 Features to Mention -- **EmbeddingModelV2 vs V3 errors**: Ensure all @ai-sdk/* packages are updated to v3.x -- **Tool input/args**: v6 uses `input` instead of `args` in tool calls (backwards compat maintained) -- **mimeType vs mediaType**: v6 uses `mediaType` (backwards compat maintained) +After upgrade, users can now use: +- **Tool approval**: `needsApproval` in createTool, `agent.approveToolCall()`, `agent.denyToolCall()` +- **Reasoning streaming**: Works with models like Groq that support reasoning +- **Detailed token usage**: `inputTokenDetails`, `outputTokenDetails` in usage tracking diff --git a/MIGRATION.md b/MIGRATION.md index 6eff33f7..fdfc09bc 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -4,10 +4,14 @@ This guide helps you upgrade from @convex-dev/agent v0.3.x to v0.6.0. ## Step 1: Update dependencies +Update all AI SDK packages **together** to avoid peer dependency conflicts: + ```bash npm install @convex-dev/agent@^0.6.0 ai@^6.0.35 @ai-sdk/provider-utils@^4.0.6 ``` +### Official AI SDK providers + Update your AI SDK provider packages to v3.x: ```bash # For OpenAI @@ -18,8 +22,38 @@ npm install @ai-sdk/anthropic@^3.0.13 # For Groq npm install @ai-sdk/groq@^3.0.8 + +# For Google (Gemini) +npm install @ai-sdk/google@^3.0.8 +``` + +### Third-party providers + +Third-party providers also need updates to be compatible with AI SDK v6: + +```bash +# For OpenRouter +npm install @openrouter/ai-sdk-provider@^2.0.0 + +# For other providers, check their documentation for AI SDK v6 compatibility +``` + +### Handling dependency conflicts + +If you see peer dependency warnings or errors, try updating all packages at once: + +```bash +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 ``` +If you still have conflicts, you can use `--force` as a last resort: + +```bash +npm install @convex-dev/agent@^0.6.0 --force +``` + +> **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. + ## Step 2: Update tool definitions Replace `parameters` with `inputSchema`: @@ -89,6 +123,30 @@ AI SDK v6 renamed `args` to `input` in tool calls. The library maintains backwar ### `mimeType` vs `mediaType` AI SDK v6 renamed `mimeType` to `mediaType`. Backwards compatibility is maintained. +### Peer dependency conflicts + +If you see errors like: +``` +npm error ERESOLVE unable to resolve dependency tree +npm error peer ai@"^5.0.0" from @openrouter/ai-sdk-provider@1.0.3 +``` + +This means a third-party provider needs updating. Common solutions: + +1. **Update the provider** to a version compatible with AI SDK v6 +2. **Check npm** for the latest version: `npm view @openrouter/ai-sdk-provider versions` +3. **Use `--force`** if a compatible version isn't available yet (temporary workaround) + +### Third-party provider compatibility + +| Provider | AI SDK v5 (ai@5.x) | AI SDK v6 (ai@6.x) | +|----------|-------------------|-------------------| +| @openrouter/ai-sdk-provider | v1.x | v2.x | +| @ai-sdk/openai | v1.x-v2.x | v3.x | +| @ai-sdk/anthropic | v1.x-v2.x | v3.x | +| @ai-sdk/groq | v1.x-v2.x | v3.x | +| @ai-sdk/google | v1.x-v2.x | v3.x | + ## More Information - [AI SDK v6 Migration Guide](https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0) diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index 835cc4f1..b7cf3c58 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -8,11 +8,13 @@ * @module */ +import type * as agents_approval from "../agents/approval.js"; import type * as agents_config from "../agents/config.js"; import type * as agents_fashion from "../agents/fashion.js"; import type * as agents_simple from "../agents/simple.js"; import type * as agents_story from "../agents/story.js"; import type * as agents_weather from "../agents/weather.js"; +import type * as chat_approval from "../chat/approval.js"; import type * as chat_basic from "../chat/basic.js"; import type * as chat_human from "../chat/human.js"; import type * as chat_streamAbort from "../chat/streamAbort.js"; @@ -55,11 +57,13 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + "agents/approval": typeof agents_approval; "agents/config": typeof agents_config; "agents/fashion": typeof agents_fashion; "agents/simple": typeof agents_simple; "agents/story": typeof agents_story; "agents/weather": typeof agents_weather; + "chat/approval": typeof chat_approval; "chat/basic": typeof chat_basic; "chat/human": typeof chat_human; "chat/streamAbort": typeof chat_streamAbort; diff --git a/example/convex/_generated/dataModel.d.ts b/example/convex/_generated/dataModel.d.ts index 8541f319..f97fd194 100644 --- a/example/convex/_generated/dataModel.d.ts +++ b/example/convex/_generated/dataModel.d.ts @@ -38,7 +38,7 @@ export type Doc = DocumentByName< * Convex documents are uniquely identified by their `Id`, which is accessible * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * - * Documents can be loaded using `db.get(id)` in query and mutation functions. + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. diff --git a/example/convex/agents/approval.ts b/example/convex/agents/approval.ts new file mode 100644 index 00000000..fb3e5bbf --- /dev/null +++ b/example/convex/agents/approval.ts @@ -0,0 +1,82 @@ +// Tool Approval Demo Agent +// Demonstrates the AI SDK v6 tool approval workflow +import { Agent, createTool, stepCountIs } from "@convex-dev/agent"; +import { components } from "../_generated/api"; +import { defaultConfig } from "./config"; +import { z } from "zod/v4"; + +// A tool that always requires approval before execution +const deleteFileTool = createTool({ + description: "Delete a file from the system. This is a destructive operation.", + inputSchema: z.object({ + filename: z.string().describe("The name of the file to delete"), + }), + needsApproval: (_ctx, input) => { + console.log("needsApproval called for deleteFile:", input); + return true; + }, + execute: async (_ctx, input) => { + console.log("execute called for deleteFile:", input); + // Simulated file deletion + return `Successfully deleted file: ${input.filename}`; + }, +}); + +// A tool that conditionally requires approval based on the amount +const transferMoneyTool = createTool({ + description: "Transfer money to an account", + inputSchema: z.object({ + amount: z.number().describe("Amount in dollars to transfer"), + toAccount: z.string().describe("Target account ID"), + }), + // Only require approval for transfers over $100 + needsApproval: async (_ctx, input) => { + return input.amount > 100; + }, + execute: async (_ctx, input) => { + // Simulated money transfer + return `Transferred $${input.amount} to account ${input.toAccount}`; + }, +}); + +// A safe tool that never requires approval +const checkBalanceTool = createTool({ + description: "Check the current account balance", + inputSchema: z.object({ + accountId: z.string().describe("Account ID to check"), + }), + execute: async (_ctx, input) => { + // Simulated balance check + const balance = Math.floor(Math.random() * 10000); + return `Account ${input.accountId} has a balance of $${balance}`; + }, +}); + +// The approval demo agent +export const approvalAgent = new Agent(components.agent, { + name: "Approval Demo Agent", + instructions: `You are a helpful assistant that can manage files and money transfers. + +You have access to these tools: +- deleteFile: Delete a file (requires user approval) +- transferMoney: Transfer money (requires approval for amounts over $100) +- checkBalance: Check account balance (no approval needed) + +IMPORTANT: When you call a tool, STOP immediately after the tool call. Do NOT write any text after the tool call. Do NOT assume the tool will succeed. Wait for the tool result before providing any confirmation or status update to the user. + +Use tools when the user asks you to perform an action. For general questions or conversation, just respond normally without calling tools. + +This is a demo application - all operations are simulated and safe.`, + tools: { + deleteFile: deleteFileTool, + transferMoney: transferMoneyTool, + checkBalance: checkBalanceTool, + }, + stopWhen: stepCountIs(5), + ...defaultConfig, + // Override settings to make tool calling more reliable + callSettings: { + ...defaultConfig.callSettings, + temperature: 0, + }, +}); diff --git a/example/convex/agents/fashion.ts b/example/convex/agents/fashion.ts index b6268f17..b0cfb6ec 100644 --- a/example/convex/agents/fashion.ts +++ b/example/convex/agents/fashion.ts @@ -11,15 +11,15 @@ export const fashionAgent = new Agent(components.agent, { tools: { getUserPreferences: createTool({ description: "Get clothing preferences for a user", - args: z.object({ + inputSchema: z.object({ search: z.string().describe("Which preferences are requested"), }), - handler: async (ctx, args) => { - console.log("getting user preferences", args); + execute: async (ctx, input) => { + console.log("getting user preferences", input); return { userId: ctx.userId, threadId: ctx.threadId, - search: args.search, + search: input.search, information: `The user likes to look stylish`, }; }, diff --git a/example/convex/agents/story.ts b/example/convex/agents/story.ts index 59c1009c..f16fb62f 100644 --- a/example/convex/agents/story.ts +++ b/example/convex/agents/story.ts @@ -14,10 +14,10 @@ export const storyAgent = new Agent(components.agent, { getCharacterNames: createTool({ description: "Get the names of characters for the story. Only call this once.", - args: z.object({ + inputSchema: z.object({ count: z.number().describe("The number of character names to get"), }), - handler: async (ctx, args) => { + execute: async (ctx, input) => { return [ "Eleanor", "Henry", @@ -39,7 +39,7 @@ export const storyAgent = new Agent(components.agent, { "Malachai", "Selene", "Victor", - ].slice(0, args.count); + ].slice(0, input.count); }, }), }, diff --git a/example/convex/chat/approval.ts b/example/convex/chat/approval.ts new file mode 100644 index 00000000..d93ea51a --- /dev/null +++ b/example/convex/chat/approval.ts @@ -0,0 +1,166 @@ +// Tool Approval Demo - Convex Functions +// Demonstrates the AI SDK v6 two-call model for tool approval +import { paginationOptsValidator } from "convex/server"; +import { + listUIMessages, + syncStreams, + vStreamArgs, +} from "@convex-dev/agent"; +import { components, internal } from "../_generated/api"; +import { + internalAction, + mutation, + query, +} from "../_generated/server"; +import { v } from "convex/values"; +import { authorizeThreadAccess } from "../threads"; +import { approvalAgent } from "../agents/approval"; + +/** + * Send a message and start generation. + * If the agent uses a tool that requires approval, it will pause and + * return a tool-approval-request in the response. + */ +export const sendMessage = mutation({ + args: { prompt: v.string(), threadId: v.string() }, + handler: async (ctx, { prompt, threadId }) => { + await authorizeThreadAccess(ctx, threadId); + + // Save the user's message + const { messageId } = await approvalAgent.saveMessage(ctx, { + threadId, + prompt, + skipEmbeddings: true, + }); + + // Start async generation + await ctx.scheduler.runAfter(0, internal.chat.approval.generateResponse, { + threadId, + promptMessageId: messageId, + }); + + return { messageId }; + }, +}); + +/** + * Internal action that generates the response. + * This will stop if a tool requires approval. + */ +export const generateResponse = internalAction({ + args: { promptMessageId: v.string(), threadId: v.string() }, + handler: async (ctx, { promptMessageId, threadId }) => { + const result = await approvalAgent.streamText( + ctx, + { threadId }, + { promptMessageId }, + { saveStreamDeltas: { chunking: "word", throttleMs: 100 } }, + ); + await result.consumeStream(); + }, +}); + +/** + * Submit an approval response for a pending tool call. + * Schedules the appropriate action to handle approval or denial. + */ +export const submitApproval = mutation({ + args: { + threadId: v.string(), + approvalId: v.string(), + approved: v.boolean(), + reason: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await authorizeThreadAccess(ctx, args.threadId); + + if (args.approved) { + await ctx.scheduler.runAfter(0, internal.chat.approval.handleApproval, { + threadId: args.threadId, + approvalId: args.approvalId, + reason: args.reason, + }); + } else { + await ctx.scheduler.runAfter(0, internal.chat.approval.handleDenial, { + threadId: args.threadId, + approvalId: args.approvalId, + reason: args.reason, + }); + } + + return { approved: args.approved }; + }, +}); + +/** + * Handle an approved tool call. + * Executes the tool, saves the result, then continues generation. + */ +export const handleApproval = internalAction({ + args: { + threadId: v.string(), + approvalId: v.string(), + reason: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const { messageId } = await approvalAgent.approveToolCall(ctx, args); + // Continue generation with the tool result as the prompt + const result = await approvalAgent.streamText( + ctx, + { threadId: args.threadId }, + { promptMessageId: messageId }, + { saveStreamDeltas: { chunking: "word", throttleMs: 100 } }, + ); + await result.consumeStream(); + }, +}); + +/** + * Handle a denied tool call. + * Saves the denial, then lets the LLM respond to it. + */ +export const handleDenial = internalAction({ + args: { + threadId: v.string(), + approvalId: v.string(), + reason: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const { messageId } = await approvalAgent.denyToolCall(ctx, args); + // Continue generation so the LLM can respond to the denial + const result = await approvalAgent.streamText( + ctx, + { threadId: args.threadId }, + { promptMessageId: messageId }, + { saveStreamDeltas: { chunking: "word", throttleMs: 100 } }, + ); + await result.consumeStream(); + }, +}); + +/** + * Query messages with streaming support. + */ +export const listThreadMessages = query({ + args: { + threadId: v.string(), + paginationOpts: paginationOptsValidator, + streamArgs: vStreamArgs, + }, + handler: async (ctx, args) => { + const { threadId, streamArgs } = args; + await authorizeThreadAccess(ctx, threadId); + + const streams = await syncStreams(ctx, components.agent, { + threadId, + streamArgs, + }); + + const paginated = await listUIMessages(ctx, components.agent, args); + + return { + ...paginated, + streams, + }; + }, +}); diff --git a/example/convex/rag/ragAsTools.ts b/example/convex/rag/ragAsTools.ts index 39a5a1f7..0df481bb 100644 --- a/example/convex/rag/ragAsTools.ts +++ b/example/convex/rag/ragAsTools.ts @@ -26,29 +26,29 @@ export const sendMessage = action({ tools: { addContext: createTool({ description: "Store information to search later via RAG", - args: z.object({ + inputSchema: z.object({ title: z.string().describe("The title of the context"), text: z.string().describe("The text body of the context"), }), - handler: async (ctx, args) => { + execute: async (ctx, input) => { await rag.add(ctx, { namespace: userId, - title: args.title, - text: args.text, + title: input.title, + text: input.text, }); }, }), searchContext: createTool({ description: "Search for context related to this user prompt", - args: z.object({ + inputSchema: z.object({ query: z .string() .describe("Describe the context you're looking for"), }), - handler: async (ctx, args) => { + execute: async (ctx, input) => { const context = await rag.search(ctx, { namespace: userId, - query: args.query, + query: input.query, limit: 5, }); // To show the context in the demo UI, we record the context used diff --git a/example/convex/tools/agentAsTool.ts b/example/convex/tools/agentAsTool.ts index 1cc9d552..4863b0eb 100644 --- a/example/convex/tools/agentAsTool.ts +++ b/example/convex/tools/agentAsTool.ts @@ -41,13 +41,13 @@ export const runAgentAsTool = action({ const agentWithToolsAsTool = createTool({ description: "agentWithTools which can either doSomething or doSomethingElse", - args: z.object({ + inputSchema: z.object({ whatToDo: z.union([ z.literal("doSomething"), z.literal("doSomethingElse"), ]), }), - handler: async (ctx, args) => { + execute: async (ctx, input) => { // Create a nested thread to call the agent with tools const threadId = await createThread(ctx, components.agent, { userId: ctx.userId, @@ -59,7 +59,7 @@ export const runAgentAsTool = action({ messages: [ { role: "assistant", - content: `I'll do this now: ${args.whatToDo}`, + content: `I'll do this now: ${input.whatToDo}`, }, ], }, diff --git a/example/convex/tools/searchMessages.ts b/example/convex/tools/searchMessages.ts index 72a6f1ef..8ceb38ee 100644 --- a/example/convex/tools/searchMessages.ts +++ b/example/convex/tools/searchMessages.ts @@ -11,10 +11,10 @@ import { textEmbeddingModel } from "../modelsForDemo"; export const searchMessages = createTool({ description: "Search for messages in the thread", - args: z.object({ + inputSchema: z.object({ query: z.string().describe("The query to search for"), }), - handler: async (ctx, { query }) => { + execute: async (ctx, { query }) => { return fetchContextMessages(ctx, components.agent, { userId: ctx.userId, threadId: ctx.threadId, diff --git a/example/convex/tools/updateThreadTitle.ts b/example/convex/tools/updateThreadTitle.ts index 7c17fae6..1842ad3e 100644 --- a/example/convex/tools/updateThreadTitle.ts +++ b/example/convex/tools/updateThreadTitle.ts @@ -4,19 +4,19 @@ import { components } from "../_generated/api"; import { z } from "zod/v3"; export const updateThreadTitle = createTool({ - args: z.object({ + inputSchema: z.object({ title: z.string().describe("The new title for the thread"), }), description: "Update the title of the current thread. It will respond with 'updated' if it succeeded", - handler: async (ctx, args) => { + execute: async (ctx, input) => { if (!ctx.threadId) { console.warn("updateThreadTitle called without a threadId"); return "missing or invalid threadId"; } await ctx.runMutation(components.agent.threads.updateThread, { threadId: ctx.threadId, - patch: { title: args.title }, + patch: { title: input.title }, }); return "updated"; }, diff --git a/example/convex/usage_tracking/usageHandler.ts b/example/convex/usage_tracking/usageHandler.ts index 7ba65e94..277d3826 100644 --- a/example/convex/usage_tracking/usageHandler.ts +++ b/example/convex/usage_tracking/usageHandler.ts @@ -37,6 +37,22 @@ export const insertRawUsage = internalMutation({ outputTokens: v.optional(v.number()), reasoningTokens: v.optional(v.number()), cachedInputTokens: v.optional(v.number()), + // AI SDK v6 detailed token breakdown fields + inputTokenDetails: v.optional( + v.object({ + noCacheTokens: v.optional(v.number()), + cacheReadTokens: v.optional(v.number()), + cacheWriteTokens: v.optional(v.number()), + }) + ), + outputTokenDetails: v.optional( + v.object({ + textTokens: v.optional(v.number()), + reasoningTokens: v.optional(v.number()), + }) + ), + // Provider-specific raw usage data (varies by provider) + raw: v.optional(v.record(v.string(), v.any())), }), providerMetadata: v.optional(vProviderMetadata), }, diff --git a/example/ui/chat/ChatApproval.tsx b/example/ui/chat/ChatApproval.tsx new file mode 100644 index 00000000..c13e14eb --- /dev/null +++ b/example/ui/chat/ChatApproval.tsx @@ -0,0 +1,340 @@ +// Tool Approval Demo UI +// Demonstrates the AI SDK v6 tool approval workflow with approve/deny buttons +import { useMutation } from "convex/react"; +import { Toaster } from "../components/ui/toaster"; +import { api } from "../../convex/_generated/api"; +import { + optimisticallySendMessage, + useUIMessages, + type UIMessage, +} from "@convex-dev/agent/react"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { useDemoThread } from "@/hooks/use-demo-thread"; +import type { ToolUIPart } from "ai"; + +export default function ChatApproval() { + const { threadId, resetThread } = useDemoThread("Tool Approval Demo"); + + return ( + <> +
+

+ Tool Approval Demo +

+
+
+ {threadId ? ( + void resetThread()} /> + ) : ( +
+ Loading... +
+ )} + +
+ + ); +} + +function ApprovalChat({ + threadId, + reset, +}: { + threadId: string; + reset: () => void; +}) { + const { + results: messages, + status, + loadMore, + } = useUIMessages( + api.chat.approval.listThreadMessages, + { threadId }, + { initialNumItems: 10, stream: true }, + ); + + const sendMessage = useMutation( + api.chat.approval.sendMessage, + ).withOptimisticUpdate( + optimisticallySendMessage(api.chat.approval.listThreadMessages), + ); + + const submitApproval = useMutation(api.chat.approval.submitApproval); + + const [prompt, setPrompt] = useState("Delete the file important.txt"); + const messagesEndRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + + function onSendClicked() { + if (prompt.trim() === "") return; + void sendMessage({ threadId, prompt }).catch(() => setPrompt(prompt)); + setPrompt(""); + } + + const handleApproval = async (approvalId: string, approved: boolean) => { + try { + await submitApproval({ + threadId, + approvalId, + approved, + reason: approved ? "User approved" : "User denied", + }); + } catch (error) { + console.error("[handleApproval] error:", error); + } + }; + + return ( + <> +
+ {/* Info banner */} +
+ Try these prompts: +
    +
  • "Delete the file important.txt" (always requires approval)
  • +
  • "Transfer $50 to account ABC123" (no approval needed)
  • +
  • "Transfer $500 to account XYZ789" (requires approval for {">"} $100)
  • +
  • "Check the balance of account TEST001" (no approval needed)
  • +
+
+ + {/* Messages area - scrollable */} +
+ {messages.length > 0 ? ( +
+ {status === "CanLoadMore" && ( + + )} + {messages.map((m) => ( + + ))} +
+
+ ) : ( +
+ Try a prompt that triggers tool approval... +
+ )} +
+ + {/* Fixed input area at bottom */} +
+
{ + e.preventDefault(); + onSendClicked(); + }} + > + setPrompt(e.target.value)} + className="flex-1 px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-gray-50" + placeholder="Ask me to do something that requires approval..." + /> + + {messages.length > 0 && ( + + )} +
+
+
+ + ); +} + +// Helper to extract tool name from type (e.g., "tool-deleteFile" -> "deleteFile") +function getToolName(type: string): string { + return type.replace("tool-", ""); +} + +function Message({ + message, + onApproval, +}: { + message: UIMessage; + onApproval: (approvalId: string, approved: boolean) => void; +}) { + const isUser = message.role === "user"; + + // Render parts in order to show approval UI in the correct position + const renderPart = (part: UIMessage["parts"][number], index: number) => { + // Skip step-start parts (visual separator, not needed here) + if (part.type === "step-start") { + return null; + } + + // Text part + if (part.type === "text") { + const textPart = part as { text: string; state?: string }; + return ( +
+ {textPart.text} + {textPart.state === "streaming" && ( + + )} +
+ ); + } + + // Tool part + if (part.type.startsWith("tool-")) { + const tool = part as ToolUIPart; + const approvalId = "approval" in tool ? (tool.approval as { id?: string })?.id : undefined; + + // Pending approval + if (tool.state === "approval-requested" && approvalId) { + return ( +
+
+ ⚠️ Approval Required: {getToolName(tool.type)} +
+
+ Action:{" "} + {JSON.stringify(tool.input, null, 2)} +
+
+ + +
+
+ ); + } + + // Completed tool + if (tool.state === "output-available" || tool.state === "approval-responded") { + return ( +
+
+ ✓ {getToolName(tool.type)} +
+
+ Input: {JSON.stringify(tool.input)} +
+ {"output" in tool && tool.output != null ? ( +
+ Output: {String(tool.output)} +
+ ) : null} +
+ ); + } + + // Denied tool + if (tool.state === "output-denied") { + return ( +
+
+ ✗ Denied: {getToolName(tool.type)} +
+
+ Action: {JSON.stringify(tool.input)} +
+
+ ); + } + + // Tool in other states (input-available, input-streaming, etc.) + return ( +
+
+ 🔧 {getToolName(tool.type)} +
+
+ Input: {JSON.stringify(tool.input)} +
+ {tool.state === "input-streaming" && ( +
+ Processing... +
+ )} +
+ ); + } + + return null; + }; + + return ( +
+
+ {/* Render parts in order */} + {message.parts.map((part, index) => renderPart(part, index))} + + {/* Status indicator */} + {message.status === "streaming" && !message.parts.some(p => p.type === "text" && (p as { state?: string }).state === "streaming") && ( +
+ Generating... +
+ )} +
+
+ ); +} diff --git a/example/ui/main.tsx b/example/ui/main.tsx index 949b74b0..fb076f42 100644 --- a/example/ui/main.tsx +++ b/example/ui/main.tsx @@ -5,6 +5,7 @@ import { BrowserRouter, Routes, Route, Link } from "react-router-dom"; import { Toaster } from "./components/ui/toaster"; import ChatBasic from "./chat/ChatBasic"; import ChatStreaming from "./chat/ChatStreaming"; +import ChatApproval from "./chat/ChatApproval"; import FilesImages from "./files/FilesImages"; import RateLimiting from "./rate_limiting/RateLimiting"; import { WeatherFashion } from "./workflows/WeatherFashion"; @@ -41,6 +42,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -88,6 +90,20 @@ function Index() { streaming!).

+
  • + + Tool Approval + +

    + Demonstrates the AI SDK v6 tool approval workflow. Tools can + require user approval before execution, enabling human-in-the-loop + patterns for sensitive operations like file deletion or money + transfers. +

    +
  • =18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", @@ -4570,13 +4586,12 @@ "license": "MIT" }, "node_modules/convex": { - "version": "1.29.3", - "resolved": "https://registry.npmjs.org/convex/-/convex-1.29.3.tgz", - "integrity": "sha512-tg5TXzMjpNk9m50YRtdp6US+t7ckxE4E+7DNKUCjJ2MupQs2RBSPF/z5SNN4GUmQLSfg0eMILDySzdAvjTrhnw==", + "version": "1.31.6", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.31.6.tgz", + "integrity": "sha512-9cIsOzepa3s9DURRF+fZHxbNuzLgilg9XGQCc45v0Xx4FemqeIezpPFSJF9WHC9ckk43TDUUXLecvLVt9djPkw==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "esbuild": "0.25.4", + "esbuild": "0.27.0", "prettier": "^3.0.0" }, "bin": { @@ -4648,6 +4663,447 @@ "convex": "^1.16.4" } }, + "node_modules/convex/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex/node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, "node_modules/cookie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", diff --git a/package.json b/package.json index f90ff564..519527ca 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ }, "files": [ "dist", - "src" + "src", + "MIGRATION.md" ], "exports": { "./package.json": "./package.json", @@ -111,7 +112,7 @@ "chokidar-cli": "3.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", - "convex": "1.29.3", + "convex": "1.31.6", "convex-helpers": "0.1.104", "convex-test": "0.0.38", "cpy-cli": "5.0.0", diff --git a/src/UIMessages.ts b/src/UIMessages.ts index 45011f77..f0e69741 100644 --- a/src/UIMessages.ts +++ b/src/UIMessages.ts @@ -379,23 +379,71 @@ function createAssistantUIMessage< const group = sorted(groupUnordered); const firstMessage = group[0]; - // Use first message for special fields + const lastMessage = group[group.length - 1]; + + // Use first message for ID/timestamp, but last message's stepOrder for deduplication with streaming + // Key uses only order (not stepOrder) to prevent React remounting when messages are added to the group const common = { id: firstMessage._id, _creationTime: firstMessage._creationTime, order: firstMessage.order, - stepOrder: firstMessage.stepOrder, - key: `${firstMessage.threadId}-${firstMessage.order}-${firstMessage.stepOrder}`, + stepOrder: lastMessage.stepOrder, + key: `${firstMessage.threadId}-${firstMessage.order}`, agentName: firstMessage.agentName, userId: firstMessage.userId, }; // Get status from last message - const lastMessage = group[group.length - 1]; const status = lastMessage.streaming ? ("streaming" as const) : lastMessage.status; + // Extract approval parts from raw message content BEFORE calling toModelMessage + // (toModelMessage filters them out since providers don't understand them) + type ApprovalPart = + | { type: "tool-approval-request"; approvalId: string; toolCallId: string } + | { + type: "tool-approval-response"; + approvalId: string; + approved: boolean; + reason?: string; + }; + const approvalParts: ApprovalPart[] = []; + + // Extract execution-denied tool results from raw content BEFORE toModelMessage + // converts them to text format for provider compatibility + type ExecutionDeniedInfo = { + toolCallId: string; + reason?: string; + }; + const executionDeniedResults: ExecutionDeniedInfo[] = []; + + for (const message of group) { + const rawContent = message.message?.content; + if (Array.isArray(rawContent)) { + for (const part of rawContent) { + if ( + part.type === "tool-approval-request" || + part.type === "tool-approval-response" + ) { + approvalParts.push(part as ApprovalPart); + } + // Check for execution-denied in tool-result outputs + if ( + part.type === "tool-result" && + typeof part.output === "object" && + part.output !== null && + (part.output as { type?: string }).type === "execution-denied" + ) { + executionDeniedResults.push({ + toolCallId: part.toolCallId as string, + reason: (part.output as { reason?: string }).reason, + }); + } + } + } + } + // Collect all parts from all messages const allParts: UIMessage["parts"] = []; @@ -477,40 +525,13 @@ function createAssistantUIMessage< break; } case "tool-result": { + // Note: execution-denied outputs are handled separately via pre-extraction + // from raw content (before toModelMessage converts them to text format). + // See executionDeniedResults processing at the end of this function. const typedPart = contentPart as unknown as ToolResultPart & { output: { type: string; value?: unknown; reason?: string }; }; - // Check if this is an execution-denied result - if (typedPart.output?.type === "execution-denied") { - const call = allParts.find( - (part) => - part.type === `tool-${contentPart.toolName}` && - "toolCallId" in part && - part.toolCallId === contentPart.toolCallId, - ) as ToolUIPart | undefined; - - if (call) { - call.state = "output-denied"; - if (!("approval" in call) || !call.approval) { - (call as ToolUIPart & { approval?: object }).approval = { - id: "", - approved: false, - reason: typedPart.output.reason, - }; - } else { - const approval = ( - call as ToolUIPart & { - approval: { approved?: boolean; reason?: string }; - } - ).approval; - approval.approved = false; - approval.reason = typedPart.output.reason; - } - } - break; - } - const output = typeof typedPart.output?.type === "string" ? typedPart.output.value @@ -537,10 +558,7 @@ function createAssistantUIMessage< call.output = output; } } else { - console.warn( - "Tool result without preceding tool call.. adding anyways", - contentPart, - ); + // Tool call is on a previous page - create standalone tool part if (hasError) { allParts.push({ type: `tool-${contentPart.toolName}`, @@ -563,68 +581,6 @@ function createAssistantUIMessage< } break; } - case "tool-approval-request": { - // Find the matching tool call - const typedPart = contentPart as { - toolCallId: string; - approvalId: string; - }; - const toolCallPart = allParts.find( - (part) => - "toolCallId" in part && part.toolCallId === typedPart.toolCallId, - ) as ToolUIPart | undefined; - - if (toolCallPart) { - toolCallPart.state = "approval-requested"; - (toolCallPart as ToolUIPart & { approval?: object }).approval = { - id: typedPart.approvalId, - }; - } else { - console.warn( - "Tool approval request without preceding tool call", - contentPart, - ); - } - break; - } - case "tool-approval-response": { - // Find the tool call that has this approval by matching approval.id - const typedPart = contentPart as { - approvalId: string; - approved: boolean; - reason?: string; - }; - const toolCallPart = allParts.find( - (part) => - "approval" in part && - (part as ToolUIPart & { approval?: { id: string } }).approval - ?.id === typedPart.approvalId, - ) as ToolUIPart | undefined; - - if (toolCallPart) { - if (typedPart.approved) { - toolCallPart.state = "approval-responded"; - (toolCallPart as ToolUIPart & { approval?: object }).approval = { - id: typedPart.approvalId, - approved: true, - reason: typedPart.reason, - }; - } else { - toolCallPart.state = "output-denied"; - (toolCallPart as ToolUIPart & { approval?: object }).approval = { - id: typedPart.approvalId, - approved: false, - reason: typedPart.reason, - }; - } - } else { - console.warn( - "Tool approval response without matching approval request", - contentPart, - ); - } - break; - } default: { const maybeSource = contentPart as unknown as SourcePart; if (maybeSource.type === "source") { @@ -645,6 +601,85 @@ function createAssistantUIMessage< } } + // Final output states that should not be overwritten by approval processing + const finalStates = new Set([ + "output-available", + "output-error", + "output-denied", + ]); + + // Process pre-extracted approval parts (extracted before toModelMessage filtered them) + for (const approvalPart of approvalParts) { + if (approvalPart.type === "tool-approval-request") { + const toolCallPart = allParts.find( + (part) => + "toolCallId" in part && part.toolCallId === approvalPart.toolCallId, + ) as ToolUIPart | undefined; + + if (toolCallPart) { + // Always set approval info (needed for response matching), but only + // update state if not in a final state + (toolCallPart as ToolUIPart & { approval?: object }).approval = { + id: approvalPart.approvalId, + }; + if (!finalStates.has(toolCallPart.state)) { + toolCallPart.state = "approval-requested"; + } + } + } else if (approvalPart.type === "tool-approval-response") { + const toolCallPart = allParts.find( + (part) => + "approval" in part && + (part as ToolUIPart & { approval?: { id: string } }).approval?.id === + approvalPart.approvalId, + ) as ToolUIPart | undefined; + + if (toolCallPart) { + // Always update approval info, but only update state if not in a final state + (toolCallPart as ToolUIPart & { approval?: object }).approval = { + id: approvalPart.approvalId, + approved: approvalPart.approved, + reason: approvalPart.reason, + }; + if (!finalStates.has(toolCallPart.state)) { + if (approvalPart.approved) { + toolCallPart.state = "approval-responded"; + } else { + toolCallPart.state = "output-denied"; + } + } + } + } + } + + // Process pre-extracted execution-denied results (extracted before toModelMessage + // converted them to text format for provider compatibility) + for (const denied of executionDeniedResults) { + const toolCallPart = allParts.find( + (part) => + "toolCallId" in part && part.toolCallId === denied.toolCallId, + ) as ToolUIPart | undefined; + + if (toolCallPart) { + toolCallPart.state = "output-denied"; + if (!("approval" in toolCallPart) || !toolCallPart.approval) { + (toolCallPart as ToolUIPart & { approval?: object }).approval = { + id: "", + approved: false, + reason: denied.reason, + }; + } else { + const approval = ( + toolCallPart as ToolUIPart & { + approval: { approved?: boolean; reason?: string }; + } + ).approval; + approval.approved = false; + approval.reason = denied.reason; + } + } + } + return { ...common, role: "assistant", @@ -713,7 +748,8 @@ export function combineUIMessages(messages: UIMessage[]): UIMessage[] { } acc.push({ ...previous, - ...pick(message, ["status", "metadata", "agentName"]), + // Use the later message's stepOrder so deduplication with streaming works + ...pick(message, ["status", "metadata", "agentName", "stepOrder"]), parts: newParts, text: joinText(newParts), }); diff --git a/src/client/approval-bugs.test.ts b/src/client/approval-bugs.test.ts new file mode 100644 index 00000000..4958690f --- /dev/null +++ b/src/client/approval-bugs.test.ts @@ -0,0 +1,840 @@ +/** + * Tests designed to find actual bugs in the tool approval workflow. + * These tests probe edge cases and stress conditions. + */ +import { describe, expect, test } from "vitest"; +import { + Agent, + createThread, + createTool, + type MessageDoc, +} from "./index.js"; +import type { DataModelFromSchemaDefinition } from "convex/server"; +import { defineSchema } from "convex/server"; +import { stepCountIs } from "ai"; +import { components, initConvexTest } from "./setup.test.js"; +import { z } from "zod/v4"; +import { mockModel } from "./mockModel.js"; +import { toUIMessages } from "../UIMessages.js"; + +const schema = defineSchema({}); + +// Simple agent for testing +const testAgent = new Agent(components.agent, { + name: "test-agent", + instructions: "Test", + tools: { + testTool: createTool({ + description: "Test tool", + inputSchema: z.object({ value: z.string() }), + needsApproval: () => true, + execute: async (_ctx, input) => `Result: ${input.value}`, + }), + }, + languageModel: mockModel(), + stopWhen: stepCountIs(3), +}); + +describe("Pagination in _findToolCallInfo", () => { + test("finds approval within 20-message window (newest first)", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Add the approval request first (oldest) + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "old-call", + toolName: "testTool", + input: { value: "old" }, + args: { value: "old" }, + }, + { + type: "tool-approval-request", + approvalId: "old-approval", + toolCallId: "old-call", + }, + ], + }, + }), + ); + + // Add 25 messages AFTER the approval to push it out of the window + for (let i = 0; i < 25; i++) { + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { role: "user", content: `Message ${i}` }, + }), + ); + } + + // The approval is now the oldest message, outside the 20-message window + // (messages are returned newest-first by default) + const toolInfo = await t.run(async (ctx) => + (testAgent as any)._findToolCallInfo(ctx, threadId, "old-approval"), + ); + + // FIXED: With indexed lookup, approvals are found regardless of position + expect(toolInfo).not.toBeNull(); + expect(toolInfo?.toolName).toBe("testTool"); + }); + + test("finds recent approval within window", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Add some older messages first + for (let i = 0; i < 5; i++) { + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { role: "user", content: `Old message ${i}` }, + }), + ); + } + + // Add the approval request (recent) + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "recent-call", + toolName: "testTool", + input: { value: "recent" }, + args: { value: "recent" }, + }, + { + type: "tool-approval-request", + approvalId: "recent-approval", + toolCallId: "recent-call", + }, + ], + }, + }), + ); + + const toolInfo = await t.run(async (ctx) => + (testAgent as any)._findToolCallInfo(ctx, threadId, "recent-approval"), + ); + + // Recent approvals should be found + expect(toolInfo).not.toBeNull(); + expect(toolInfo?.toolName).toBe("testTool"); + }); +}); + +describe("Bug: Tool call and approval request in different messages", () => { + test("fails when tool-call and tool-approval-request are in separate messages", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Save tool call in one message + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "split-call", + toolName: "testTool", + input: { value: "split" }, + args: { value: "split" }, + }, + ], + }, + }), + ); + + // Save approval request in a different message (unusual but possible) + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-approval-request", + approvalId: "split-approval", + toolCallId: "split-call", + }, + ], + }, + }), + ); + + const toolInfo = await t.run(async (ctx) => + (testAgent as any)._findToolCallInfo(ctx, threadId, "split-approval"), + ); + + // FIXED: Extraction happens at message save time, so both parts must be in same message + // If they're in separate messages, the approval won't be indexed + expect(toolInfo).toBeNull(); + }); +}); + +describe("Tool not registered on agent calling approveToolCall", () => { + test("succeeds even when tool is not on the agent instance (save-only)", async () => { + const t = initConvexTest(schema); + + // Agent without the tool + const agentWithoutTool = new Agent(components.agent, { + name: "no-tools", + instructions: "Test", + languageModel: mockModel(), + }); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Save a tool call from a different agent that has the tool + const { messageId: parentMessageId } = await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "cross-agent-call", + toolName: "testTool", // This tool exists on testAgent but not agentWithoutTool + input: { value: "cross" }, + args: { value: "cross" }, + }, + { + type: "tool-approval-request", + approvalId: "cross-agent-approval", + toolCallId: "cross-agent-call", + }, + ], + }, + }), + ); + + // approveToolCall no longer executes tools — it just saves the approval + // as a pending message. Any agent can approve regardless of tool registration. + const result = await t.run(async (ctx) => + agentWithoutTool.approveToolCall(ctx as any, { + threadId, + approvalId: "cross-agent-approval", + }), + ); + + expect(result.messageId).toBeDefined(); + + // Verify the saved message is pending with approval content + const messages = await t.run(async (ctx) => { + const res = await agentWithoutTool.listMessages(ctx, { + threadId, + paginationOpts: { cursor: null, numItems: 10 }, + statuses: ["pending"], + }); + return res.page; + }); + + const approvalMsg = messages.find((m) => m._id === result.messageId); + expect(approvalMsg).toBeDefined(); + expect(approvalMsg?.status).toBe("pending"); + expect(approvalMsg?.message?.role).toBe("tool"); + const content = approvalMsg?.message?.content; + expect(Array.isArray(content)).toBe(true); + expect((content as any[])?.[0]?.type).toBe("tool-approval-response"); + expect((content as any[])?.[0]?.approved).toBe(true); + }); +}); + +describe("Multiple tool calls with same toolCallId", () => { + test("finds first matching toolCallId regardless of which message has approval", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Second message has CORRECT and the approval request + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "duplicate-id", + toolName: "testTool", + input: { value: "CORRECT" }, + args: { value: "CORRECT" }, + }, + { + type: "tool-approval-request", + approvalId: "dup-approval", + toolCallId: "duplicate-id", + }, + ], + }, + }), + ); + + // First message (older) has WRONG but no approval + // Note: In real scenarios, duplicate toolCallIds shouldn't happen + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "duplicate-id", // Same ID! + toolName: "testTool", + input: { value: "WRONG" }, + args: { value: "WRONG" }, + }, + ], + }, + }), + ); + + const toolInfo = await t.run(async (ctx) => + (testAgent as any)._findToolCallInfo(ctx, threadId, "dup-approval"), + ); + + // FIXED: Indexed fields are extracted from the SAME message as the approval request + // So it finds the CORRECT tool call from the approval message + expect(toolInfo?.toolInput).toEqual({ value: "CORRECT" }); + }); +}); + +describe("Bug: UIMessage state not updated when approval comes after output", () => { + test("final state depends on part order in messages array", () => { + // If tool-result comes before tool-approval-response in the processing, + // the final state might be incorrect + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "order-test", + toolName: "testTool", + input: { value: "test" }, + args: { value: "test" }, + }, + { + type: "tool-approval-request", + approvalId: "order-approval", + toolCallId: "order-test", + }, + ], + }, + }, + { + _id: "msg2", + _creationTime: Date.now() + 1, + order: 0, + stepOrder: 1, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "tool", + content: [ + // Result comes first in the array + { + type: "tool-result", + toolCallId: "order-test", + toolName: "testTool", + output: { type: "text", value: "done" }, + }, + // Approval response comes second + { + type: "tool-approval-response", + approvalId: "order-approval", + approved: true, + }, + ], + }, + }, + ]; + + const uiMessages = toUIMessages(messages); + const toolPart = uiMessages[0].parts.find( + (p) => p.type === "tool-testTool", + ); + + // The state should be output-available since we have the result + expect((toolPart as any).state).toBe("output-available"); + expect((toolPart as any).output).toBe("done"); + }); +}); + +describe("Bug: Empty or malformed approval parts", () => { + test("handles missing approvalId in request", () => { + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "no-approval-id", + toolName: "testTool", + input: { value: "test" }, + args: { value: "test" }, + }, + { + type: "tool-approval-request", + // Missing approvalId! + toolCallId: "no-approval-id", + } as any, + ], + }, + }, + ]; + + // Should not throw, should handle gracefully + const uiMessages = toUIMessages(messages); + expect(uiMessages).toHaveLength(1); + + const toolPart = uiMessages[0].parts.find( + (p) => p.type === "tool-testTool", + ); + // State should be approval-requested but approval.id will be undefined + expect((toolPart as any).state).toBe("approval-requested"); + expect((toolPart as any).approval?.id).toBeUndefined(); + }); + + test("handles undefined approval response fields", () => { + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "undefined-fields", + toolName: "testTool", + input: { value: "test" }, + args: { value: "test" }, + }, + { + type: "tool-approval-request", + approvalId: "undef-approval", + toolCallId: "undefined-fields", + }, + ], + }, + }, + { + _id: "msg2", + _creationTime: Date.now() + 1, + order: 0, + stepOrder: 1, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "undef-approval", + // Missing 'approved' field! + } as any, + ], + }, + }, + ]; + + const uiMessages = toUIMessages(messages); + const toolPart = uiMessages[0].parts.find( + (p) => p.type === "tool-testTool", + ); + + // BUG: If approved is undefined, what state should it be? + // Currently it might be treated as falsy (denied) + expect((toolPart as any).approval?.approved).toBeUndefined(); + }); +}); + +describe("Bug: String content instead of array", () => { + test("handles message with string content (no approval parts extracted)", () => { + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: false, + message: { + role: "assistant", + content: "This is just a string, not an array", + }, + text: "This is just a string, not an array", + }, + ]; + + // Should handle gracefully without throwing + const uiMessages = toUIMessages(messages); + expect(uiMessages).toHaveLength(1); + expect(uiMessages[0].text).toBe("This is just a string, not an array"); + }); +}); + +describe("approveToolCall saves pending approval (no tool execution)", () => { + test("approveToolCall saves approval without executing tool", async () => { + const t = initConvexTest(schema); + + // Agent with a tool that would throw — but approveToolCall won't execute it + const throwingAgent = new Agent(components.agent, { + name: "throwing-agent", + instructions: "Test", + tools: { + throwingTool: createTool({ + description: "Throws an error", + inputSchema: z.object({}), + needsApproval: () => true, + execute: async (): Promise => { + throw new Error("Intentional test error"); + }, + }), + }, + languageModel: mockModel(), + stopWhen: stepCountIs(3), + }); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + const { messageId: parentMessageId } = await t.run(async (ctx) => + throwingAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "throwing-call", + toolName: "throwingTool", + input: {}, + args: {}, + }, + { + type: "tool-approval-request", + approvalId: "throwing-approval", + toolCallId: "throwing-call", + }, + ], + }, + }), + ); + + // approveToolCall no longer executes tools, so it should succeed + // even for tools that would throw. The tool execution happens later + // when the caller runs streamText/generateText. + const result = await t.run(async (ctx) => + throwingAgent.approveToolCall(ctx as any, { + threadId, + approvalId: "throwing-approval", + }), + ); + + expect(result.messageId).toBeDefined(); + }); +}); + +describe("Bug: Race condition with concurrent approvals", () => { + test("documents TOCTOU issue - check and write are separate transactions", async () => { + // This test documents a race condition caused by the action architecture: + // + // approveToolCall() is an ACTION that makes separate query/mutation calls: + // 1. _findToolCallInfo() calls listMessages() → QUERY (transaction 1) + // 2. saveMessage() → MUTATION (transaction 2) + // + // Race scenario: + // Action A: listMessages() → no response found (query tx 1) + // Action B: listMessages() → no response found (query tx 2) + // Action A: saveMessage() → saves response (mutation tx 3) + // Action B: saveMessage() → DUPLICATE response! (mutation tx 4) + // + // If this were a SINGLE MUTATION, Convex's serializable isolation would + // prevent the race. But since it's an action with separate transactions, + // the race exists. + // + // FIX: Move the check-and-write into a single mutation, or use + // optimistic concurrency control (e.g., check approvalId uniqueness + // via a unique index in the database). + + // We can't easily test this race condition in a unit test, + // but we document it here as a known architectural limitation + expect(true).toBe(true); + }); +}); + +describe("Bug: Approval for non-existent toolCallId", () => { + test("returns null when toolCallId doesn't match any tool-call", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Approval request references a toolCallId that doesn't exist + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-approval-request", + approvalId: "orphan-approval", + toolCallId: "non-existent-call", + }, + ], + }, + }), + ); + + const toolInfo = await t.run(async (ctx) => + (testAgent as any)._findToolCallInfo(ctx, threadId, "orphan-approval"), + ); + + // Should return null because the referenced tool-call doesn't exist + expect(toolInfo).toBeNull(); + }); +}); + +describe("Bug: Tool input normalization", () => { + test("handles tool call with only 'args' and no 'input'", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Some older messages might only have 'args' not 'input' + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "args-only-call", + toolName: "testTool", + args: { value: "from-args" }, + // No 'input' field! + }, + { + type: "tool-approval-request", + approvalId: "args-only-approval", + toolCallId: "args-only-call", + }, + ], + }, + }), + ); + + const toolInfo = await t.run(async (ctx) => + (testAgent as any)._findToolCallInfo(ctx, threadId, "args-only-approval"), + ); + + // Should fallback to 'args' when 'input' is undefined + expect(toolInfo?.toolInput).toEqual({ value: "from-args" }); + }); + + test("handles tool call with neither 'args' nor 'input'", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Tool call with no input at all + await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "no-input-call", + toolName: "testTool", + input: undefined, // Explicitly undefined to test fallback + args: undefined, + } as any, // Cast to any since we're testing edge case with missing fields + { + type: "tool-approval-request", + approvalId: "no-input-approval", + toolCallId: "no-input-call", + }, + ], + }, + }), + ); + + const toolInfo = await t.run(async (ctx) => + (testAgent as any)._findToolCallInfo(ctx, threadId, "no-input-approval"), + ); + + // Should fallback to empty object + expect(toolInfo?.toolInput).toEqual({}); + }); +}); + +describe("Content merge in addMessages", () => { + test("merges pending approval content with subsequent tool result", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Save the assistant message with a tool call + approval request + const { messageId: assistantMsgId } = await t.run(async (ctx) => + testAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "merge-call", + toolName: "testTool", + input: { value: "merge-test" }, + args: { value: "merge-test" }, + }, + { + type: "tool-approval-request", + approvalId: "merge-approval", + toolCallId: "merge-call", + }, + ], + }, + }), + ); + + // Approve the tool call — saves as pending with approval content + const { messageId: approvalMsgId } = await t.run(async (ctx) => + testAgent.approveToolCall(ctx as any, { + threadId, + approvalId: "merge-approval", + }), + ); + + // Verify the pending message has approval content + const pendingMessages = await t.run(async (ctx) => { + const res = await testAgent.listMessages(ctx, { + threadId, + paginationOpts: { cursor: null, numItems: 10 }, + statuses: ["pending"], + }); + return res.page; + }); + const pendingMsg = pendingMessages.find((m) => m._id === approvalMsgId); + expect(pendingMsg).toBeDefined(); + expect(pendingMsg?.status).toBe("pending"); + const pendingContent = pendingMsg?.message?.content; + expect(Array.isArray(pendingContent)).toBe(true); + expect((pendingContent as any[])?.[0]?.type).toBe( + "tool-approval-response", + ); + + // Now simulate what happens when addMessages replaces the pending message + // with a tool result (as streamText would do). The approval-response + // content should be preserved (merged/prepended). + await t.run(async (ctx) => + testAgent.saveMessages(ctx, { + threadId, + promptMessageId: assistantMsgId, + pendingMessageId: approvalMsgId, + messages: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "merge-call", + toolName: "testTool", + output: { type: "text", value: "Result: merge-test" }, + }, + ], + }, + ], + skipEmbeddings: true, + }), + ); + + // Fetch the message that replaced the pending one + const allMessages = await t.run(async (ctx) => { + const res = await testAgent.listMessages(ctx, { + threadId, + paginationOpts: { cursor: null, numItems: 20 }, + }); + return res.page; + }); + + const mergedMsg = allMessages.find((m) => m._id === approvalMsgId); + expect(mergedMsg).toBeDefined(); + expect(mergedMsg?.status).toBe("success"); + const mergedContent = mergedMsg?.message?.content; + expect(Array.isArray(mergedContent)).toBe(true); + + // Should have both the approval-response (prepended) and tool-result + const contentArr = mergedContent as any[]; + expect(contentArr.length).toBe(2); + expect(contentArr[0].type).toBe("tool-approval-response"); + expect(contentArr[0].approved).toBe(true); + expect(contentArr[1].type).toBe("tool-result"); + expect(contentArr[1].toolName).toBe("testTool"); + }); +}); diff --git a/src/client/approval.test.ts b/src/client/approval.test.ts new file mode 100644 index 00000000..ace22b50 --- /dev/null +++ b/src/client/approval.test.ts @@ -0,0 +1,748 @@ +import { describe, expect, test, vi } from "vitest"; +import { + Agent, + createThread, + createTool, + type MessageDoc, +} from "./index.js"; +import type { DataModelFromSchemaDefinition } from "convex/server"; +import { actionGeneric } from "convex/server"; +import type { ActionBuilder } from "convex/server"; +import { v } from "convex/values"; +import { defineSchema } from "convex/server"; +import { stepCountIs } from "ai"; +import { components, initConvexTest } from "./setup.test.js"; +import { z } from "zod/v4"; +import { mockModel } from "./mockModel.js"; +import { toUIMessages } from "../UIMessages.js"; + +const schema = defineSchema({}); +type DataModel = DataModelFromSchemaDefinition; +const action = actionGeneric as ActionBuilder; + +// Tool that always requires approval +const deleteFileTool = createTool({ + description: "Delete a file", + inputSchema: z.object({ + filename: z.string(), + }), + needsApproval: () => true, + execute: async (_ctx, input) => { + return `Deleted: ${input.filename}`; + }, +}); + +// Tool that conditionally requires approval +const transferMoneyTool = createTool({ + description: "Transfer money", + inputSchema: z.object({ + amount: z.number(), + toAccount: z.string(), + }), + needsApproval: (_ctx, input) => input.amount > 100, + execute: async (_ctx, input) => { + return `Transferred $${input.amount} to ${input.toAccount}`; + }, +}); + +// Tool that never requires approval +const checkBalanceTool = createTool({ + description: "Check balance", + inputSchema: z.object({ + accountId: z.string(), + }), + execute: async (_ctx, input) => { + return `Balance for ${input.accountId}: $500`; + }, +}); + +// Agent with approval tools for testing +const approvalAgent = new Agent(components.agent, { + name: "approval-test-agent", + instructions: "Test agent for approval workflow", + tools: { + deleteFile: deleteFileTool, + transferMoney: transferMoneyTool, + checkBalance: checkBalanceTool, + }, + languageModel: mockModel({ + contentSteps: [ + // First step: tool call that needs approval + [ + { + type: "tool-call", + toolCallId: "call-123", + toolName: "deleteFile", + input: JSON.stringify({ filename: "important.txt" }), + }, + ], + // Second step: after approval, generate final response + [{ type: "text", text: "File deleted successfully." }], + ], + }), + stopWhen: stepCountIs(5), +}); + +// Agent for testing tool execution without approval +const noApprovalAgent = new Agent(components.agent, { + name: "no-approval-agent", + instructions: "Test agent without approval requirement", + tools: { + checkBalance: checkBalanceTool, + }, + languageModel: mockModel({ + contentSteps: [ + [ + { + type: "tool-call", + toolCallId: "call-456", + toolName: "checkBalance", + input: JSON.stringify({ accountId: "ABC123" }), + }, + ], + [{ type: "text", text: "Your balance is $500." }], + ], + }), + stopWhen: stepCountIs(5), +}); + +describe("Tool Approval Workflow", () => { + describe("_findToolCallInfo", () => { + test("finds tool call info for valid approval ID", async () => { + const t = initConvexTest(schema); + + // Create thread and save messages simulating an approval request + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Save user message + await t.run(async (ctx) => + approvalAgent.saveMessage(ctx, { + threadId, + message: { role: "user", content: "Delete important.txt" }, + }), + ); + + // Save assistant message with tool call and approval request + await t.run(async (ctx) => + approvalAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-123", + toolName: "deleteFile", + input: { filename: "important.txt" }, + args: { filename: "important.txt" }, + }, + { + type: "tool-approval-request", + approvalId: "approval-abc", + toolCallId: "call-123", + }, + ], + }, + }), + ); + + // Test finding the tool call info + const toolInfo = await t.run(async (ctx) => + (approvalAgent as any)._findToolCallInfo(ctx, threadId, "approval-abc"), + ); + + expect(toolInfo).not.toBeNull(); + expect(toolInfo?.toolCallId).toBe("call-123"); + expect(toolInfo?.toolName).toBe("deleteFile"); + expect(toolInfo?.toolInput).toEqual({ filename: "important.txt" }); + expect(toolInfo?.parentMessageId).toBeDefined(); + }); + + test("returns null for non-existent approval ID", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + await t.run(async (ctx) => + approvalAgent.saveMessage(ctx, { + threadId, + message: { role: "user", content: "Hello" }, + }), + ); + + const toolInfo = await t.run(async (ctx) => + (approvalAgent as any)._findToolCallInfo( + ctx, + threadId, + "non-existent-approval", + ), + ); + + expect(toolInfo).toBeNull(); + }); + + test("returns null for already handled approval (idempotency)", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Save message with approval request + await t.run(async (ctx) => + approvalAgent.saveMessage(ctx, { + threadId, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-123", + toolName: "deleteFile", + input: { filename: "test.txt" }, + args: { filename: "test.txt" }, + }, + { + type: "tool-approval-request", + approvalId: "approval-xyz", + toolCallId: "call-123", + }, + ], + }, + }), + ); + + // Approve via the proper flow (which updates the approval status) + await t.run(async (ctx) => + approvalAgent.approveToolCall(ctx as any, { + threadId, + approvalId: "approval-xyz", + }), + ); + + // Should return alreadyHandled because approval was already processed + const toolInfo = await t.run(async (ctx) => + (approvalAgent as any)._findToolCallInfo(ctx, threadId, "approval-xyz"), + ); + + // Returns { alreadyHandled: true, wasApproved: true } when already approved + expect(toolInfo).not.toBeNull(); + expect(toolInfo?.alreadyHandled).toBe(true); + expect(toolInfo?.wasApproved).toBe(true); + }); + }); + + describe("UIMessage approval state handling", () => { + test("shows approval-requested state for pending approvals", () => { + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "deleteFile", + input: { filename: "test.txt" }, + args: { filename: "test.txt" }, + }, + { + type: "tool-approval-request", + approvalId: "approval-1", + toolCallId: "call-1", + }, + ], + }, + }, + ]; + + const uiMessages = toUIMessages(messages); + expect(uiMessages).toHaveLength(1); + + const toolPart = uiMessages[0].parts.find( + (p) => p.type === "tool-deleteFile", + ); + expect(toolPart).toBeDefined(); + expect((toolPart as any).state).toBe("approval-requested"); + expect((toolPart as any).approval?.id).toBe("approval-1"); + }); + + test("shows approval-responded state after approval", () => { + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "deleteFile", + input: { filename: "test.txt" }, + args: { filename: "test.txt" }, + }, + { + type: "tool-approval-request", + approvalId: "approval-1", + toolCallId: "call-1", + }, + ], + }, + }, + { + _id: "msg2", + _creationTime: Date.now() + 1, + order: 0, + stepOrder: 1, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "approval-1", + approved: true, + reason: "User approved", + }, + { + type: "tool-result", + toolCallId: "call-1", + toolName: "deleteFile", + output: { type: "text", value: "Deleted: test.txt" }, + }, + ], + }, + }, + ]; + + const uiMessages = toUIMessages(messages); + expect(uiMessages).toHaveLength(1); // Should be grouped into one assistant message + + const toolPart = uiMessages[0].parts.find( + (p) => p.type === "tool-deleteFile", + ); + expect(toolPart).toBeDefined(); + // After approval and output, state should be output-available + expect((toolPart as any).state).toBe("output-available"); + expect((toolPart as any).output).toBe("Deleted: test.txt"); + }); + + test("shows output-denied state after denial", () => { + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "deleteFile", + input: { filename: "test.txt" }, + args: { filename: "test.txt" }, + }, + { + type: "tool-approval-request", + approvalId: "approval-1", + toolCallId: "call-1", + }, + ], + }, + }, + { + _id: "msg2", + _creationTime: Date.now() + 1, + order: 0, + stepOrder: 1, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "approval-1", + approved: false, + reason: "User denied", + }, + { + type: "tool-result", + toolCallId: "call-1", + toolName: "deleteFile", + output: { + type: "execution-denied", + reason: "User denied", + }, + }, + ], + }, + }, + ]; + + const uiMessages = toUIMessages(messages); + expect(uiMessages).toHaveLength(1); + + const toolPart = uiMessages[0].parts.find( + (p) => p.type === "tool-deleteFile", + ); + expect(toolPart).toBeDefined(); + expect((toolPart as any).state).toBe("output-denied"); + expect((toolPart as any).approval?.approved).toBe(false); + expect((toolPart as any).approval?.reason).toBe("User denied"); + }); + }); + + describe("Conditional approval (needsApproval function)", () => { + test("needsApproval receives correct input", async () => { + const needsApprovalSpy = vi.fn().mockReturnValue(true); + + const testTool = createTool({ + description: "Test tool", + inputSchema: z.object({ value: z.number() }), + needsApproval: needsApprovalSpy, + execute: async (_ctx, input) => `Value: ${input.value}`, + }); + + // The needsApproval function is called by the AI SDK during tool execution + // We can verify the tool is set up correctly + expect(testTool.needsApproval).toBeDefined(); + }); + }); + + describe("order behavior after approval", () => { + test("continuation messages stay in the same order", async () => { + const t = initConvexTest(schema); + + const threadId = await t.run(async (ctx) => + createThread(ctx, components.agent, { userId: "user1" }), + ); + + // Save initial user message at order 0 + const { messageId: firstMsgId } = await t.run(async (ctx) => + approvalAgent.saveMessage(ctx, { + threadId, + message: { role: "user", content: "First message" }, + }), + ); + + // Get first message to check its order + const firstMsg = await t.run(async (ctx) => { + const result = await approvalAgent.listMessages(ctx, { + threadId, + paginationOpts: { cursor: null, numItems: 10 }, + }); + return result.page.find((m) => m._id === firstMsgId); + }); + + expect(firstMsg?.order).toBeDefined(); + // After tool approval, continuation messages should stay in the same order + // (incrementing stepOrder) rather than creating a new order. + // This keeps all assistant responses for a single user turn grouped together. + }); + }); + + describe("Tool execution context", () => { + test("tool receives correct context fields", async () => { + let capturedCtx: any = null; + + const contextCaptureTool = createTool({ + description: "Captures context", + inputSchema: z.object({}), + execute: async (ctx, _input) => { + capturedCtx = ctx; + return "captured"; + }, + }); + + // Verify the tool has the right structure + expect(contextCaptureTool.execute).toBeDefined(); + expect((contextCaptureTool as any).__acceptsCtx).toBe(true); + }); + }); + + describe("Message grouping with approvals", () => { + test("approval request and response in same group show correct final state", () => { + // When tool call, approval request, approval response, and result + // are all in the same message group (same order), the final state + // should reflect the completed state + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "checkBalance", + input: { accountId: "123" }, + args: { accountId: "123" }, + }, + ], + }, + }, + { + _id: "msg2", + _creationTime: Date.now() + 1, + order: 0, + stepOrder: 1, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "checkBalance", + output: { type: "text", value: "Balance: $500" }, + }, + ], + }, + }, + ]; + + const uiMessages = toUIMessages(messages); + expect(uiMessages).toHaveLength(1); + + const toolPart = uiMessages[0].parts.find( + (p) => p.type === "tool-checkBalance", + ); + expect(toolPart).toBeDefined(); + expect((toolPart as any).state).toBe("output-available"); + expect((toolPart as any).output).toBe("Balance: $500"); + }); + + test("handles tool result on previous page gracefully", () => { + // When we only have the tool result (tool call was on previous page) + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 1, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-orphan", + toolName: "someTool", + output: { type: "text", value: "Result from previous page" }, + }, + ], + }, + }, + ]; + + const uiMessages = toUIMessages(messages); + expect(uiMessages).toHaveLength(1); + + // Should create a standalone tool part + const toolPart = uiMessages[0].parts.find( + (p) => p.type === "tool-someTool", + ); + expect(toolPart).toBeDefined(); + expect((toolPart as any).output).toBe("Result from previous page"); + }); + }); + + describe("Error handling in approval workflow", () => { + test("execution-denied output is converted to text for providers", () => { + // This tests that the mapping layer converts execution-denied + // to text format for provider compatibility + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "deleteFile", + output: { + type: "execution-denied", + reason: "Operation not permitted", + }, + }, + ], + }, + }, + ]; + + const uiMessages = toUIMessages(messages); + expect(uiMessages).toHaveLength(1); + + const toolPart = uiMessages[0].parts.find( + (p) => p.type === "tool-deleteFile", + ); + expect(toolPart).toBeDefined(); + expect((toolPart as any).state).toBe("output-denied"); + }); + }); + + describe("Multiple tool calls with mixed approval requirements", () => { + test("handles mix of approved and non-approved tools", () => { + const messages: MessageDoc[] = [ + { + _id: "msg1", + _creationTime: Date.now(), + order: 0, + stepOrder: 0, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "assistant", + content: [ + // Tool that needs approval + { + type: "tool-call", + toolCallId: "call-1", + toolName: "deleteFile", + input: { filename: "test.txt" }, + args: { filename: "test.txt" }, + }, + { + type: "tool-approval-request", + approvalId: "approval-1", + toolCallId: "call-1", + }, + // Tool that doesn't need approval + { + type: "tool-call", + toolCallId: "call-2", + toolName: "checkBalance", + input: { accountId: "123" }, + args: { accountId: "123" }, + }, + ], + }, + }, + { + _id: "msg2", + _creationTime: Date.now() + 1, + order: 0, + stepOrder: 1, + status: "success", + threadId: "thread1", + tool: true, + message: { + role: "tool", + content: [ + // Result for non-approved tool (executed immediately) + { + type: "tool-result", + toolCallId: "call-2", + toolName: "checkBalance", + output: { type: "text", value: "Balance: $500" }, + }, + ], + }, + }, + ]; + + const uiMessages = toUIMessages(messages); + expect(uiMessages).toHaveLength(1); + + const deletePart = uiMessages[0].parts.find( + (p) => p.type === "tool-deleteFile", + ); + const balancePart = uiMessages[0].parts.find( + (p) => p.type === "tool-checkBalance", + ); + + expect(deletePart).toBeDefined(); + expect(balancePart).toBeDefined(); + + // Delete tool should be waiting for approval + expect((deletePart as any).state).toBe("approval-requested"); + + // Balance tool should have output + expect((balancePart as any).state).toBe("output-available"); + expect((balancePart as any).output).toBe("Balance: $500"); + }); + }); +}); + +describe("createTool with approval", () => { + test("createTool accepts needsApproval function", () => { + const tool = createTool({ + description: "Test", + inputSchema: z.object({ value: z.number() }), + needsApproval: (_ctx, input) => input.value > 100, + execute: async (_ctx, input) => `Value: ${input.value}`, + }); + + expect(tool).toBeDefined(); + expect(tool.needsApproval).toBeDefined(); + }); + + test("createTool accepts needsApproval boolean", () => { + const tool = createTool({ + description: "Test", + inputSchema: z.object({}), + needsApproval: true, + execute: async () => "done", + }); + + expect(tool).toBeDefined(); + // needsApproval is wrapped by the AI SDK, so check it's defined + expect(tool.needsApproval).toBeDefined(); + }); + + test("createTool works without needsApproval", () => { + const tool = createTool({ + description: "Test", + inputSchema: z.object({}), + execute: async () => "done", + }); + + expect(tool).toBeDefined(); + // The AI SDK may wrap needsApproval, so just verify the tool works + expect(tool.execute).toBeDefined(); + }); +}); diff --git a/src/client/createTool.ts b/src/client/createTool.ts index eb42d15b..77170843 100644 --- a/src/client/createTool.ts +++ b/src/client/createTool.ts @@ -11,7 +11,7 @@ import type { GenericActionCtx, GenericDataModel } from "convex/server"; import type { ProviderOptions } from "../validators.js"; import type { Agent } from "./index.js"; -const MIGRATION_URL = "https://github.com/get-convex/agent/blob/main/MIGRATION.md"; +const MIGRATION_URL = "https://github.com/get-convex/agent/blob/v0.6.0/MIGRATION.md"; const warnedDeprecations = new Set(); function warnDeprecation(key: string, message: string) { if (!warnedDeprecations.has(key)) { @@ -72,60 +72,57 @@ type NeverOptional = 0 extends 1 & N ? Partial> : T; +/** + * Error message type for deprecated 'handler' property. + * Using a string literal type causes TypeScript to show this message in errors. + */ +type HANDLER_REMOVED_ERROR = + "⚠️ 'handler' was removed in @convex-dev/agent v0.6.0. Rename to 'execute'. See: node_modules/@convex-dev/agent/MIGRATION.md"; + export type ToolOutputPropertiesCtx< INPUT, OUTPUT, Ctx extends ToolCtx = ToolCtx, > = NeverOptional< OUTPUT, - | { - /** - * An async function that is called with the arguments from the tool call and produces a result. - * If `execute` (or `handler`) is not provided, the tool will not be executed automatically. - * - * @param input - The input of the tool call. - * @param options.abortSignal - A signal that can be used to abort the tool call. - */ - execute: ToolExecuteFunctionCtx; - outputSchema?: FlexibleSchema; - handler?: never; - } - | { - /** @deprecated Use execute instead. */ - handler: ToolExecuteFunctionCtx; - outputSchema?: FlexibleSchema; - execute?: never; - } - | { - outputSchema: FlexibleSchema; - execute?: never; - handler?: never; - } + { + /** + * An async function that is called with the arguments from the tool call and produces a result. + * If `execute` is not provided, the tool will not be executed automatically. + * + * @param input - The input of the tool call. + * @param options.abortSignal - A signal that can be used to abort the tool call. + */ + execute?: ToolExecuteFunctionCtx; + outputSchema?: FlexibleSchema; + /** + * @deprecated Removed in v0.6.0. Use `execute` instead. + */ + handler?: HANDLER_REMOVED_ERROR; + } >; -export type ToolInputProperties = - | { - /** - * The schema of the input that the tool expects. - * The language model will use this to generate the input. - * It is also used to validate the output of the language model. - * - * You can use descriptions on the schema properties to make the input understandable for the language model. - */ - inputSchema: FlexibleSchema; - args?: never; - } - | { - /** - * The schema of the input that the tool expects. The language model will use this to generate the input. - * It is also used to validate the output of the language model. - * Use descriptions to make the input understandable for the language model. - * - * @deprecated Use inputSchema instead. - */ - args: FlexibleSchema; - inputSchema?: never; - }; +/** + * Error message type for deprecated 'args' property. + * Using a string literal type causes TypeScript to show this message in errors. + */ +type ARGS_REMOVED_ERROR = + "⚠️ 'args' was removed in @convex-dev/agent v0.6.0. Rename to 'inputSchema'. See: node_modules/@convex-dev/agent/MIGRATION.md"; + +export type ToolInputProperties = { + /** + * The schema of the input that the tool expects. + * The language model will use this to generate the input. + * It is also used to validate the output of the language model. + * + * You can use descriptions on the schema properties to make the input understandable for the language model. + */ + inputSchema: FlexibleSchema; + /** + * @deprecated Removed in v0.6.0. Use `inputSchema` instead. + */ + args?: ARGS_REMOVED_ERROR; +}; /** * This is a wrapper around the ai.tool function that adds extra context to the @@ -238,24 +235,25 @@ export function createTool( ) => ToolResultOutput | PromiseLike; }, ): Tool { - const inputSchema = def.inputSchema ?? def.args; + // Runtime backwards compat - types will show errors but runtime still works + const inputSchema = def.inputSchema ?? (def as any).args; if (!inputSchema) - throw new Error("To use a Convex tool, you must provide an `inputSchema` (or `args`)"); + throw new Error("To use a Convex tool, you must provide an `inputSchema`"); - if (def.args && !def.inputSchema) { + if ((def as any).args && !def.inputSchema) { warnDeprecation( "createTool.args", "createTool: 'args' is deprecated. Use 'inputSchema' instead.", ); } - if (def.handler && !def.execute) { + if ((def as any).handler && !def.execute) { warnDeprecation( "createTool.handler", "createTool: 'handler' is deprecated. Use 'execute' instead.", ); } - const executeHandler = def.execute ?? def.handler; + const executeHandler = def.execute ?? (def as any).handler; if (!executeHandler && !def.outputSchema) throw new Error( "To use a Convex tool, you must either provide an execute" + diff --git a/src/client/index.ts b/src/client/index.ts index dd0041f9..6f9b78d8 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -19,7 +19,7 @@ import type { } from "ai"; import { generateObject, generateText, stepCountIs, streamObject } from "ai"; -const MIGRATION_URL = "https://github.com/get-convex/agent/blob/main/MIGRATION.md"; +const MIGRATION_URL = "https://github.com/get-convex/agent/blob/v0.6.0/MIGRATION.md"; const warnedDeprecations = new Set(); function warnDeprecation(key: string, message: string) { if (!warnedDeprecations.has(key)) { @@ -1508,4 +1508,225 @@ export class Agent< }, }); } + + /** + * Approve a pending tool call and save the approval as a pending message. + * + * This is a helper for the AI SDK v6 tool approval workflow. When a tool + * with `needsApproval: true` is called, it returns a `tool-approval-request`. + * Call this method to approve the tool call, then call `streamText` (or + * `generateText`) with the returned `messageId` as `promptMessageId` to + * continue generation. The AI SDK will execute the tool during that call, + * so runtime tools and streaming options are available. + * + * @param ctx The context from a mutation or action. + * @param args.threadId The thread containing the tool call. + * @param args.approvalId The approval ID from the tool-approval-request. + * @param args.reason Optional reason for the approval. + * @returns The messageId of the saved approval, for use as promptMessageId. + */ + async approveToolCall( + ctx: (ActionCtx | MutationCtx) & CustomCtx, + args: { + threadId: string; + approvalId: string; + reason?: string; + }, + ): Promise<{ messageId: string }> { + const { threadId, approvalId, reason } = args; + + // Look up tool call info using indexed query (O(1)) + const toolInfo = await this._findToolCallInfo(ctx, threadId, approvalId); + + if (!toolInfo) { + throw new Error( + `Approval request not found: ${approvalId}`, + ); + } + + if (toolInfo.alreadyHandled) { + if (toolInfo.wasApproved) { + throw new Error( + `Tool call was already approved (approval ID: ${approvalId})`, + ); + } + throw new Error( + `Cannot approve tool call that was already denied (approval ID: ${approvalId})`, + ); + } + + // Save approval response as a pending message. The tool execution info + // is stored in providerOptions so startGeneration can execute the tool + // with the caller's runtime tools when streamText/generateText is called. + const { messageId } = await this.saveMessage(ctx, { + threadId, + promptMessageId: toolInfo.parentMessageId, + message: { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId, + approved: true, + reason, + providerOptions: { + "convex-agent": { + pendingToolExecution: true, + toolCallId: toolInfo.toolCallId, + toolName: toolInfo.toolName, + toolInput: toolInfo.toolInput, + }, + }, + }, + ], + }, + metadata: { status: "pending" }, + skipEmbeddings: true, + }); + + // Update the approval status for idempotency + await ctx.runMutation(this.component.messages.updateApprovalStatus, { + approvalId, + status: "approved", + }); + + return { messageId }; + } + + /** + * Deny a pending tool call and save the denial. + * + * This is a helper for the AI SDK v6 tool approval workflow. When a tool + * with `needsApproval: true` is called, it returns a `tool-approval-request`. + * Call this method to deny the tool, then use the returned `messageId` as + * `promptMessageId` in a follow-up `streamText` call to let the LLM respond + * to the denial. + * + * @param ctx The context from an action. + * @param args.threadId The thread containing the tool call. + * @param args.approvalId The approval ID from the tool-approval-request. + * @param args.reason Optional reason for the denial. + * @returns The messageId of the saved denial, for use as promptMessageId. + */ + async denyToolCall( + ctx: (ActionCtx | MutationCtx) & CustomCtx, + args: { + threadId: string; + approvalId: string; + reason?: string; + }, + ): Promise<{ messageId: string }> { + const { threadId, approvalId, reason } = args; + + // Look up tool call info using indexed query (O(1)) + const toolInfo = await this._findToolCallInfo(ctx, threadId, approvalId); + + if (!toolInfo) { + throw new Error( + `Approval request not found: ${approvalId}`, + ); + } + + if (toolInfo.alreadyHandled) { + if (!toolInfo.wasApproved) { + throw new Error( + `Tool call was already denied (approval ID: ${approvalId})`, + ); + } + throw new Error( + `Cannot deny tool call that was already approved (approval ID: ${approvalId})`, + ); + } + + const denialReason = reason ?? "Tool execution was denied by the user"; + + // Save approval response (denied) and tool result with execution-denied + const { messageId } = await this.saveMessage(ctx, { + threadId, + promptMessageId: toolInfo.parentMessageId, + message: { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId, + approved: false, + reason: denialReason, + }, + { + type: "tool-result", + toolCallId: toolInfo.toolCallId, + toolName: toolInfo.toolName, + output: { + type: "execution-denied", + reason: denialReason, + }, + }, + ], + }, + skipEmbeddings: true, + }); + + // Update the approval status for idempotency + await ctx.runMutation(this.component.messages.updateApprovalStatus, { + approvalId, + status: "denied", + }); + + return { messageId }; + } + + /** + * Find tool call information by approvalId using O(1) indexed lookup. + * Returns null if not found, or tool info with alreadyHandled flag if processed. + * @internal + */ + private async _findToolCallInfo( + ctx: ActionCtx | MutationCtx, + threadId: string, + approvalId: string, + ): Promise<{ + toolCallId: string; + toolName: string; + toolInput: Record; + parentMessageId: string; + alreadyHandled?: boolean; + wasApproved?: boolean; + } | null> { + // O(1) indexed lookup by approvalId + const message = await ctx.runQuery( + this.component.messages.getByApprovalId, + { approvalId }, + ); + + if (!message) { + return null; + } + + // Verify thread ID for security + if (message.threadId !== threadId) { + return null; + } + + // Check if already handled based on approval status + const status = (message as any).approvalStatus; + if (status === "approved" || status === "denied") { + return { + toolCallId: (message as any).approvalToolCallId, + toolName: (message as any).approvalToolName, + toolInput: (message as any).approvalToolInput ?? {}, + parentMessageId: message._id, + alreadyHandled: true, + wasApproved: status === "approved", + }; + } + + // Return the tool context for pending approvals + return { + toolCallId: (message as any).approvalToolCallId, + toolName: (message as any).approvalToolName, + toolInput: (message as any).approvalToolInput ?? {}, + parentMessageId: message._id, + }; + } } diff --git a/src/client/saveInputMessages.ts b/src/client/saveInputMessages.ts index 8c659516..fadb821e 100644 --- a/src/client/saveInputMessages.ts +++ b/src/client/saveInputMessages.ts @@ -37,6 +37,26 @@ export async function saveInputMessages( pendingMessage: MessageDoc; savedMessages: MessageDoc[]; }> { + // If the promptMessageId points to an already-pending message (e.g. from + // approveToolCall), reuse it directly — don't create a new pending message + // and don't fail existing pending steps. + if (args.promptMessageId && "runQuery" in ctx) { + try { + const [msg] = await ctx.runQuery(component.messages.getMessagesByIds, { + messageIds: [args.promptMessageId], + }); + if (msg?.status === "pending") { + return { + promptMessageId: args.promptMessageId, + pendingMessage: msg, + savedMessages: [], + }; + } + } catch { + // ID validation may fail in test environments with mock IDs — fall through. + } + } + const shouldSave = args.storageOptions?.saveMessages ?? "promptAndOutput"; // If only a promptMessageId is provided, this will be empty. const promptArray = getPromptArray(prompt); diff --git a/src/client/search.test.ts b/src/client/search.test.ts index a3c1c034..aed2138d 100644 --- a/src/client/search.test.ts +++ b/src/client/search.test.ts @@ -292,7 +292,9 @@ describe("search.ts", () => { expect(result[1]._id).toBe("2"); }); - it("should filter out tool calls with approval request but NO approval response", () => { + it("should keep tool calls with approval request even without approval response", () => { + // Tool calls with an approval-request should be kept because the + // approval flow may be in progress (response saved as pending). const messages: MessageDoc[] = [ { _id: "1", @@ -321,19 +323,18 @@ describe("search.ts", () => { const result = filterOutOrphanedToolMessages(messages); expect(result).toHaveLength(1); - // The assistant message should have the tool-call filtered out const assistantContent = result[0].message?.content; expect(Array.isArray(assistantContent)).toBe(true); if (Array.isArray(assistantContent)) { - // Text and approval-request should remain, but tool-call should be filtered - expect(assistantContent).toHaveLength(2); + // All parts should remain: text, tool-call, and approval-request + expect(assistantContent).toHaveLength(3); expect(assistantContent.find((p) => p.type === "text")).toBeDefined(); expect( - assistantContent.find((p) => p.type === "tool-approval-request"), + assistantContent.find((p) => p.type === "tool-call"), ).toBeDefined(); expect( - assistantContent.find((p) => p.type === "tool-call"), - ).toBeUndefined(); + assistantContent.find((p) => p.type === "tool-approval-request"), + ).toBeDefined(); } }); diff --git a/src/client/search.ts b/src/client/search.ts index 62ab4cda..c5cfdc6c 100644 --- a/src/client/search.ts +++ b/src/client/search.ts @@ -294,7 +294,8 @@ export function filterOutOrphanedToolMessages(docs: MessageDoc[]) { (p) => p.type !== "tool-call" || toolResultIds.has(p.toolCallId) || - hasApprovalResponse(p.toolCallId), + hasApprovalResponse(p.toolCallId) || + approvalRequestsByToolCallId.has(p.toolCallId), ); if (content.length) { result.push({ diff --git a/src/client/start.ts b/src/client/start.ts index f9124032..a82baf17 100644 --- a/src/client/start.ts +++ b/src/client/start.ts @@ -112,6 +112,7 @@ export async function startGeneration< | { step: StepResult } | { object: GenerateObjectResult }, createPendingMessage?: boolean, + finishStreamId?: string, ) => Promise; fail: (reason: string) => Promise; getSavedMessages: () => MessageDoc[]; @@ -184,6 +185,107 @@ export async function startGeneration< agent: opts.agentForToolCtx, } satisfies ToolCtx; const tools = wrapTools(toolCtx, args.tools) as Tools; + + // Handle pending tool approval: execute the approved tool now that runtime + // tools are available. The tool info was stored by approveToolCall in the + // approval part's providerOptions. + if (pendingMessage?.message?.role === "tool" && threadId) { + const approvalPart = ( + Array.isArray(pendingMessage.message.content) + ? pendingMessage.message.content + : [] + ).find( + (p: any) => + p.type === "tool-approval-response" && + p.approved === true && + p.providerOptions?.["convex-agent"]?.pendingToolExecution, + ) as any; + + if (approvalPart) { + const meta = approvalPart.providerOptions["convex-agent"]; + const { toolCallId, toolName, toolInput } = meta; + const tool = (tools as Record)[toolName]; + + let resultValue: string; + if (tool?.execute) { + try { + const output = await tool.execute(toolInput, { + toolCallId, + messages: context.messages, + }); + resultValue = + typeof output === "string" ? output : JSON.stringify(output); + } catch (error) { + resultValue = `Error: ${error instanceof Error ? error.message : String(error)}`; + console.error("Tool execution error:", error); + } + } else { + resultValue = `Error: Tool not found: ${toolName}`; + console.error(`Tool not found for approval execution: ${toolName}`); + } + + // Save the tool result by replacing the pending message (merging + // approval-response + tool-result) and creating a new pending + // assistant message for the LLM output. + const saved = await ctx.runMutation(component.messages.addMessages, { + userId, + threadId, + agentName: opts.agentName, + promptMessageId, + pendingMessageId, + messages: [ + { + message: { + role: "tool" as const, + content: [ + { + type: "tool-result" as const, + toolCallId, + toolName, + output: { type: "text" as const, value: resultValue }, + }, + ], + }, + }, + { + message: { role: "assistant" as const, content: [] }, + status: "pending" as const, + }, + ], + embeddings: undefined, + failPendingSteps: false, + }); + + const newPendingMessage = saved.messages.at(-1)!; + savedMessages.push(...saved.messages.slice(0, -1)); + pendingMessageId = newPendingMessage._id; + + // Remove empty tool messages from context (e.g. approval-request + // messages whose content was stripped by toModelMessage). + context.messages = context.messages.filter( + (m) => + !( + m.role === "tool" && + Array.isArray(m.content) && + m.content.length === 0 + ), + ); + + // Add tool result to context so the LLM sees the completed tool call. + context.messages.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId, + toolName, + output: { type: "text" as const, value: resultValue }, + }, + ], + }); + } + } + const aiArgs = { ...opts.callSettings, providerOptions: opts.providerOptions, @@ -228,6 +330,11 @@ export async function startGeneration< | { step: StepResult } | { object: GenerateObjectResult }, createPendingMessage?: boolean, + /** + * If provided, finish this stream atomically with the message save. + * This prevents UI flickering from separate mutations (issue #181). + */ + finishStreamId?: string, ) => { if (threadId && saveMessages !== "none") { const serialized = @@ -265,6 +372,7 @@ export async function startGeneration< messages: serialized.messages, embeddings, failPendingSteps: false, + finishStreamId: finishStreamId as any, // optional stream to finish atomically }); const lastMessage = saved.messages.at(-1)!; if (createPendingMessage) { diff --git a/src/client/streamText.ts b/src/client/streamText.ts index ea500dac..d8757607 100644 --- a/src/client/streamText.ts +++ b/src/client/streamText.ts @@ -80,6 +80,9 @@ export async function streamText< const steps: StepResult[] = []; + // Track the final step for atomic save with stream finish (issue #181) + let pendingFinalStep: StepResult | undefined; + const streamer = threadId && options.saveStreamDeltas ? new DeltaStreamer( @@ -138,20 +141,42 @@ export async function streamText< onStepFinish: async (step) => { steps.push(step); const createPendingMessage = await willContinue(steps, args.stopWhen); - await call.save({ step }, createPendingMessage); + if (!createPendingMessage && streamer) { + // This is the final step with streaming enabled. + // Defer saving until stream consumption completes for atomic finish (issue #181). + streamer.markFinishedExternally(); + pendingFinalStep = step; + } else { + await call.save({ step }, createPendingMessage); + } return args.onStepFinish?.(step); }, }) as StreamTextResult; const stream = streamer?.consumeStream( result.toUIMessageStream>(), ); - if ( - (typeof options?.saveStreamDeltas === "object" && - !options.saveStreamDeltas.returnImmediately) || - options?.saveStreamDeltas === true - ) { - await stream; - await result.consumeStream(); + try { + if ( + (typeof options?.saveStreamDeltas === "object" && + !options.saveStreamDeltas.returnImmediately) || + options?.saveStreamDeltas === true + ) { + await stream; + await result.consumeStream(); + } + } catch (error) { + // If an error occurs during streaming (e.g., in onStepFinish callbacks), + // make sure to abort the streaming message so it doesn't get stuck + if (streamer) { + await streamer.fail(errorToString(error)); + } + throw error; + } + + // If we deferred the final step save, do it now with atomic stream finish + if (pendingFinalStep && streamer) { + const finishStreamId = await streamer.getOrCreateStreamId(); + await call.save({ step: pendingFinalStep }, false, finishStreamId); } const metadata: GenerationOutputMetadata = { promptMessageId, diff --git a/src/client/streaming.ts b/src/client/streaming.ts index 194631f1..cbfc5d46 100644 --- a/src/client/streaming.ts +++ b/src/client/streaming.ts @@ -210,6 +210,9 @@ export class DeltaStreamer { #ongoingWrite: Promise | undefined; #cursor: number = 0; public abortController: AbortController; + // When true, the stream will be finished externally (e.g., atomically via addMessages) + // and consumeStream should skip calling finish(). + #finishedExternally: boolean = false; constructor( public readonly component: AgentComponent, @@ -290,7 +293,28 @@ export class DeltaStreamer { for await (const chunk of stream) { await this.addParts([chunk]); } - await this.finish(); + // Skip finish if it will be handled externally (atomically with message save) + if (!this.#finishedExternally) { + await this.finish(); + } + } + + /** + * Mark the stream as being finished externally (e.g., atomically via addMessages). + * When called, consumeStream() will skip calling finish() since it will be + * handled elsewhere in the same mutation as message saving. + */ + public markFinishedExternally(): void { + this.#finishedExternally = true; + } + + /** + * Get the stream ID, waiting for it to be created if necessary. + * Useful for passing to addMessages for atomic finish. + */ + public async getOrCreateStreamId(): Promise { + await this.getStreamId(); + return this.streamId!; } async #sendDelta() { diff --git a/src/client/types.ts b/src/client/types.ts index 2533d029..ea07439a 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -46,6 +46,14 @@ import type { import type { StreamingOptions } from "./streaming.js"; import type { ComponentApi } from "../component/_generated/component.js"; +/** + * Type-level check that ensures models are from AI SDK v6. + * If a v5 model (LanguageModelV2) is passed, TypeScript will show the error message string. + */ +type AssertAISDKv6 = T extends { specificationVersion: "v3" } + ? T + : "⚠️ @convex-dev/agent v0.6.0 requires AI SDK v6. Update your dependencies: npm install ai@^6.0.35 @ai-sdk/openai@^3.0.10 (or other provider). See: node_modules/@convex-dev/agent/MIGRATION.md"; + export type AgentPrompt = { /** * System message to include in the prompt. Overwrites Agent instructions. @@ -91,23 +99,17 @@ export type AgentPrompt = { export type Config = { /** * The LLM model to use for generating / streaming text and objects. - * e.g. + * Requires AI SDK v6 (@ai-sdk/* packages v3.x). + * + * @example * import { openai } from "@ai-sdk/openai" * const myAgent = new Agent(components.agent, { * languageModel: openai.chat("gpt-4o-mini"), + * }) */ - languageModel?: LanguageModel; + languageModel?: AssertAISDKv6; /** - * The model to use for text embeddings. Optional. - * If specified, it will use this for generating vector embeddings - * of chats, and can opt-in to doing vector search for automatic context - * on generateText, etc. - * e.g. - * import { openai } from "@ai-sdk/openai" - * const myAgent = new Agent(components.agent, { - * ... - * textEmbeddingModel: openai.embedding("text-embedding-3-small") - * @deprecated — Use embeddingModel instead. + * @deprecated Use `embeddingModel` instead. */ textEmbeddingModel?: EmbeddingModel; /** diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index f6708edc..65e2dc22 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -154,6 +154,7 @@ export type ComponentApi = vectors: Array | null>; }; failPendingSteps?: boolean; + finishStreamId?: string; hideFromUserIdSearch?: boolean; messages: Array<{ error?: string; @@ -683,6 +684,15 @@ export type ComponentApi = usage?: { cachedInputTokens?: number; completionTokens: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + noCacheTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + textTokens?: number; + }; promptTokens: number; reasoningTokens?: number; totalTokens: number; @@ -707,6 +717,11 @@ export type ComponentApi = _creationTime: number; _id: string; agentName?: string; + approvalId?: string; + approvalStatus?: "pending" | "approved" | "denied"; + approvalToolCallId?: string; + approvalToolInput?: any; + approvalToolName?: string; embeddingId?: string; error?: string; fileIds?: Array; @@ -1241,69 +1256,604 @@ export type ComponentApi = usage?: { cachedInputTokens?: number; completionTokens: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + noCacheTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + textTokens?: number; + }; promptTokens: number; reasoningTokens?: number; - totalTokens: number; + totalTokens: number; + }; + userId?: string; + warnings?: Array< + | { + details?: string; + setting: string; + type: "unsupported-setting"; + } + | { details?: string; tool: any; type: "unsupported-tool" } + | { message: string; type: "other" } + >; + }>; + }, + Name + >; + cloneThread: FunctionReference< + "action", + "internal", + { + batchSize?: number; + copyUserIdForVectorSearch?: boolean; + excludeToolMessages?: boolean; + insertAtOrder?: number; + limit?: number; + sourceThreadId: string; + statuses?: Array<"pending" | "success" | "failed">; + targetThreadId: string; + upToAndIncludingMessageId?: string; + }, + number, + Name + >; + deleteByIds: FunctionReference< + "mutation", + "internal", + { messageIds: Array }, + Array, + Name + >; + deleteByOrder: FunctionReference< + "mutation", + "internal", + { + endOrder: number; + endStepOrder?: number; + startOrder: number; + startStepOrder?: number; + threadId: string; + }, + { isDone: boolean; lastOrder?: number; lastStepOrder?: number }, + Name + >; + finalizeMessage: FunctionReference< + "mutation", + "internal", + { + messageId: string; + result: { status: "success" } | { error: string; status: "failed" }; + }, + null, + Name + >; + getByApprovalId: FunctionReference< + "query", + "internal", + { approvalId: string }, + { + _creationTime: number; + _id: string; + agentName?: string; + approvalId?: string; + approvalStatus?: "pending" | "approved" | "denied"; + approvalToolCallId?: string; + approvalToolInput?: any; + approvalToolName?: string; + embeddingId?: string; + error?: string; + fileIds?: Array; + finishReason?: + | "stop" + | "length" + | "content-filter" + | "tool-calls" + | "error" + | "other" + | "unknown"; + id?: string; + message?: + | { + content: + | string + | Array< + | { + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + text: string; + type: "text"; + } + | { + image: string | ArrayBuffer; + mediaType?: string; + mimeType?: string; + providerOptions?: Record>; + type: "image"; + } + | { + data: string | ArrayBuffer; + filename?: string; + mediaType?: string; + mimeType?: string; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + type: "file"; + } + >; + providerOptions?: Record>; + role: "user"; + } + | { + content: + | string + | Array< + | { + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + text: string; + type: "text"; + } + | { + data: string | ArrayBuffer; + filename?: string; + mediaType?: string; + mimeType?: string; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + type: "file"; + } + | { + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + signature?: string; + text: string; + type: "reasoning"; + } + | { + data: string; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + type: "redacted-reasoning"; + } + | { + args?: any; + input: any; + providerExecuted?: boolean; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + toolCallId: string; + toolName: string; + type: "tool-call"; + } + | { + args: any; + input?: any; + providerExecuted?: boolean; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + toolCallId: string; + toolName: string; + type: "tool-call"; + } + | { + args?: any; + experimental_content?: Array< + | { text: string; type: "text" } + | { data: string; mimeType?: string; type: "image" } + >; + isError?: boolean; + output?: + | { + providerOptions?: Record< + string, + Record + >; + type: "text"; + value: string; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "json"; + value: any; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "error-text"; + value: string; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "error-json"; + value: any; + } + | { + providerOptions?: Record< + string, + Record + >; + reason?: string; + type: "execution-denied"; + } + | { + type: "content"; + value: Array< + | { + providerOptions?: Record< + string, + Record + >; + text: string; + type: "text"; + } + | { + data: string; + mediaType: string; + type: "media"; + } + | { + data: string; + filename?: string; + mediaType: string; + providerOptions?: Record< + string, + Record + >; + type: "file-data"; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "file-url"; + url: string; + } + | { + fileId: string | Record; + providerOptions?: Record< + string, + Record + >; + type: "file-id"; + } + | { + data: string; + mediaType: string; + providerOptions?: Record< + string, + Record + >; + type: "image-data"; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "image-url"; + url: string; + } + | { + fileId: string | Record; + providerOptions?: Record< + string, + Record + >; + type: "image-file-id"; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "custom"; + } + >; + }; + providerExecuted?: boolean; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + result?: any; + toolCallId: string; + toolName: string; + type: "tool-result"; + } + | { + id: string; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + sourceType: "url"; + title?: string; + type: "source"; + url: string; + } + | { + filename?: string; + id: string; + mediaType: string; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + sourceType: "document"; + title: string; + type: "source"; + } + | { + approvalId: string; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + toolCallId: string; + type: "tool-approval-request"; + } + >; + providerOptions?: Record>; + role: "assistant"; + } + | { + content: Array< + | { + args?: any; + experimental_content?: Array< + | { text: string; type: "text" } + | { data: string; mimeType?: string; type: "image" } + >; + isError?: boolean; + output?: + | { + providerOptions?: Record< + string, + Record + >; + type: "text"; + value: string; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "json"; + value: any; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "error-text"; + value: string; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "error-json"; + value: any; + } + | { + providerOptions?: Record< + string, + Record + >; + reason?: string; + type: "execution-denied"; + } + | { + type: "content"; + value: Array< + | { + providerOptions?: Record< + string, + Record + >; + text: string; + type: "text"; + } + | { + data: string; + mediaType: string; + type: "media"; + } + | { + data: string; + filename?: string; + mediaType: string; + providerOptions?: Record< + string, + Record + >; + type: "file-data"; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "file-url"; + url: string; + } + | { + fileId: string | Record; + providerOptions?: Record< + string, + Record + >; + type: "file-id"; + } + | { + data: string; + mediaType: string; + providerOptions?: Record< + string, + Record + >; + type: "image-data"; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "image-url"; + url: string; + } + | { + fileId: string | Record; + providerOptions?: Record< + string, + Record + >; + type: "image-file-id"; + } + | { + providerOptions?: Record< + string, + Record + >; + type: "custom"; + } + >; + }; + providerExecuted?: boolean; + providerMetadata?: Record>; + providerOptions?: Record>; + result?: any; + toolCallId: string; + toolName: string; + type: "tool-result"; + } + | { + approvalId: string; + approved: boolean; + providerExecuted?: boolean; + providerMetadata?: Record>; + providerOptions?: Record>; + reason?: string; + type: "tool-approval-response"; + } + >; + providerOptions?: Record>; + role: "tool"; + } + | { + content: string; + providerOptions?: Record>; + role: "system"; + }; + model?: string; + order: number; + provider?: string; + providerMetadata?: Record>; + providerOptions?: Record>; + reasoning?: string; + reasoningDetails?: Array< + | { + providerMetadata?: Record>; + providerOptions?: Record>; + signature?: string; + text: string; + type: "reasoning"; + } + | { signature?: string; text: string; type: "text" } + | { data: string; type: "redacted" } + >; + sources?: Array< + | { + id: string; + providerMetadata?: Record>; + providerOptions?: Record>; + sourceType: "url"; + title?: string; + type?: "source"; + url: string; + } + | { + filename?: string; + id: string; + mediaType: string; + providerMetadata?: Record>; + providerOptions?: Record>; + sourceType: "document"; + title: string; + type: "source"; + } + >; + status: "pending" | "success" | "failed"; + stepOrder: number; + text?: string; + threadId: string; + tool: boolean; + usage?: { + cachedInputTokens?: number; + completionTokens: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + noCacheTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + textTokens?: number; }; - userId?: string; - warnings?: Array< - | { - details?: string; - setting: string; - type: "unsupported-setting"; - } - | { details?: string; tool: any; type: "unsupported-tool" } - | { message: string; type: "other" } - >; - }>; - }, - Name - >; - cloneThread: FunctionReference< - "action", - "internal", - { - batchSize?: number; - copyUserIdForVectorSearch?: boolean; - excludeToolMessages?: boolean; - insertAtOrder?: number; - limit?: number; - sourceThreadId: string; - statuses?: Array<"pending" | "success" | "failed">; - targetThreadId: string; - upToAndIncludingMessageId?: string; - }, - number, - Name - >; - deleteByIds: FunctionReference< - "mutation", - "internal", - { messageIds: Array }, - Array, - Name - >; - deleteByOrder: FunctionReference< - "mutation", - "internal", - { - endOrder: number; - endStepOrder?: number; - startOrder: number; - startStepOrder?: number; - threadId: string; - }, - { isDone: boolean; lastOrder?: number; lastStepOrder?: number }, - Name - >; - finalizeMessage: FunctionReference< - "mutation", - "internal", - { - messageId: string; - result: { status: "success" } | { error: string; status: "failed" }; - }, - null, + promptTokens: number; + reasoningTokens?: number; + totalTokens: number; + }; + userId?: string; + warnings?: Array< + | { details?: string; setting: string; type: "unsupported-setting" } + | { details?: string; tool: any; type: "unsupported-tool" } + | { message: string; type: "other" } + >; + } | null, Name >; getMessagesByIds: FunctionReference< @@ -1314,6 +1864,11 @@ export type ComponentApi = _creationTime: number; _id: string; agentName?: string; + approvalId?: string; + approvalStatus?: "pending" | "approved" | "denied"; + approvalToolCallId?: string; + approvalToolInput?: any; + approvalToolName?: string; embeddingId?: string; error?: string; fileIds?: Array; @@ -1420,22 +1975,19 @@ export type ComponentApi = toolName: string; type: "tool-call"; } - | { - args: any; - input?: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } + | { + args: any; + input?: any; + providerExecuted?: boolean; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + toolCallId: string; + toolName: string; + type: "tool-call"; + } | { args?: any; experimental_content?: Array< @@ -1808,6 +2360,15 @@ export type ComponentApi = usage?: { cachedInputTokens?: number; completionTokens: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + noCacheTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + textTokens?: number; + }; promptTokens: number; reasoningTokens?: number; totalTokens: number; @@ -1853,6 +2414,11 @@ export type ComponentApi = _creationTime: number; _id: string; agentName?: string; + approvalId?: string; + approvalStatus?: "pending" | "approved" | "denied"; + approvalToolCallId?: string; + approvalToolInput?: any; + approvalToolName?: string; embeddingId?: string; error?: string; fileIds?: Array; @@ -2387,6 +2953,15 @@ export type ComponentApi = usage?: { cachedInputTokens?: number; completionTokens: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + noCacheTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + textTokens?: number; + }; promptTokens: number; reasoningTokens?: number; totalTokens: number; @@ -2427,6 +3002,11 @@ export type ComponentApi = _creationTime: number; _id: string; agentName?: string; + approvalId?: string; + approvalStatus?: "pending" | "approved" | "denied"; + approvalToolCallId?: string; + approvalToolInput?: any; + approvalToolName?: string; embeddingId?: string; error?: string; fileIds?: Array; @@ -2533,22 +3113,19 @@ export type ComponentApi = toolName: string; type: "tool-call"; } - | { - args: any; - input?: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } + | { + args: any; + input?: any; + providerExecuted?: boolean; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + toolCallId: string; + toolName: string; + type: "tool-call"; + } | { args?: any; experimental_content?: Array< @@ -2921,6 +3498,15 @@ export type ComponentApi = usage?: { cachedInputTokens?: number; completionTokens: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + noCacheTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + textTokens?: number; + }; promptTokens: number; reasoningTokens?: number; totalTokens: number; @@ -2948,6 +3534,11 @@ export type ComponentApi = _creationTime: number; _id: string; agentName?: string; + approvalId?: string; + approvalStatus?: "pending" | "approved" | "denied"; + approvalToolCallId?: string; + approvalToolInput?: any; + approvalToolName?: string; embeddingId?: string; error?: string; fileIds?: Array; @@ -3054,22 +3645,19 @@ export type ComponentApi = toolName: string; type: "tool-call"; } - | { - args: any; - input?: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } + | { + args: any; + input?: any; + providerExecuted?: boolean; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + toolCallId: string; + toolName: string; + type: "tool-call"; + } | { args?: any; experimental_content?: Array< @@ -3442,6 +4030,15 @@ export type ComponentApi = usage?: { cachedInputTokens?: number; completionTokens: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + noCacheTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + textTokens?: number; + }; promptTokens: number; reasoningTokens?: number; totalTokens: number; @@ -3455,6 +4052,13 @@ export type ComponentApi = }>, Name >; + updateApprovalStatus: FunctionReference< + "mutation", + "internal", + { approvalId: string; status: "approved" | "denied" }, + null, + Name + >; updateMessage: FunctionReference< "mutation", "internal", @@ -3957,6 +4561,11 @@ export type ComponentApi = _creationTime: number; _id: string; agentName?: string; + approvalId?: string; + approvalStatus?: "pending" | "approved" | "denied"; + approvalToolCallId?: string; + approvalToolInput?: any; + approvalToolName?: string; embeddingId?: string; error?: string; fileIds?: Array; @@ -4063,22 +4672,19 @@ export type ComponentApi = toolName: string; type: "tool-call"; } - | { - args: any; - input?: any; - providerExecuted?: boolean; - providerMetadata?: Record< - string, - Record - >; - providerOptions?: Record< - string, - Record - >; - toolCallId: string; - toolName: string; - type: "tool-call"; - } + | { + args: any; + input?: any; + providerExecuted?: boolean; + providerMetadata?: Record< + string, + Record + >; + providerOptions?: Record>; + toolCallId: string; + toolName: string; + type: "tool-call"; + } | { args?: any; experimental_content?: Array< @@ -4451,6 +5057,15 @@ export type ComponentApi = usage?: { cachedInputTokens?: number; completionTokens: number; + inputTokenDetails?: { + cacheReadTokens?: number; + cacheWriteTokens?: number; + noCacheTokens?: number; + }; + outputTokenDetails?: { + reasoningTokens?: number; + textTokens?: number; + }; promptTokens: number; reasoningTokens?: number; totalTokens: number; diff --git a/src/component/_generated/dataModel.ts b/src/component/_generated/dataModel.ts index 8541f319..f97fd194 100644 --- a/src/component/_generated/dataModel.ts +++ b/src/component/_generated/dataModel.ts @@ -38,7 +38,7 @@ export type Doc = DocumentByName< * Convex documents are uniquely identified by their `Id`, which is accessible * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * - * Documents can be loaded using `db.get(id)` in query and mutation functions. + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. diff --git a/src/component/_generated/server.ts b/src/component/_generated/server.ts index 24994e4e..739b02f7 100644 --- a/src/component/_generated/server.ts +++ b/src/component/_generated/server.ts @@ -107,11 +107,6 @@ export const internalAction: ActionBuilder = */ export const httpAction: HttpActionBuilder = httpActionGeneric; -type GenericCtx = - | GenericActionCtx - | GenericMutationCtx - | GenericQueryCtx; - /** * A set of services for use within Convex query functions. * diff --git a/src/component/messages.ts b/src/component/messages.ts index 627bf19e..1db69005 100644 --- a/src/component/messages.ts +++ b/src/component/messages.ts @@ -39,13 +39,53 @@ import { vVectorId, } from "./vector/tables.js"; import { changeRefcount } from "./files.js"; -import { getStreamingMessagesWithMetadata } from "./streams.js"; +import { getStreamingMessagesWithMetadata, finishHandler } from "./streams.js"; import { partial } from "convex-helpers/validators"; function publicMessage(message: Doc<"messages">): MessageDoc { return omit(message, ["parentMessageId", "stepId", "files"]); } +/** + * Extract approval fields from message content for O(1) indexed lookup. + * When a message contains a tool-approval-request, extract the tool context + * and store it as top-level fields for fast querying. + */ +function extractApprovalFields( + message: Omit>, "order" | "stepOrder">, +) { + if (!message.message || !Array.isArray(message.message.content)) { + return null; + } + + // Find tool-approval-request in content + const approvalRequest = message.message.content.find( + (p: any) => p.type === "tool-approval-request", + ) as any; + + if (!approvalRequest?.approvalId) { + return null; + } + + // Find corresponding tool-call in the same message + const toolCall = message.message.content.find( + (p: any) => + p.type === "tool-call" && p.toolCallId === approvalRequest.toolCallId, + ) as any; + + if (!toolCall) { + return null; + } + + return { + approvalId: approvalRequest.approvalId, + approvalToolCallId: toolCall.toolCallId, + approvalToolName: toolCall.toolName, + approvalToolInput: toolCall.input ?? toolCall.args ?? {}, + approvalStatus: "pending" as const, + }; +} + export async function deleteMessage( ctx: MutationCtx, messageDoc: Doc<"messages">, @@ -141,6 +181,9 @@ const addMessagesArgs = { // if set to true, these messages will not show up in text or vector search // results for the userId hideFromUserIdSearch: v.optional(v.boolean()), + // If provided, finish this stream atomically with the message save. + // This prevents UI flickering from separate mutations (issue #181). + finishStreamId: v.optional(v.id("streamingMessages")), }; export const addMessages = mutation({ args: addMessagesArgs, @@ -167,6 +210,7 @@ async function addMessagesHandler( hideFromUserIdSearch, ...rest } = args; + const promptMessage = promptMessageId && (await ctx.db.get(promptMessageId)); if (failPendingSteps) { assert(args.threadId, "threadId is required to fail pending steps"); @@ -218,6 +262,42 @@ async function addMessagesHandler( "embeddings.vectors.length must match messages.length", ); } + + // When the pending message already has content (e.g. approval response from + // approveToolCall), we need to merge that content into the first incoming + // message rather than discarding it. Handle role mismatches by swapping or + // finalizing the pending message. + let activePendingMessageId = pendingMessageId; + if (activePendingMessageId) { + const pm = await ctx.db.get(activePendingMessageId); + if ( + pm?.message && + Array.isArray(pm.message.content) && + pm.message.content.length > 0 + ) { + const matchIdx = messages.findIndex( + (m) => m.message.role === pm.message!.role, + ); + if (matchIdx > 0) { + // Swap matching message to position 0 for merge + [messages[0], messages[matchIdx]] = [messages[matchIdx], messages[0]]; + if (embeddings) { + [embeddings.vectors[0], embeddings.vectors[matchIdx]] = [ + embeddings.vectors[matchIdx], + embeddings.vectors[0], + ]; + } + } else if ( + matchIdx === -1 && + messages[0]?.message.role !== pm.message.role + ) { + // No matching role in batch — finalize pending as completed + await ctx.db.patch(activePendingMessageId, { status: "success" }); + activePendingMessageId = undefined; + } + } + } + for (let i = 0; i < messages.length; i++) { const message = messages[i]; let embeddingId: VectorTableId | undefined; @@ -251,17 +331,43 @@ async function addMessagesHandler( >; // If there is a pending message, we replace that one with the first message // and subsequent ones will follow the regular order/subOrder advancement. - if (i === 0 && pendingMessageId) { - const pendingMessage = await ctx.db.get(pendingMessageId); - assert(pendingMessage, `Pending msg ${pendingMessageId} not found`); + if (i === 0 && activePendingMessageId) { + const pendingMessage = await ctx.db.get(activePendingMessageId); + assert( + pendingMessage, + `Pending msg ${activePendingMessageId} not found`, + ); if (pendingMessage.status === "failed") { fail = true; error = - `Trying to update a message that failed: ${pendingMessageId}, ` + + `Trying to update a message that failed: ${activePendingMessageId}, ` + `error: ${pendingMessage.error ?? error}`; messageDoc.status = "failed"; messageDoc.error = error; } + + // Merge: prepend any existing pending content into the new message. + // Both messages have the same role (ensured by the swap/finalize logic + // above), so the content arrays are compatible at runtime. + const pendingContent = pendingMessage.message?.content; + if ( + Array.isArray(pendingContent) && + pendingContent.length > 0 && + Array.isArray(messageDoc.message?.content) + ) { + // The pending and incoming content have the same role, but TS can't + // prove the union is compatible across the role discriminant, so we + // cast the merged content to match the incoming message's content type. + (messageDoc.message as any).content = [ + ...pendingContent, + ...messageDoc.message.content, + ]; + messageDoc.tool = isTool(messageDoc.message); + messageDoc.text = hideFromUserIdSearch + ? undefined + : extractText(messageDoc.message); + } + if (message.fileIds) { await changeRefcount( ctx, @@ -292,10 +398,14 @@ async function addMessagesHandler( } stepOrder++; } + // Extract approval fields for indexed lookup (if present) + const approvalFields = extractApprovalFields(messageDoc); + const messageId = await ctx.db.insert("messages", { ...messageDoc, order, stepOrder, + ...(approvalFields || {}), }); if (message.fileIds) { await changeRefcount(ctx, [], message.fileIds); @@ -303,6 +413,11 @@ async function addMessagesHandler( // TODO: delete the associated stream data for the order/stepOrder toReturn.push((await ctx.db.get(messageId))!); } + // Atomically finish the stream if requested, preventing UI flickering + // from separate mutations for message save and stream finish (issue #181). + if (args.finishStreamId) { + await finishHandler(ctx, { streamId: args.finishStreamId }); + } return { messages: toReturn.map(publicMessage) }; } @@ -970,3 +1085,42 @@ export const getMessageSearchFields = query({ }; }, }); + +/** + * Get a message by its approvalId field (O(1) indexed lookup). + * Used by approval workflow to quickly find approval requests. + */ +export const getByApprovalId = query({ + args: { approvalId: v.string() }, + returns: v.union(vMessageDoc, v.null()), + handler: async (ctx, { approvalId }) => { + const message = await ctx.db + .query("messages") + .withIndex("by_approvalId", (q) => q.eq("approvalId", approvalId)) + .first(); + return message ? publicMessage(message) : null; + }, +}); + +/** + * Update the approval status of a message (for idempotency tracking). + * Called after approveToolCall or denyToolCall to mark the approval as handled. + */ +export const updateApprovalStatus = mutation({ + args: { + approvalId: v.string(), + status: v.union(v.literal("approved"), v.literal("denied")), + }, + returns: v.null(), + handler: async (ctx, { approvalId, status }) => { + const message = await ctx.db + .query("messages") + .withIndex("by_approvalId", (q) => q.eq("approvalId", approvalId)) + .first(); + + if (message) { + await ctx.db.patch(message._id, { approvalStatus: status }); + } + return null; + }, +}); diff --git a/src/component/schema.ts b/src/component/schema.ts index 644cc62e..0c1be7c2 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -64,6 +64,19 @@ export const schema = defineSchema({ parentMessageId: v.optional(v.id("messages")), stepId: v.optional(v.string()), files: v.optional(v.array(v.any())), + + // Tool approval fields (extracted from message content for O(1) indexed lookup) + approvalId: v.optional(v.string()), + approvalToolCallId: v.optional(v.string()), + approvalToolName: v.optional(v.string()), + approvalToolInput: v.optional(v.any()), + approvalStatus: v.optional( + v.union( + v.literal("pending"), + v.literal("approved"), + v.literal("denied"), + ), + ), }) // Allows finding successful visible messages in order // Also surface pending messages separately to e.g. stream @@ -81,7 +94,11 @@ export const schema = defineSchema({ filterFields: ["userId", "threadId"], }) // Allows finding messages by vector embedding id - .index("embeddingId_threadId", ["embeddingId", "threadId"]), + .index("embeddingId_threadId", ["embeddingId", "threadId"]) + // Allows O(1) lookup of approval requests by approvalId + .index("by_approvalId", ["approvalId"]) + // Allows querying pending approvals for a thread + .index("by_threadId_approvalStatus", ["threadId", "approvalStatus"]), // Status: if it's done, it's deleted, then deltas are vacuumed streamingMessages: defineTable({ diff --git a/src/deltas.ts b/src/deltas.ts index 87a8f9f7..fc33eab6 100644 --- a/src/deltas.ts +++ b/src/deltas.ts @@ -25,7 +25,8 @@ export function blankUIMessage( ): UIMessage { return { id: `stream:${streamMessage.streamId}`, - key: `${threadId}-${streamMessage.order}-${streamMessage.stepOrder}`, + // Key uses only order (not stepOrder) to prevent React remounting when combined with saved messages + key: `${threadId}-${streamMessage.order}`, order: streamMessage.order, stepOrder: streamMessage.stepOrder, status: statusFromStreamStatus(streamMessage.status), @@ -124,7 +125,6 @@ export async function deriveUIMessagesFromDeltas( blankUIMessage(streamMessage, threadId), parts, ); - // TODO: this fails on partial tool calls messages.push(uiMessage); } else { const [uiMessages] = deriveUIMessagesFromTextStreamParts( diff --git a/src/fromUIMessages.test.ts b/src/fromUIMessages.test.ts index 4413c3c1..db507b38 100644 --- a/src/fromUIMessages.test.ts +++ b/src/fromUIMessages.test.ts @@ -226,8 +226,8 @@ describe("fromUIMessages round-trip tests", () => { // Check that tool information is preserved const toolMessages = backToMessageDocs.filter((msg) => msg.tool); expect(toolMessages.length).toBeGreaterThan(0); - expect(toolMessages[0].stepOrder).toBe(1); - expect(toolMessages[1].stepOrder).toBe(2); + // stepOrders should be consecutive (the base is now the last message's stepOrder) + expect(toolMessages[1].stepOrder).toBe(toolMessages[0].stepOrder + 1); } }); diff --git a/src/mapping.ts b/src/mapping.ts index de22c780..8a617a4d 100644 --- a/src/mapping.ts +++ b/src/mapping.ts @@ -46,8 +46,6 @@ import { convertUint8ArrayToBase64, type ProviderOptions, type ReasoningPart, - type ToolApprovalRequest, - type ToolApprovalResponse, } from "@ai-sdk/provider-utils"; import { parse, validate } from "convex-helpers/validators"; import { @@ -585,21 +583,12 @@ export function toModelMessageContent( case "source": return part satisfies SourcePart; case "tool-approval-request": - return { - type: part.type, - approvalId: part.approvalId, - toolCallId: part.toolCallId, - ...metadata, - } satisfies ToolApprovalRequest; case "tool-approval-response": - return { - type: part.type, - approvalId: part.approvalId, - approved: part.approved, - reason: part.reason, - providerExecuted: part.providerExecuted, - ...metadata, - } satisfies ToolApprovalResponse; + // Filter out approval parts - providers like Anthropic don't understand these + // and will error if they are included in messages sent to the API. + // The approval data is preserved in storage and extracted for UI rendering + // directly from message.message.content before toModelMessage is called. + return null; default: return null; } @@ -632,13 +621,24 @@ function normalizeToolResult( providerMetadata?: ProviderMetadata; }, ): ToolResultPart & Infer { + let output = part.output + ? normalizeToolOutput(part.output as any) + : normalizeToolOutput("result" in part ? part.result : undefined); + + // Convert execution-denied to text format for provider compatibility + // Anthropic and other providers don't understand the execution-denied type + if (output?.type === "execution-denied") { + output = { + type: "text", + value: + (output as { reason?: string }).reason ?? + "Tool execution was denied by the user", + }; + } + return { type: part.type, - output: part.output - ? validate(vToolResultOutput, part.output) - ? (part.output as any) - : normalizeToolOutput(JSON.stringify(part.output)) - : normalizeToolOutput("result" in part ? part.result : undefined), + output, toolCallId: part.toolCallId, toolName: part.toolName, // Preserve isError flag for error reporting diff --git a/src/react/useUIMessages.ts b/src/react/useUIMessages.ts index bba3df3d..92d860df 100644 --- a/src/react/useUIMessages.ts +++ b/src/react/useUIMessages.ts @@ -155,17 +155,33 @@ export function useUIMessages>( ); const merged = useMemo(() => { - // Messages may have been split by pagination. Re-combine them here. - const combined = combineUIMessages(sorted(paginated.results)); + // Combine saved messages with streaming messages, then combine by order + // This ensures streaming continuations appear in the same bubble as saved content + const allMessages = dedupeMessages(paginated.results, streamMessages ?? []); + const combined = combineUIMessages(sorted(allMessages)); + return { ...paginated, - results: dedupeMessages(combined, streamMessages ?? []), + results: combined, }; }, [paginated, streamMessages]); return merged as UIMessagesQueryResult; } +/** + * Reconciles saved messages (from DB) with streaming messages (real-time deltas). + * + * This is complex because they're independent data sources that can have overlapping + * stepOrders with different content. For example, after tool approval: + * - Saved message has tool call + result parts + * - Streaming message starts empty and builds up continuation text + * - Both may have the same stepOrder + * + * We merge rather than pick one to preserve both the tool context and streaming content. + * A cleaner architecture would have streaming carry forward prior context, eliminating + * the need for client-side reconciliation. + */ export function dedupeMessages< M extends { order: number; @@ -173,7 +189,36 @@ export function dedupeMessages< status: UIStatus; }, >(messages: M[], streamMessages: M[]): M[] { - return sorted(messages.concat(streamMessages)).reduce((msgs, msg) => { + // Filter out stale streaming messages - those with stepOrder lower than + // the max saved message at the same order (they're from a previous generation) + const maxStepOrderByOrder = new Map(); + for (const msg of messages) { + const current = maxStepOrderByOrder.get(msg.order) ?? -1; + if (msg.stepOrder > current) { + maxStepOrderByOrder.set(msg.order, msg.stepOrder); + } + } + + const filteredStreamMessages = streamMessages.filter((s) => { + const maxSaved = maxStepOrderByOrder.get(s.order); + // Keep streaming message if: + // 1. No saved at that order, OR + // 2. stepOrder >= max saved stepOrder, OR + // 3. There's a saved message at the SAME stepOrder (let dedup logic handle it) + const hasSavedAtSameStepOrder = messages.some( + (m) => m.order === s.order && m.stepOrder === s.stepOrder, + ); + return ( + maxSaved === undefined || + s.stepOrder >= maxSaved || + hasSavedAtSameStepOrder + ); + }); + + // Merge saved and streaming messages, deduplicating by (order, stepOrder) + // When saved (with parts) and streaming (building up) have the same stepOrder, + // we need to keep the saved parts while showing streaming status. + return sorted(messages.concat(filteredStreamMessages)).reduce((msgs, msg) => { const last = msgs.at(-1); if (!last) { return [msg]; @@ -181,15 +226,76 @@ export function dedupeMessages< if (last.order !== msg.order || last.stepOrder !== msg.stepOrder) { return [...msgs, msg]; } - if ( - (last.status === "pending" || last.status === "streaming") && - msg.status !== "pending" - ) { - // Let's prefer a streaming or finalized message over a pending - // one. + // Same (order, stepOrder) - merge them rather than choosing one + // This preserves saved parts while allowing streaming status to show + const lastIsFinalized = + last.status === "success" || last.status === "failed"; + const msgIsFinalized = msg.status === "success" || msg.status === "failed"; + + // If either is finalized, use the finalized one + if (lastIsFinalized && !msgIsFinalized) { + return msgs; + } + if (msgIsFinalized && !lastIsFinalized) { return [...msgs.slice(0, -1), msg]; } - // skip the new one if the previous one (listed) was finalized - return msgs; + if (lastIsFinalized && msgIsFinalized) { + return msgs; // Both finalized, keep first + } + + // Neither finalized - merge parts from both, prefer streaming message identity + const lastParts = "parts" in last ? ((last as any).parts ?? []) : []; + const msgParts = "parts" in msg ? ((msg as any).parts ?? []) : []; + const hasParts = lastParts.length > 0 || msgParts.length > 0; + + // If no parts on either, just pick the streaming one (or msg if it's streaming) + if (!hasParts) { + if (msg.status === "streaming") { + return [...msgs.slice(0, -1), msg]; + } + if (last.status === "streaming") { + return msgs; + } + return [...msgs.slice(0, -1), msg]; + } + + // Combine parts, avoiding duplicates by toolCallId + const mergedParts = [...lastParts]; + for (const part of msgParts) { + const toolCallId = (part as any).toolCallId; + if (toolCallId) { + const existingIdx = mergedParts.findIndex( + (p: any) => p.toolCallId === toolCallId, + ); + if (existingIdx >= 0) { + // Merge tool part - prefer the one with more complete state + const existing = mergedParts[existingIdx] as any; + if ( + part.state === "output-available" || + part.state === "output-error" || + (part.state && !existing.state) + ) { + mergedParts[existingIdx] = part; + } + continue; + } + } + // Add non-duplicate parts (skip duplicate step-starts) + const isDuplicateStepStart = + (part as any).type === "step-start" && + mergedParts.some((p: any) => p.type === "step-start"); + if (!isDuplicateStepStart) { + mergedParts.push(part); + } + } + + // Use streaming message as base if it's streaming, otherwise use the one with more parts + const base = msg.status === "streaming" ? msg : last; + const merged = { + ...base, + status: msg.status === "streaming" ? "streaming" : last.status, + parts: mergedParts, + } as M; + return [...msgs.slice(0, -1), merged]; }, [] as M[]); } diff --git a/src/toUIMessages.test.ts b/src/toUIMessages.test.ts index 8c5dfca1..3d54e360 100644 --- a/src/toUIMessages.test.ts +++ b/src/toUIMessages.test.ts @@ -716,10 +716,10 @@ describe("toUIMessages", () => { // Should concatenate text from all messages (only the final response has text) expect(uiMessages[0].text).toBe("The result is 42."); - // Should use first message's fields (msg1 with stepOrder 1) + // Should use first message's ID, but last message's stepOrder for deduplication expect(uiMessages[0].id).toBe("msg1"); expect(uiMessages[0].order).toBe(1); - expect(uiMessages[0].stepOrder).toBe(1); + expect(uiMessages[0].stepOrder).toBe(3); // Should have both tool call and result parts const toolParts = uiMessages[0].parts.filter( diff --git a/src/validators.ts b/src/validators.ts index 51a08e41..56d3bad2 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -430,12 +430,27 @@ export const vFinishReason = v.union( v.literal("unknown"), ); +// AI SDK v6 detailed token breakdown fields +export const vInputTokenDetails = v.object({ + noCacheTokens: v.optional(v.number()), + cacheReadTokens: v.optional(v.number()), + cacheWriteTokens: v.optional(v.number()), +}); + +export const vOutputTokenDetails = v.object({ + textTokens: v.optional(v.number()), + reasoningTokens: v.optional(v.number()), +}); + export const vUsage = v.object({ promptTokens: v.number(), completionTokens: v.number(), totalTokens: v.number(), reasoningTokens: v.optional(v.number()), cachedInputTokens: v.optional(v.number()), + // AI SDK v6 detailed token breakdown + inputTokenDetails: v.optional(vInputTokenDetails), + outputTokenDetails: v.optional(vOutputTokenDetails), }); export type Usage = Infer; @@ -668,6 +683,19 @@ export const vMessageDoc = v.object({ reasoningDetails: v.optional(vReasoningDetails), // Deprecated id: v.optional(v.string()), // external id, e.g. from Vercel AI SDK + + // Tool approval fields (extracted from message content for O(1) indexed lookup) + approvalId: v.optional(v.string()), + approvalToolCallId: v.optional(v.string()), + approvalToolName: v.optional(v.string()), + approvalToolInput: v.optional(v.any()), + approvalStatus: v.optional( + v.union( + v.literal("pending"), + v.literal("approved"), + v.literal("denied"), + ), + ), }); export type MessageDoc = Infer; // Public