Conversation
…nents.json reference
## Problem
When streaming multiple tool calls, new tool calls were corrupting existing ones. The bug was in `combineUIMessages`:
```typescript
// BUGGY CODE
const previousPartIndex = newParts.findIndex(
(p) => getToolCallId(p) === toolCallId,
);
const previousPart = newParts.splice(previousPartIndex, 1)[0];
if (!previousPart) {
newParts.push(part);
continue;
}
```
When `findIndex` returns `-1` (tool call ID not found in previous parts):
- `splice(-1, 1)` removes the **last element** of the array (JavaScript quirk!)
- `previousPart` is that removed element (truthy, not undefined)
- The new part incorrectly merges with the last element instead of being added
- This corrupts the parts array, causing tool calls to disappear
## Solution
Check for `-1` explicitly before calling splice:
```typescript
// FIXED CODE
if (previousPartIndex === -1) {
// Tool call not found, add as new
newParts.push(part);
continue;
}
const previousPart = newParts.splice(previousPartIndex, 1)[0];
newParts.push(mergeParts(previousPart, part));
```
Fixes get-convex#182
## Test plan
- [x] Test: preserves all tool calls when combining messages
- [x] Test: accumulates tool calls progressively (A → A,B → A,B,C)
- [x] Test: merges tool calls with same toolCallId correctly
🤖 Generated with [Claude Code](https://claude.ai/code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
* **Bug Fixes**
* Fixed an issue in message combination that could unintentionally remove elements when specific tool calls were not found.
* **Tests**
* Added comprehensive test suite validating preservation and progressive accumulation of tool calls, state transitions, and deduplication during message combination.
<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Update Tailwind configuration to use CommonJS format
…ssage-188 Add userId field to UIMessage type
- Upgrade @convex-dev/rag from 0.6.1 to 0.7.0 RAG 0.7.0 uses ai@^6.0.0 (compatible with v6), while 0.6.x used AI SDK v5 types (EmbeddingModelV2) causing type conflicts - Fix unused type params in Output interface (src/client/types.ts) Prefix with _ to indicate intentionally unused for type signature compatibility - Remove unused vContent import (src/mapping.ts) Co-Authored-By: Claude <noreply@anthropic.com>
- Add MIGRATION.md with step-by-step upgrade instructions - Add CLAUDE.md for AI agent guidance - Add runtime deprecation warnings that fire once per session: - textEmbeddingModel → embeddingModel - createTool args → inputSchema - createTool handler → execute - Warnings link to migration guide on GitHub Co-Authored-By: Claude <noreply@anthropic.com>
- Fix race condition in useThreadMessages.ts using generation counter
- Replace {} as any with properly typed token detail objects
- Convert bare URL to markdown link in CLAUDE.md
- Add warning comment about textEmbeddingModel potentially being undefined
- Document why exhaustiveness check is disabled in deltas.ts (forwards compat)
Co-Authored-By: Claude <noreply@anthropic.com>
Fixes the issue where continuation text after tool approval/denial was appearing in two places - both merged with the original message and as a separate streaming message. Key fixes: 1. Add overrideOrder parameter to addMessages to force a specific order for continuation messages when forceNewOrder is true 2. Update start.ts to pass overrideOrder when forceNewOrder is set 3. Add error handling in streamText.ts to abort streaming on error 4. Update usage handler to accept AI SDK v6 token detail fields 5. Add tool approval example with approval.ts agent and UI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
These methods encapsulate the complexity of the AI SDK v6 tool approval workflow, reducing boilerplate in application code. Before: ~340 lines of manual tool finding, execution, and message handling After: ~140 lines using the clean helper API The helpers handle: - Finding the tool call info from the approval ID - Executing the tool (for approval) with proper context injection - Saving tool-approval-response and tool-result messages - Continuing generation with forceNewOrder for clean message separation This is a workaround for AI SDK v6 issue #10980 where the native approval flow doesn't generate proper tool_result blocks for Anthropic. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When pagination cuts off a tool-result from its tool-call, we handle it gracefully by creating a standalone tool part. The warning was noisy for this expected pagination behavior. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- inputTokenDetails: noCacheTokens, cacheReadTokens, cacheWriteTokens - outputTokenDetails: textTokens, reasoningTokens - raw: kept as v.any() since it's provider-specific Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add tool approval flow to Key Patterns architecture section - Replace upgrade section with detailed AI-friendly guidance including: - Detection patterns for v5 code (parameters, handler, textEmbeddingModel) - Dependency update commands - Before/after transformation examples for tools, embeddings, step limits - Verification steps and common issues - New v6 features (tool approval, reasoning streaming, token details) - Remove outdated TODO comment in deltas.ts (partial tool calls now handled) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Enhance CLAUDE.md with comprehensive AI upgrade guidance
When users upgrade to v0.6.0 with old AI SDK v5 dependencies, TypeScript now shows helpful error messages pointing to the migration guide: - languageModel: Shows error if model has specificationVersion "v2" instead of "v3" - createTool args: Shows "⚠️ 'args' was removed... Rename to 'inputSchema'" - createTool handler: Shows "⚠️ 'handler' was removed... Rename to 'execute'" This helps users (and AI assistants) understand what needs to change before they try to run the code. Also adds scripts/check-upgrade.js CLI tool for scanning codebases. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add idempotency guard to prevent duplicate tool execution on retry (_findToolCallInfo now returns null if approval already handled) - Propagate thread userId to tool context for proper auth/scoping - Remove unnecessary JSON.stringify that corrupted tool outputs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add MIGRATION.md to package.json files array so it ships with npm package - Update compile-time error messages to point to local MIGRATION.md - Enhance CLAUDE.md with: - AI SDK v6 requirement note upfront - Compatible sibling packages (@convex-dev/rag@^0.7.0) - Type import changes (LanguageModelV2 → V3, EmbeddingModel → EmbeddingModelV3) - generateObject mode: "json" removal documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update createTool calls in examples: args -> inputSchema, handler -> execute - Fix lint errors in check-upgrade.js (Node.js globals, unused catch var) - Remove unused ToolApprovalRequest/Response imports from mapping.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tests cover: - _findToolCallInfo: finding tool calls, idempotency guard, missing approvals - UIMessage approval states: pending, approved, denied - Message grouping with mixed approval/non-approval tools - Tool result handling when tool call is on previous page - execution-denied output conversion for provider compatibility - createTool with needsApproval function and boolean Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tests that document actual bugs and edge cases: BUGS FOUND: 1. Pagination limit: Approvals older than 20 messages won't be found (_findToolCallInfo uses hardcoded numItems: 20) 2. Duplicate toolCallId: With duplicate IDs (shouldn't happen but possible), code finds first match in iteration order, not the one with the approval 3. Cross-agent tool lookup: approveToolCall fails if called on an agent that doesn't have the tool registered, even if approval is valid 4. Tool errors swallowed: Execution errors become string results instead of failing the approval workflow properly 5. Race condition (TOCTOU): Concurrent approvals could both pass the idempotency check due to non-atomic check-then-act EDGE CASES HANDLED CORRECTLY: - Tool call and approval request in different messages - Missing approvalId in request - Undefined 'approved' field in response - String content (non-array) messages - Tool input with only 'args' (no 'input') - Tool input with neither 'args' nor 'input' Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The approval workflow had a race condition where concurrent approvals
could both pass the idempotency check (in a query) and then both save
duplicate approval responses (in separate mutations).
Fix: Add approvalIdempotencyKey parameter to addMessages mutation.
When provided, the mutation atomically checks if an approval response
with that ID already exists before saving. If it does, returns the
existing message instead of creating a duplicate.
Changes:
- Add approvalIdempotencyKey to SaveMessagesArgs and addMessages mutation
- Update _findToolCallInfo to return { alreadyHandled, existingMessageId }
instead of null for already-processed approvals
- Update approveToolCall/denyToolCall to:
1. Handle alreadyHandled case by continuing from existing message
2. Pass approvalIdempotencyKey to saveMessage for atomic check
- Update tests to reflect new idempotent behavior
The check-and-write is now atomic within a single Convex mutation,
eliminating the race condition.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add third-party provider compatibility info to MIGRATION.md - Add OpenRouter provider upgrade instructions (v1.x → v2.x) - Add guidance for handling peer dependency conflicts - Add compatibility table for AI SDK v5 vs v6 providers - Enhance check-upgrade.js to detect outdated package versions - Script now checks package.json for incompatible provider versions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Remove the complex approvalIdempotencyKey machinery. Instead, rely on _findToolCallInfo detecting already-handled approvals and throw an error. Convex's atomicity guarantees within the mutation are sufficient. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Included AI SDK function `createToolModelOutput` in `shared.ts` to handle the generation of tool model outputs for approval responses, including error handling based on the specified error mode. - Updated `approveToolCall` in `index.ts` to utilize the new helper function for creating structured tool results.
- Removed the NeverOptional utility type and directly defined the properties for ToolOutputPropertiesCtx. - Updated the comments to clarify the removal of deprecated parameters `args` and `handler`, emphasizing the use of `inputSchema` and `execute` instead. - This change simplifies the type structure and avoids potential type issues extending `createTool`.
commit: |
There was a problem hiding this comment.
Pull request overview
This pull request implements AI SDK v6 tool approval workflow support for @convex-dev/agent, along with dependency updates and various improvements. The PR upgrades the package to require AI SDK v6 (ai@^6.0.0) and adds comprehensive support for tools that need user approval before execution.
Changes:
- Adds tool approval workflow with
Agent.approveToolCall()andAgent.denyToolCall()methods - Implements
forceNewOrdermechanism to create separate messages for approval continuations - Upgrades dependencies to AI SDK v6 (ai@^6.0.35, @ai-sdk/provider-utils@^4.0.6, provider packages v3.x)
- Adds deprecation warnings for v5 API patterns (
args→inputSchema,handler→execute,textEmbeddingModel→embeddingModel) - Improves error handling in streaming with try-catch wrapper
- Refactors React hook to use generation ref pattern instead of isMounted flag
- Adds comprehensive test coverage for approval workflows including edge cases
- Adds ChatApproval example demonstrating the approval UI
- Adds MIGRATION.md and CLAUDE.md documentation files
Reviewed changes
Copilot reviewed 39 out of 42 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| src/client/index.ts | Adds approveToolCall/denyToolCall methods with _findToolCallInfo helper, AI SDK v6 type checks, deprecation warnings |
| src/client/start.ts | Implements forceNewOrder flag to increment message order for approval continuations |
| src/client/streamText.ts | Adds try-catch wrapper to handle streaming errors and abort streams properly |
| src/client/createTool.ts | Updates type definitions to show deprecation errors for args/handler, adds runtime warnings |
| src/client/types.ts | Adds AssertAISDKv6 type check, unused generic prefix convention, forceNewOrder option |
| src/shared.ts | Adds createToolModelOutput helper for tool approval responses |
| src/mapping.ts | Filters out approval parts for provider compatibility, converts execution-denied to text format, improves token detail fields |
| src/UIMessages.ts | Pre-extracts approval parts before toModelMessage filtering, manages approval state with finalStates protection |
| src/react/useThreadMessages.ts | Replaces isMounted flag with generationRef for race condition handling |
| src/component/messages.ts | Adds overrideOrder parameter for forceNewOrder support |
| example/convex/chat/approval.ts | New file demonstrating tool approval workflow |
| example/ui/chat/ChatApproval.tsx | New React UI for tool approval with approve/deny buttons |
| MIGRATION.md | New comprehensive migration guide from v0.3.x to v0.6.0 |
| CLAUDE.md | New AI assistant guidance document |
| docs/* | Typo fixes ("an pass" → "and pass", "the" → "then") |
| package.json | Updates dependencies: @convex-dev/rag@0.7.0, convex@^1.31.6, adds MIGRATION.md to files |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const messagesResult = await this.listMessages(ctx, { | ||
| threadId, | ||
| paginationOpts: { numItems: 20, cursor: null }, | ||
| }); |
There was a problem hiding this comment.
The _findToolCallInfo method only retrieves 20 messages from the thread and searches within them. This creates a limitation where approval requests older than 20 messages cannot be found. This is documented in the test file approval-bugs.test.ts lines 38-89, but it's a significant operational issue.
Consider either:
- Increasing the pagination limit (though this has performance implications)
- Fetching additional pages if not found in the first 20
- Adding an index on approval IDs for direct lookup
- Documenting this limitation prominently in the API docs for
approveToolCallanddenyToolCall
| private async _findToolCallInfo( | ||
| ctx: ActionCtx, | ||
| threadId: string, | ||
| approvalId: string, | ||
| ): Promise< | ||
| | { | ||
| toolCallId: string; | ||
| toolName: string; | ||
| toolInput: Record<string, unknown>; | ||
| parentMessageId: string; | ||
| alreadyHandled?: false; | ||
| } | ||
| | { alreadyHandled: true; wasApproved: boolean } | ||
| | null | ||
| > { | ||
| const messagesResult = await this.listMessages(ctx, { | ||
| threadId, | ||
| paginationOpts: { numItems: 20, cursor: null }, | ||
| }); | ||
|
|
||
| let toolCallId: string | undefined; | ||
| let parentMessageId: string | undefined; | ||
| let toolName: string | undefined; | ||
| let toolInput: Record<string, unknown> | undefined; | ||
|
|
||
| // First, check if this approval has already been handled | ||
| for (const msg of messagesResult.page) { | ||
| if (msg.message?.role === "tool" && Array.isArray(msg.message.content)) { | ||
| for (const part of msg.message.content) { | ||
| if ( | ||
| part.type === "tool-approval-response" && | ||
| (part as any).approvalId === approvalId | ||
| ) { | ||
| return { alreadyHandled: true, wasApproved: (part as any).approved === true }; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Second pass: find the approval request to get toolCallId and parent message | ||
| for (const msg of messagesResult.page) { | ||
| if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) { | ||
| for (const part of msg.message.content) { | ||
| if ( | ||
| part.type === "tool-approval-request" && | ||
| (part as any).approvalId === approvalId | ||
| ) { | ||
| parentMessageId = msg._id; | ||
| toolCallId = (part as any).toolCallId; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (toolCallId) break; | ||
| } | ||
|
|
||
| if (!toolCallId || !parentMessageId) { | ||
| return null; | ||
| } | ||
|
|
||
| // Third pass: find the tool-call with matching toolCallId to get toolName and input | ||
| for (const msg of messagesResult.page) { | ||
| if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) { | ||
| for (const part of msg.message.content) { | ||
| if ( | ||
| part.type === "tool-call" && | ||
| (part as any).toolCallId === toolCallId | ||
| ) { | ||
| toolName = (part as any).toolName; | ||
| toolInput = (part as any).input ?? (part as any).args ?? {}; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (toolName) break; | ||
| } | ||
|
|
||
| if (!toolName || !toolInput) { | ||
| return null; | ||
| } | ||
|
|
||
| return { toolCallId, toolName, toolInput, parentMessageId }; | ||
| } |
There was a problem hiding this comment.
There is a race condition in the approval workflow as documented in approval-bugs.test.ts lines 581-607. The approveToolCall and denyToolCall methods use separate transactions: one to check if an approval exists via _findToolCallInfo (which calls listMessages), and another to save the approval response via saveMessage. This creates a Time-Of-Check to Time-Of-Use (TOCTOU) vulnerability where two concurrent approval calls could both pass the check and both save responses.
This should be fixed by either:
- Moving the check-and-write into a single mutation
- Using optimistic concurrency control (e.g., a unique index on approvalId)
- At minimum, documenting this limitation prominently
| // First, check if this approval has already been handled | ||
| for (const msg of messagesResult.page) { | ||
| if (msg.message?.role === "tool" && Array.isArray(msg.message.content)) { | ||
| for (const part of msg.message.content) { | ||
| if ( | ||
| part.type === "tool-approval-response" && | ||
| (part as any).approvalId === approvalId | ||
| ) { | ||
| return { alreadyHandled: true, wasApproved: (part as any).approved === true }; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Second pass: find the approval request to get toolCallId and parent message | ||
| for (const msg of messagesResult.page) { | ||
| if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) { | ||
| for (const part of msg.message.content) { | ||
| if ( | ||
| part.type === "tool-approval-request" && | ||
| (part as any).approvalId === approvalId | ||
| ) { | ||
| parentMessageId = msg._id; | ||
| toolCallId = (part as any).toolCallId; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (toolCallId) break; | ||
| } | ||
|
|
||
| if (!toolCallId || !parentMessageId) { | ||
| return null; | ||
| } | ||
|
|
||
| // Third pass: find the tool-call with matching toolCallId to get toolName and input | ||
| for (const msg of messagesResult.page) { | ||
| if (msg.message?.role === "assistant" && Array.isArray(msg.message.content)) { | ||
| for (const part of msg.message.content) { | ||
| if ( | ||
| part.type === "tool-call" && | ||
| (part as any).toolCallId === toolCallId | ||
| ) { | ||
| toolName = (part as any).toolName; | ||
| toolInput = (part as any).input ?? (part as any).args ?? {}; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (toolName) break; | ||
| } | ||
|
|
||
| if (!toolName || !toolInput) { | ||
| return null; | ||
| } | ||
|
|
||
| return { toolCallId, toolName, toolInput, parentMessageId }; | ||
| } |
There was a problem hiding this comment.
The _findToolCallInfo method performs three sequential passes through the message list, which is inefficient. All three pieces of information (approval response, approval request, and tool call) could be extracted in a single pass.
Consider refactoring to:
for (const msg of messagesResult.page) {
if (!Array.isArray(msg.message?.content)) continue;
for (const part of msg.message.content) {
// Check all three types in one pass
if (part.type === "tool-approval-response" && ...) { ... }
if (part.type === "tool-approval-request" && ...) { ... }
if (part.type === "tool-call" && ...) { ... }
}
}| ) { | ||
| parentMessageId = msg._id; | ||
| toolCallId = (part as any).toolCallId; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| if (toolCallId) break; | ||
| } |
There was a problem hiding this comment.
When a tool-call and tool-approval-request are in different messages (as tested in approval-bugs.test.ts lines 142-195), the parentMessageId will point to the approval request message rather than the tool-call message. This could cause the approval response to be saved with an incorrect parent relationship, potentially breaking message threading or order logic.
Consider validating that the approval request and tool-call are in the same message, or explicitly handling this edge case.
| if (useForceNewOrder) { | ||
| // TypeScript can't infer order is defined here from the compound condition above | ||
| order = order! + 1; | ||
| stepOrder = 0; | ||
| } |
There was a problem hiding this comment.
The comment says "TypeScript can't infer order is defined here from the compound condition above" but the non-null assertion order! on line 169 could still fail at runtime if order is undefined. While useForceNewOrder checks order !== undefined, TypeScript correctly doesn't narrow the type because order could be reassigned between lines 164-166.
Consider using a type assertion or restructuring the logic:
if (useForceNewOrder) {
if (order === undefined) {
throw new Error("Cannot use forceNewOrder without an existing order");
}
order = order + 1;
stepOrder = 0;
}|
|
||
| const schema = defineSchema({}); | ||
| type DataModel = DataModelFromSchemaDefinition<typeof schema>; | ||
| const action = actionGeneric as ActionBuilder<DataModel, "public">; |
There was a problem hiding this comment.
Unused variable action.
| const action = actionGeneric as ActionBuilder<DataModel, "public">; |
| // 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), | ||
| }); | ||
|
|
There was a problem hiding this comment.
Unused variable noApprovalAgent.
| // 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), | |
| }); |
| }); | ||
|
|
||
| expect(firstMsg?.order).toBeDefined(); | ||
| const initialOrder = firstMsg!.order; |
There was a problem hiding this comment.
Unused variable initialOrder.
| const initialOrder = firstMsg!.order; |
| ) { | ||
| await stream; | ||
| await result.consumeStream(); | ||
| streamConsumed = true; |
There was a problem hiding this comment.
The value assigned to streamConsumed here is unused.
| } 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 && !streamConsumed) { |
There was a problem hiding this comment.
This negation always evaluates to true.
…e raw output directly to `createToolModelOutput`, which handles the conversion properly.
No description provided.