+ );
+ }
+
+ // 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