mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-30 14:01:44 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0525e6dacb | ||
|
|
cc2885f6a9 | ||
|
|
1be5ca57ee | ||
|
|
7de5e4bcca | ||
|
|
85a45b2895 | ||
|
|
d8af943a1e | ||
|
|
7a701623df | ||
|
|
e692462249 | ||
|
|
bd0ae4dde1 | ||
|
|
7ff6b7fd6b | ||
|
|
12bd2762d9 | ||
|
|
0dbccf588f | ||
|
|
9854b0c679 | ||
|
|
d877ee7133 | ||
|
|
18516a3088 | ||
|
|
ec14211056 | ||
|
|
398f763f71 | ||
|
|
23cbe6f58f | ||
|
|
7ffb78c572 | ||
|
|
f4704c168f |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.41.0"
|
||||
".": "0.45.0"
|
||||
}
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## [0.45.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.44.0...v0.45.0) (2026-06-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** bump the minor group with 3 updates ([#763](https://github.com/agentclientprotocol/claude-agent-acp/issues/763)) ([7de5e4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/7de5e4bcca9bfea70593092060f82bc8abe33e0e))
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.177 ([#771](https://github.com/agentclientprotocol/claude-agent-acp/issues/771)) ([1be5ca5](https://github.com/agentclientprotocol/claude-agent-acp/commit/1be5ca57ee772fe90e41126365dc4186a18ad257))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preserve ANTHROPIC_CUSTOM_MODEL_OPTION when availableModels is set ([#768](https://github.com/agentclientprotocol/claude-agent-acp/issues/768)) ([cc2885f](https://github.com/agentclientprotocol/claude-agent-acp/commit/cc2885f6a9993cf61e759c3c770015f94c218627))
|
||||
|
||||
## [0.44.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.43.0...v0.44.0) (2026-06-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump the minor group with 14 updates ([#758](https://github.com/agentclientprotocol/claude-agent-acp/issues/758)) ([7a70162](https://github.com/agentclientprotocol/claude-agent-acp/commit/7a701623df2e05aec8e552bdfd1cf573413c3471))
|
||||
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.3.170 ([#761](https://github.com/agentclientprotocol/claude-agent-acp/issues/761)) ([d8af943](https://github.com/agentclientprotocol/claude-agent-acp/commit/d8af943a1efef9be27d771cec089d2f1cb56c523))
|
||||
|
||||
## [0.43.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.42.0...v0.43.0) (2026-06-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add experimental elicitation support ([#756](https://github.com/agentclientprotocol/claude-agent-acp/issues/756)) ([12bd276](https://github.com/agentclientprotocol/claude-agent-acp/commit/12bd2762d9ba0ccb7497a3848a5928b8bb2ce820))
|
||||
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.3.169 ([#754](https://github.com/agentclientprotocol/claude-agent-acp/issues/754)) ([bd0ae4d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bd0ae4dde160f59559ab4ac1bf703fcf151eff17))
|
||||
* Update ACP SDK to 0.25.0 ([#753](https://github.com/agentclientprotocol/claude-agent-acp/issues/753)) ([0dbccf5](https://github.com/agentclientprotocol/claude-agent-acp/commit/0dbccf588ff702015494dd29969d47f0b7402feb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Forward unstreamed assistant text blocks ([#757](https://github.com/agentclientprotocol/claude-agent-acp/issues/757)) ([7ff6b7f](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ff6b7fd6b157aedbce0c636087f51ae7d1df3a7))
|
||||
* Validate cwd before creating sessions ([#751](https://github.com/agentclientprotocol/claude-agent-acp/issues/751)) ([9854b0c](https://github.com/agentclientprotocol/claude-agent-acp/commit/9854b0c6790ebafff514e3c4c8e03a131f065a4a)), closes [#749](https://github.com/agentclientprotocol/claude-agent-acp/issues/749)
|
||||
|
||||
## [0.42.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.41.0...v0.42.0) (2026-06-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** bump obug from 2.1.1 to 2.1.2 in the minor group ([#743](https://github.com/agentclientprotocol/claude-agent-acp/issues/743)) ([7ffb78c](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ffb78c57280fc32af77862896c0035ce0e690b7))
|
||||
* Send message ids for assistant and user messages ([#750](https://github.com/agentclientprotocol/claude-agent-acp/issues/750)) ([18516a3](https://github.com/agentclientprotocol/claude-agent-acp/commit/18516a3088f847a38f5eee0fe81ad5ca3e2a751c))
|
||||
* Update Claude Agent SDK to 0.3.165 ([#746](https://github.com/agentclientprotocol/claude-agent-acp/issues/746)) ([23cbe6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/23cbe6f58fa9e10ad736f664c4cf94dbd1c6ad65))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Prevent cross-family model matching in resolveModelPreference ([#731](https://github.com/agentclientprotocol/claude-agent-acp/issues/731)) ([f4704c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/f4704c168f917e6710f2b3cd49e1fef49613a469))
|
||||
* Prune tool cache per session after results ([#748](https://github.com/agentclientprotocol/claude-agent-acp/issues/748)) ([ec14211](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec142110561f680330c6976783805685c5c069ec))
|
||||
* Update to better utilize existing SDK data ([#747](https://github.com/agentclientprotocol/claude-agent-acp/issues/747)) ([398f763](https://github.com/agentclientprotocol/claude-agent-acp/commit/398f763f71bd2fe2ca9e8269de120d26a744b7a8))
|
||||
|
||||
## [0.41.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.40.0...v0.41.0) (2026-06-04)
|
||||
|
||||
|
||||
|
||||
400
package-lock.json
generated
400
package-lock.json
generated
@@ -1,62 +1,62 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.41.0",
|
||||
"version": "0.45.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.41.0",
|
||||
"version": "0.45.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.24.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.162",
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.177",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.100.1",
|
||||
"@anthropic-ai/sdk": "0.104.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"eslint": "10.4.1",
|
||||
"@types/node": "25.9.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"eslint": "10.5.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.6.0",
|
||||
"prettier": "3.8.3",
|
||||
"prettier": "3.8.4",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.8"
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.24.0.tgz",
|
||||
"integrity": "sha512-vvu9appvGvfYstBj19C6NCepV6SvUhY5VRv60KUZ4XzhTah/olOYul5Zo4C+x2enyshMSvgB2mm/OEmrsHaSmA==",
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.25.1.tgz",
|
||||
"integrity": "sha512-jx2rF3bdpGwZ75Q/meyEDLLbYmbtxk82Uh9hDCdxDvcEedBnNSF5hZAnL/kJR5VNz56JqwOmqnAqasC84MwwkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.162",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.162.tgz",
|
||||
"integrity": "sha512-piAlpc1h6FUMCNU+bnYNNLX1lOgMlFnqZmIhn6Myv48MaNRaecFaZEuVLY+UxV0kjGiQFYHBLR4fk/rRwi2SAA==",
|
||||
"version": "0.3.177",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.177.tgz",
|
||||
"integrity": "sha512-CBzXnzR661q3AlfZzBjmIFQx0cxr36iJV3PExTYmPyGQX32qxtiFQgnxTcF8wB4hcSVf2hnoy/gprVJdkNx7cw==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.162",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.162",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.162",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.162",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.162",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.162",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.162",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.162"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.177",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.177",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.177",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.177",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.177",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.177",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.177",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.177"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -65,9 +65,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.162",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.162.tgz",
|
||||
"integrity": "sha512-hafbfEtDeYko1rYCgIBAQbYnFXzd/hHf3IcoaD8mmlCQOAQhKA8gT/RPdLuuzQHdOjEgqDGTNOU+IjwL+msYcw==",
|
||||
"version": "0.3.177",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.177.tgz",
|
||||
"integrity": "sha512-u9Ty+KPllm2nw0RatdPF0zcPRquNZjVptmyLG0DqduGbgZDLQpfPFMF5hffFIRnVaXhx7+jkUmEdw0jrda0UcA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -78,9 +78,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.162",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.162.tgz",
|
||||
"integrity": "sha512-BNg2Mh/4zc2Jsgpq7Mj8+UH8iJ9xzHS/0CmusBMik0H2Bn7Hra5R/f+csAfZOzSflQl4CNQdGOB2bir7d2n8Ww==",
|
||||
"version": "0.3.177",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.177.tgz",
|
||||
"integrity": "sha512-ona6Jv54XFwBTqOj3MzLWfKtc2m7Rdh58wOAX9Hnue/6FcWfyeuz/UDcidVTXQ7Xytz//Tb0JJgFtiQjO7FbIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -91,9 +91,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.162",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.162.tgz",
|
||||
"integrity": "sha512-zCXYSimaXWQKZASfDJkoKXQr//toYDGIi16wKDh02Rqcr4mqFi9f5SBw/UCyimkGyYkNx3e+bmC+o/tFrLSTWw==",
|
||||
"version": "0.3.177",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.177.tgz",
|
||||
"integrity": "sha512-wBCbklkaDb483Ab4DUFfmJjZJKXz58YXPv+CiGsyjq1St19mbKEma5KKz3Ya9mlV8aLyh4zmLK2mEHPnF//Ipg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -104,9 +104,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.162",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.162.tgz",
|
||||
"integrity": "sha512-Jr4cyfqzb5V2+p/1PynIfGROOI0JNtV3vBMAgckAdtDzpIFk4mV2T8936tsZzgZr04y40YH1HlQpnJDr92I+Fw==",
|
||||
"version": "0.3.177",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.177.tgz",
|
||||
"integrity": "sha512-v6PMDD3h2erLuTK5S2ZvExqdL3v44OyC70XpKhyqIUnyPaGR9YAMjh//EKdWC+mNvt6mIbRZpaDcHtbASVW8Rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -117,9 +117,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.162",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.162.tgz",
|
||||
"integrity": "sha512-FO2+zDuSTsZ/5MqxIwExLxG0c2auA5wO6iICwZdUOWtboC6yU2AEgp4mzF0jbe1UOP0J27uODFpvz7uRpWipkg==",
|
||||
"version": "0.3.177",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.177.tgz",
|
||||
"integrity": "sha512-WDP6puwPHscggNAfvIxyUSHSAjfUEkGRfnMXEPBHOqO+qjX2KGxeE13/ih3EVioeBVOUIqPui6VB05vXLa6T9Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -130,9 +130,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.162",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.162.tgz",
|
||||
"integrity": "sha512-gW9Gpk7W3w3zGFHBDyY8uer/PE6T0pB+emN1aafZAomfseIH1ixJ6ya5Fw2cIS/K0/4oR2pvu4AprlbRBtr45Q==",
|
||||
"version": "0.3.177",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.177.tgz",
|
||||
"integrity": "sha512-1cdEO06WoEsl1JnnLCPIlg/8x37GtBsTuj65gIpSjdrImNBjgIuMWVyceP4qaXhFjtQgYK1nx3TpaxEFVDOrDg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -143,9 +143,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.162",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.162.tgz",
|
||||
"integrity": "sha512-TZQifFDBdhzt1u6wbbpQ2AZcRkhNVYx1iZVfydAbs3L7ZMK264LjRPytBK4n4S413Is1XyLHbGMvEUNA3N+Tng==",
|
||||
"version": "0.3.177",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.177.tgz",
|
||||
"integrity": "sha512-xLgDWnZaYohtFrkgEIkGZdP+rp4sXxAMbbpEvEp0LK1vAYmJam/ztT2yoK6gfI58IbToJq1WGUEX2HVnE65yOg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -156,9 +156,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.162",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.162.tgz",
|
||||
"integrity": "sha512-8ZYDgNxkGp47xwpcEZ6/qUXd4BByA8hTnNf4fJD6+P1wWi7Ofxc/d5jwXSYwajYvBvbbktQDthmGzjtoK3lXUw==",
|
||||
"version": "0.3.177",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.177.tgz",
|
||||
"integrity": "sha512-SIdQLbtF//rYK4KDBNpUPyjyui7NwCFPZ2/3vyW3TR8R8xynkNq3cLis90FnPSgxaSshi38vkJYWh+9kGDB7zg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -169,9 +169,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.100.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz",
|
||||
"integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==",
|
||||
"version": "0.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz",
|
||||
"integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
@@ -509,14 +509,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -893,9 +893,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"version": "25.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -903,17 +903,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz",
|
||||
"integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/type-utils": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/type-utils": "8.61.0",
|
||||
"@typescript-eslint/utils": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -926,22 +926,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
|
||||
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
|
||||
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -957,14 +957,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
|
||||
"integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
|
||||
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.60.1",
|
||||
"@typescript-eslint/types": "^8.60.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.0",
|
||||
"@typescript-eslint/types": "^8.61.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -979,14 +979,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz",
|
||||
"integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
|
||||
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1"
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -997,9 +997,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz",
|
||||
"integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
|
||||
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1014,15 +1014,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz",
|
||||
"integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
|
||||
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1",
|
||||
"@typescript-eslint/utils": "8.60.1",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
||||
"@typescript-eslint/utils": "8.61.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -1039,9 +1039,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
|
||||
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
|
||||
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1053,16 +1053,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz",
|
||||
"integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
|
||||
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.60.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/visitor-keys": "8.60.1",
|
||||
"@typescript-eslint/project-service": "8.61.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -1081,16 +1081,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz",
|
||||
"integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
|
||||
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.60.1",
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/typescript-estree": "8.60.1"
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1105,13 +1105,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz",
|
||||
"integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==",
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
|
||||
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.60.1",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1136,16 +1136,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -1154,13 +1154,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -1181,9 +1181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1194,13 +1194,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1208,14 +1208,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1224,9 +1224,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1234,13 +1234,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -1263,9 +1263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -1361,21 +1361,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1385,6 +1385,20 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
@@ -1686,11 +1700,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz",
|
||||
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz",
|
||||
"integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
@@ -2313,9 +2330,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.23",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
||||
"version": "4.12.25",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
||||
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2954,15 +2971,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
@@ -3154,9 +3174,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"version": "3.8.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
|
||||
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -3304,9 +3324,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -3392,15 +3412,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -3826,19 +3846,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.8",
|
||||
"@vitest/mocker": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/runner": "4.1.8",
|
||||
"@vitest/snapshot": "4.1.8",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -3866,12 +3886,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/browser-preview": "4.1.8",
|
||||
"@vitest/browser-webdriverio": "4.1.8",
|
||||
"@vitest/coverage-istanbul": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vitest/ui": "4.1.8",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
20
package.json
20
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.41.0",
|
||||
"version": "0.45.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -60,22 +60,22 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.24.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.162",
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.177",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.100.1",
|
||||
"@anthropic-ai/sdk": "0.104.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"eslint": "10.4.1",
|
||||
"@types/node": "25.9.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"eslint": "10.5.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.6.0",
|
||||
"prettier": "3.8.3",
|
||||
"prettier": "3.8.4",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.8"
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
642
src/acp-agent.ts
642
src/acp-agent.ts
@@ -36,8 +36,6 @@ import {
|
||||
CloseSessionResponse,
|
||||
DeleteSessionRequest,
|
||||
DeleteSessionResponse,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
StopReason,
|
||||
@@ -50,8 +48,10 @@ import {
|
||||
McpServerConfig,
|
||||
ModelInfo,
|
||||
ModelUsage,
|
||||
OnElicitation,
|
||||
Options,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
PermissionUpdate,
|
||||
Query,
|
||||
query,
|
||||
@@ -62,13 +62,24 @@ import {
|
||||
SDKPartialAssistantMessage,
|
||||
SDKUserMessage,
|
||||
SlashCommand,
|
||||
ThinkingConfig,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Stats } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import {
|
||||
applyAskElicitationResponse,
|
||||
askUserQuestionsToCreateRequest,
|
||||
createElicitationResponseToElicitResult,
|
||||
ElicitationSupport,
|
||||
extractAskUserQuestions,
|
||||
mcpElicitationToCreateRequest,
|
||||
} from "./elicitation.js";
|
||||
import { SettingsManager } from "./settings.js";
|
||||
import {
|
||||
applyTaskCreate,
|
||||
@@ -194,6 +205,30 @@ type Session = {
|
||||
/** Accumulated task list for the session, keyed by task ID. Task IDs are
|
||||
* per-session, so this state must not be shared across sessions. */
|
||||
taskState: TaskState;
|
||||
/** Caches `tool_use` blocks by id so the matching `tool_result` can recover
|
||||
* the tool name/input when mapping it to a `tool_call_update`. Per-session
|
||||
* (tool_use ids are only unique within a session) and pruned at
|
||||
* `tool_result` time so a long-running session doesn't accumulate every
|
||||
* tool call for its whole lifetime. */
|
||||
toolUseCache: ToolUseCache;
|
||||
/** Maps the ACP `messageId` we expose to clients (see `messageIdForGrouping`)
|
||||
* to the SDK message uuid that the Agent SDK's rewind/resume APIs key on
|
||||
* (`Query.rewindFiles` takes a user-message uuid; `resumeSessionAt` takes an
|
||||
* `SDKAssistantMessage.uuid`). For assistant turns the two differ — the ACP
|
||||
* id is the Anthropic API message id (`msg_…`), available at `message_start`
|
||||
* so streamed chunks can carry it, while the uuid only arrives on the
|
||||
* consolidated message — so a client can only ask to rewind/fork by the id it
|
||||
* was given, and we need this table to translate it back.
|
||||
*
|
||||
* Populated as a byproduct of the message loop (the consolidated message
|
||||
* carries both ids) and of `replaySessionHistory` on load, so no extra
|
||||
* `getSessionMessages` read is needed at rewind time. Last-write-wins
|
||||
* naturally yields the turn-boundary uuid when one `msg_…` spans several
|
||||
* content-block messages.
|
||||
*
|
||||
* NOT READ YET — recorded now so the mapping exists if/when we wire up
|
||||
* fork/rewind. */
|
||||
messageIdToUuid: Map<string, string>;
|
||||
};
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
@@ -208,17 +243,6 @@ function computeSessionFingerprint(params: {
|
||||
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
||||
}
|
||||
|
||||
type BackgroundTerminal =
|
||||
| {
|
||||
handle: TerminalHandle;
|
||||
status: "started";
|
||||
lastOutput: TerminalOutputResponse | null;
|
||||
}
|
||||
| {
|
||||
status: "aborted" | "exited" | "killed" | "timedOut";
|
||||
pendingOutput: TerminalOutputResponse;
|
||||
};
|
||||
|
||||
export type SDKMessageFilter = {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
@@ -547,8 +571,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
[key: string]: Session;
|
||||
};
|
||||
client: AgentSideConnection;
|
||||
toolUseCache: ToolUseCache;
|
||||
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
logger: Logger;
|
||||
gatewayAuthRequest?: GatewayAuthRequest;
|
||||
@@ -560,7 +582,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
constructor(client: AgentSideConnection, logger?: Logger) {
|
||||
this.sessions = {};
|
||||
this.client = client;
|
||||
this.toolUseCache = {};
|
||||
this.logger = logger ?? console;
|
||||
}
|
||||
|
||||
@@ -830,6 +851,32 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// `compacting` status sets it again, so every distinct compaction (e.g.
|
||||
// repeated auto-compactions in a long turn) is still shown.
|
||||
let compactionInProgress = false;
|
||||
// Holds the Anthropic API message id of the assistant message currently
|
||||
// being streamed, captured from `message_start` so every streamed chunk can
|
||||
// be tagged with it. We use the API message id rather than the
|
||||
// per-`stream_event` uuid because the same id is also present on the
|
||||
// consolidated assistant message and in the persisted transcript — so a turn
|
||||
// keeps the same ACP `messageId` whether it is streamed live or replayed
|
||||
// from history. The per-event uuid is unique per event and never persisted.
|
||||
// A single value suffices because every streaming partial arrives with
|
||||
// `parent_tool_use_id === null` (subagent work is folded into tool-result
|
||||
// messages, never surfaced as partial streams).
|
||||
let currentStreamMessageId: string | undefined;
|
||||
// Per-message-id record of which assistant content actually streamed live via
|
||||
// `stream_event` deltas, split by block type. The `assistant` case below
|
||||
// normally drops `text`/`thinking` blocks on the assumption they already
|
||||
// reached the client as `agent_message_chunk`/`agent_thought_chunk`. That
|
||||
// breaks behind Anthropic-protocol gateways that return a turn as a single
|
||||
// non-streamed block (common with OpenAI-compatible proxies): no
|
||||
// `content_block_delta` fires, so the assembled block is the only copy the
|
||||
// client will ever see. We keep the filter per (id, block type) for content
|
||||
// that did stream, and fall back to forwarding what did not. Split by type so
|
||||
// a gateway that streams text but not thinking (or vice versa) doesn't lose
|
||||
// the un-streamed block. Only top-level (`parent_tool_use_id === null`)
|
||||
// streams are recorded — subagent text is never streamed and must stay
|
||||
// filtered, as it is internal to the tool call.
|
||||
const streamedTextIds = new Set<string>();
|
||||
const streamedThinkingIds = new Set<string>();
|
||||
|
||||
const userMessage = promptToClaude(params);
|
||||
|
||||
@@ -941,28 +988,35 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
case "compact_boundary": {
|
||||
// Send used:0 immediately so the client doesn't keep showing
|
||||
// the stale pre-compaction context size until the next turn.
|
||||
// Refresh the displayed usage immediately so the client doesn't
|
||||
// keep showing the stale pre-compaction size (e.g. "944k/1m")
|
||||
// right after the user sees "Compacting completed", which is
|
||||
// confusing and wrong.
|
||||
//
|
||||
// This is a deliberate approximation: we don't know the exact
|
||||
// post-compaction token count (only the SDK's next API call
|
||||
// reveals that). But used:0 is directionally correct — context
|
||||
// just dropped dramatically — and the real value replaces it
|
||||
// within seconds when the next result message arrives.
|
||||
// The alternative (no update) leaves the client showing e.g.
|
||||
// "944k/1m" right after the user sees "Compacting completed",
|
||||
// which is confusing and wrong.
|
||||
// Prefer the SDK's authoritative post-compaction `used` via
|
||||
// getContextUsage — it reflects the real retained context
|
||||
// (system prompt + tools + surviving messages), which the
|
||||
// per-message API usage numbers can't give us until the next
|
||||
// turn's result. If the control request fails, fall back to the
|
||||
// used:0 approximation: directionally correct (context just
|
||||
// dropped dramatically) and replaced within seconds by the next
|
||||
// result message.
|
||||
//
|
||||
// `size` keeps coming from session.contextWindowSize (learned
|
||||
// from modelUsage / the model heuristic) — getContextUsage's
|
||||
// window field under-reports extended 1M windows.
|
||||
//
|
||||
// The "Compacting completed." text is emitted from the `status`
|
||||
// handler (keyed on `compact_result`), not here, so the failure
|
||||
// path gets a message too.
|
||||
lastAssistantTotalUsage = 0;
|
||||
const usedTokens = await fetchContextUsedTokens(session.query, this.logger);
|
||||
lastAssistantUsage = null;
|
||||
lastAssistantTotalUsage = usedTokens ?? 0;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 0,
|
||||
used: lastAssistantTotalUsage,
|
||||
size: session.contextWindowSize,
|
||||
},
|
||||
});
|
||||
@@ -1043,6 +1097,49 @@ export class ClaudeAcpAgent implements Agent {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "mirror_error": {
|
||||
// The SDK failed to persist session history (SessionStore
|
||||
// append rejected/timed out after retry) — potential data loss
|
||||
// the user should know about rather than a silent gap on
|
||||
// resume. Log it and surface a warning in the conversation.
|
||||
this.logger.error(
|
||||
`Session ${message.session_id}: failed to persist history: ${message.error}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "permission_denied": {
|
||||
// A tool call was auto-denied (by a rule, the classifier,
|
||||
// dontAsk mode, etc.) before running. The tool_use block was
|
||||
// already emitted as a `tool_call`, so mark it failed with the
|
||||
// rejection reason — otherwise the client shows a tool call
|
||||
// that silently never resolves.
|
||||
const reason = message.decision_reason ?? message.message;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: message.tool_use_id,
|
||||
status: "failed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: `Permission denied: ${reason}` },
|
||||
},
|
||||
],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: message.tool_name,
|
||||
toolResponse: {
|
||||
decisionReasonType: message.decision_reason_type,
|
||||
decisionReason: message.decision_reason,
|
||||
message: message.message,
|
||||
},
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "hook_started":
|
||||
case "hook_progress":
|
||||
case "hook_response":
|
||||
@@ -1051,13 +1148,27 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "task_notification":
|
||||
case "task_progress":
|
||||
case "task_updated":
|
||||
case "elicitation_complete":
|
||||
break;
|
||||
case "elicitation_complete": {
|
||||
// A url-mode MCP elicitation finished server-side. Let the client
|
||||
// dismiss any UI it opened for it. Only meaningful when the
|
||||
// client supports url elicitation; ignore failures otherwise.
|
||||
if (this.clientCapabilities?.elicitation?.url) {
|
||||
try {
|
||||
await this.client.unstable_completeElicitation({
|
||||
elicitationId: message.elicitation_id,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to complete elicitation: ${error}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "plugin_install":
|
||||
case "notification":
|
||||
case "api_retry":
|
||||
case "mirror_error":
|
||||
case "permission_denied":
|
||||
case "thinking_tokens":
|
||||
case "model_refusal_fallback":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
default:
|
||||
@@ -1161,7 +1272,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message.result,
|
||||
"assistant",
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
session.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
@@ -1208,6 +1319,28 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
case "stream_event": {
|
||||
// `message_start` carries the Anthropic API message id; capture it
|
||||
// so the streamed chunks that follow (whose delta events don't carry
|
||||
// it) can all be tagged with the same, replay-stable id.
|
||||
if (message.event.type === "message_start") {
|
||||
currentStreamMessageId = message.event.message.id || undefined;
|
||||
}
|
||||
// Record that this top-level message id actually streamed text/
|
||||
// thinking, so the `assistant` case below knows its assembled blocks
|
||||
// are duplicates (filter them) rather than the only copy (forward
|
||||
// them). Gated on `parent_tool_use_id === null` so a subagent stream
|
||||
// can't attribute its content to the top-level message id.
|
||||
if (
|
||||
currentStreamMessageId &&
|
||||
message.parent_tool_use_id === null &&
|
||||
message.event.type === "content_block_delta"
|
||||
) {
|
||||
if (message.event.delta.type === "text_delta") {
|
||||
streamedTextIds.add(currentStreamMessageId);
|
||||
} else if (message.event.delta.type === "thinking_delta") {
|
||||
streamedThinkingIds.add(currentStreamMessageId);
|
||||
}
|
||||
}
|
||||
if (
|
||||
message.parent_tool_use_id === null &&
|
||||
(message.event.type === "message_start" || message.event.type === "message_delta")
|
||||
@@ -1262,13 +1395,14 @@ export class ClaudeAcpAgent implements Agent {
|
||||
for (const notification of streamEventToAcpNotifications(
|
||||
message,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
session.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
messageId: currentStreamMessageId,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
@@ -1281,6 +1415,15 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
|
||||
// Record the ACP messageId -> SDK uuid mapping for this message. The
|
||||
// consolidated message carries both ids, so this is where we learn
|
||||
// the uuid that the SDK's rewind/resume APIs key on for the id we
|
||||
// hand clients. Not read yet (see Session.messageIdToUuid).
|
||||
const mappedMessageId = messageIdForGrouping(message);
|
||||
if (mappedMessageId && typeof message.uuid === "string" && message.uuid.length > 0) {
|
||||
session.messageIdToUuid.set(mappedMessageId, message.uuid);
|
||||
}
|
||||
|
||||
// Check for prompt replay
|
||||
if (message.type === "user" && "uuid" in message && message.uuid) {
|
||||
if (message.uuid === promptUuid) {
|
||||
@@ -1336,7 +1479,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
stripped,
|
||||
message.message.role,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
session.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{
|
||||
@@ -1344,6 +1487,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
messageId: messageIdForGrouping(message),
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
@@ -1386,19 +1530,53 @@ export class ClaudeAcpAgent implements Agent {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
const content =
|
||||
message.type === "assistant"
|
||||
? // Handled by stream events above
|
||||
message.message.content.filter(
|
||||
(item) => !["text", "thinking"].includes(item.type),
|
||||
)
|
||||
: message.message.content;
|
||||
let content: typeof message.message.content;
|
||||
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
||||
// Top-level assistant message: drop text/thinking blocks already
|
||||
// streamed live as chunks, and forward (as a fallback) any that were
|
||||
// not, so non-streaming gateways still deliver the final answer.
|
||||
const id = messageIdForGrouping(message);
|
||||
content = message.message.content.filter((item) => {
|
||||
// Non-text blocks (tool_use, etc.) always pass through; their own
|
||||
// dedupe (`toolUseCache`) collapses the streamed/assembled pair.
|
||||
if (item.type !== "text" && item.type !== "thinking") {
|
||||
return true;
|
||||
}
|
||||
// Already delivered live as a chunk of this exact type — drop the
|
||||
// duplicate. Checked per type so streaming one (e.g. text) doesn't
|
||||
// suppress an un-streamed block of the other (e.g. thinking).
|
||||
const streamedLive =
|
||||
id !== undefined &&
|
||||
(item.type === "text" ? streamedTextIds : streamedThinkingIds).has(id);
|
||||
if (streamedLive) {
|
||||
return false;
|
||||
}
|
||||
// Empty assembled blocks carry nothing (some gateways emit an empty
|
||||
// `thinking` block before the real text) — don't forward stray
|
||||
// empty chunks.
|
||||
const text = item.type === "text" ? item.text : item.thinking;
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} else if (message.type === "assistant") {
|
||||
// Subagent assistant message (`parent_tool_use_id !== null`). It is
|
||||
// never streamed live and its text/thinking is internal to the tool
|
||||
// call — keep dropping it so subagent prose doesn't leak into the
|
||||
// top-level feed.
|
||||
content = message.message.content.filter(
|
||||
(item) => item.type !== "text" && item.type !== "thinking",
|
||||
);
|
||||
} else {
|
||||
content = message.message.content;
|
||||
}
|
||||
|
||||
for (const notification of toAcpNotifications(
|
||||
content,
|
||||
message.message.role,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
session.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{
|
||||
@@ -1406,17 +1584,47 @@ export class ClaudeAcpAgent implements Agent {
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
messageId: messageIdForGrouping(message),
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_progress":
|
||||
case "tool_progress": {
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: message.tool_use_id,
|
||||
status: "in_progress",
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: message.tool_name,
|
||||
toolResponse: { elapsedTimeSeconds: message.elapsed_time_seconds },
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "rate_limit_event": {
|
||||
if (lastAssistantTotalUsage !== null) {
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: lastAssistantTotalUsage,
|
||||
size: session.contextWindowSize,
|
||||
_meta: { "_claude/rateLimit": message.rate_limit_info },
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_use_summary":
|
||||
case "auth_status":
|
||||
case "prompt_suggestion":
|
||||
case "rate_limit_event":
|
||||
break;
|
||||
default:
|
||||
unreachable(message);
|
||||
@@ -1591,7 +1799,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return {};
|
||||
}
|
||||
|
||||
async unstable_deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
||||
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
||||
// Tear down any active in-memory state first so the on-disk file isn't
|
||||
// recreated by an outstanding query writing to it.
|
||||
if (this.sessions[params.sessionId]) {
|
||||
@@ -1718,6 +1926,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
|
||||
for (const message of messages) {
|
||||
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
|
||||
// observe live (resumed/loaded sessions), so rewind/resume can translate
|
||||
// a client-supplied id without an extra getSessionMessages read. Not read
|
||||
// yet (see Session.messageIdToUuid).
|
||||
const replayMessageId = messageIdForGrouping(message);
|
||||
const replaySession = this.sessions[sessionId];
|
||||
if (replaySession && replayMessageId && message.uuid) {
|
||||
replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
|
||||
}
|
||||
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
let content: unknown = message.message.content;
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
@@ -1740,6 +1958,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
cwd: this.sessions[sessionId]?.cwd,
|
||||
taskState: this.sessions[sessionId]?.taskState,
|
||||
messageId: replayMessageId,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
@@ -1769,6 +1988,14 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
|
||||
// AskUserQuestion is surfaced to us as a normal permission check (the SDK
|
||||
// routes it through canUseTool whenever a callback is registered, rather
|
||||
// than the interactive dialog). Present it as an ACP form elicitation and
|
||||
// feed the answers back as updatedInput for the tool's own call() to read.
|
||||
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
||||
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
||||
}
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const optionsAll: PermissionOption[] = [
|
||||
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
||||
@@ -1913,6 +2140,71 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle elicitation requests that originate from MCP servers by forwarding
|
||||
* them to the client over ACP. Modes the client did not advertise (or
|
||||
* requests we can't represent) are declined.
|
||||
*/
|
||||
private handleMcpElicitation(sessionId: string, support: ElicitationSupport): OnElicitation {
|
||||
return async (request, { signal }) => {
|
||||
const isUrl = request.mode === "url";
|
||||
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
|
||||
return { action: "decline" };
|
||||
}
|
||||
|
||||
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
|
||||
if (!createRequest) {
|
||||
return { action: "decline" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.unstable_createElicitation(createRequest);
|
||||
if (signal.aborted) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
return createElicitationResponseToElicitResult(response);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to forward MCP elicitation: ${error}`);
|
||||
return { action: "decline" };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Present the built-in AskUserQuestion tool's questions as an ACP form
|
||||
* elicitation and return the answers as the tool's `updatedInput`. Called from
|
||||
* `canUseTool` since that is where the SDK routes the tool's permission check.
|
||||
*/
|
||||
private async handleAskUserQuestion(
|
||||
sessionId: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
toolUseID: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<PermissionResult> {
|
||||
const questions = extractAskUserQuestions(toolInput);
|
||||
if (!questions) {
|
||||
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
||||
}
|
||||
|
||||
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
||||
let response;
|
||||
try {
|
||||
response = await this.client.unstable_createElicitation(createRequest);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
||||
return { behavior: "deny", message: "Could not present the question to the user." };
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
|
||||
const outcome = applyAskElicitationResponse(response, toolInput, questions);
|
||||
if (outcome.action === "cancel") {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
return { behavior: "allow", updatedInput: outcome.updatedInput };
|
||||
}
|
||||
|
||||
private async sendAvailableCommandsUpdate(sessionId: string): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return;
|
||||
@@ -2088,10 +2380,46 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the requested `cwd` is an absolute path that points at an existing
|
||||
* directory before we create a session. Throws an `invalidParams` error with
|
||||
* an actionable message so clients (e.g. Zed) can surface it to the user
|
||||
* instead of failing later with an opaque SDK error.
|
||||
*/
|
||||
private async validateCwd(cwd: string): Promise<void> {
|
||||
if (!path.isAbsolute(cwd)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ cwd },
|
||||
`\`cwd\` must be an absolute path, but received: ${cwd}`,
|
||||
);
|
||||
}
|
||||
|
||||
let stats: Stats;
|
||||
try {
|
||||
stats = await fs.stat(cwd);
|
||||
} catch {
|
||||
throw RequestError.invalidParams(
|
||||
{ cwd },
|
||||
`\`cwd\` does not exist on the machine running the agent: ${cwd}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
params: NewSessionRequest,
|
||||
creationOpts: { resume?: string; forkSession?: boolean } = {},
|
||||
): Promise<NewSessionResponse> {
|
||||
// Validate `cwd` up front. The ACP spec requires an absolute path, and the
|
||||
// directory must actually exist on the machine running the agent. Without
|
||||
// this check a session is created against a missing directory and the
|
||||
// failure only surfaces later as a confusing "native binary failed to
|
||||
// launch" error from the SDK (see issue #749).
|
||||
await this.validateCwd(params.cwd);
|
||||
|
||||
// We want to create a new session id unless it is resume,
|
||||
// but not resume + forkSession.
|
||||
let sessionId;
|
||||
@@ -2165,16 +2493,24 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const sessionMeta = params._meta as NewSessionMeta | undefined;
|
||||
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
||||
|
||||
// Configure thinking tokens from environment variable
|
||||
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
|
||||
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
|
||||
: undefined;
|
||||
// Configure thinking behavior from environment variable
|
||||
const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
|
||||
|
||||
// Parse model configuration from environment (e.g. Bedrock model overrides)
|
||||
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
|
||||
|
||||
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
|
||||
const disallowedTools = ["AskUserQuestion"];
|
||||
// Elicitation modes the connected client advertised. We only forward
|
||||
// elicitations (and only re-enable AskUserQuestion) for modes the client
|
||||
// can actually render.
|
||||
const elicitationSupport: ElicitationSupport = {
|
||||
form: !!this.clientCapabilities?.elicitation?.form,
|
||||
url: !!this.clientCapabilities?.elicitation?.url,
|
||||
};
|
||||
|
||||
// AskUserQuestion surfaces as a `permission_ask_user_question` dialog that
|
||||
// we render as a form elicitation. Without form-elicitation support there
|
||||
// is no way to present it over ACP, so keep it disabled in that case.
|
||||
const disallowedTools = elicitationSupport.form ? [] : ["AskUserQuestion"];
|
||||
|
||||
// Resolve which built-in tools to expose.
|
||||
// Explicit tools array from _meta.claudeCode.options takes precedence.
|
||||
@@ -2194,7 +2530,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const options: Options = {
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
|
||||
...(thinking !== undefined && { thinking }),
|
||||
...userProvidedOptions,
|
||||
// CLAUDE_MODEL_CONFIG env var is a fallback for model
|
||||
// configuration (e.g. Bedrock model ID overrides). When the caller
|
||||
@@ -2223,6 +2559,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
||||
permissionMode,
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
// Forward MCP elicitation requests onto ACP elicitation. Only attached
|
||||
// when the client advertised support, so non-supporting clients keep the
|
||||
// SDK's default (auto-decline) behavior. (AskUserQuestion is handled in
|
||||
// canUseTool, not here.)
|
||||
...(elicitationSupport.form || elicitationSupport.url
|
||||
? { onElicitation: this.handleMcpElicitation(sessionId, elicitationSupport) }
|
||||
: {}),
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
|
||||
extraArgs: {
|
||||
...userProvidedOptions?.extraArgs,
|
||||
@@ -2422,7 +2765,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
effortLevel: initialEffort.currentValue as Settings["effortLevel"],
|
||||
});
|
||||
}
|
||||
|
||||
this.sessions[sessionId] = {
|
||||
query: q,
|
||||
input: input,
|
||||
@@ -2448,6 +2790,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
contextWindowSize:
|
||||
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
taskState,
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -2739,11 +3083,14 @@ function tokenizeModelPreference(model: string): { tokens: string[]; contextHint
|
||||
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
|
||||
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
|
||||
let score = 0;
|
||||
let nonHintMatched = false;
|
||||
for (const token of tokens) {
|
||||
if (haystack.includes(token)) {
|
||||
if (token !== contextHint) nonHintMatched = true;
|
||||
score += token === contextHint ? 3 : 1;
|
||||
}
|
||||
}
|
||||
if (contextHint && !nonHintMatched) return 0;
|
||||
return score;
|
||||
}
|
||||
|
||||
@@ -2848,6 +3195,21 @@ function applyAvailableModelsAllowlist(sdkModels: ModelInfo[], allowlist: string
|
||||
seen.add(trimmed);
|
||||
}
|
||||
|
||||
// The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
|
||||
// allowlist, the same way Default is. Per the model-config docs it adds an
|
||||
// entry "without replacing the built-in aliases" and "appears at the bottom of
|
||||
// the /model picker", so we append it last and skip the allowlist filter; this
|
||||
// keeps a slim alias allowlist from hiding the custom model row.
|
||||
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
|
||||
const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim();
|
||||
if (customModelOption && !seen.has(customModelOption)) {
|
||||
const customModel = sdkModels.find((m) => m.value === customModelOption);
|
||||
if (customModel) {
|
||||
result.push(customModel);
|
||||
seen.add(customModel.value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -3039,6 +3401,54 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the ACP `messageId` for a Claude SDK message (live) or a persisted
|
||||
* transcript message (replay) so chunk grouping is identical in both views.
|
||||
*
|
||||
* Assistant turns are keyed by the Anthropic API message id (`message.id`),
|
||||
* which is identical at `message_start`, on the consolidated assistant message,
|
||||
* and in the persisted transcript — unlike the per-`stream_event` uuid, which is
|
||||
* unique per event and never persisted. User messages have no API id, but they
|
||||
* are never streamed, so their (stable) SDK uuid is used instead. ACP message
|
||||
* ids are opaque strings, so no particular format is required.
|
||||
*/
|
||||
export function messageIdForGrouping(message: {
|
||||
type?: string;
|
||||
uuid?: string | null;
|
||||
message?: unknown;
|
||||
}): string | undefined {
|
||||
if (message.type === "assistant") {
|
||||
const inner = message.message;
|
||||
const apiId =
|
||||
inner && typeof inner === "object" && "id" in inner
|
||||
? (inner as { id?: unknown }).id
|
||||
: undefined;
|
||||
if (typeof apiId === "string" && apiId.length > 0) {
|
||||
return apiId;
|
||||
}
|
||||
}
|
||||
return typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamps an ACP `messageId` onto a session update, but only on the message/
|
||||
* thought chunk variants that carry one — tool_call/plan/etc. updates never do.
|
||||
* No-op when `messageId` is falsy, so callers can pass it through unconditionally.
|
||||
*/
|
||||
function applyMessageId(
|
||||
update: SessionNotification["update"],
|
||||
messageId: string | undefined,
|
||||
): void {
|
||||
if (
|
||||
messageId &&
|
||||
(update.sessionUpdate === "agent_message_chunk" ||
|
||||
update.sessionUpdate === "user_message_chunk" ||
|
||||
update.sessionUpdate === "agent_thought_chunk")
|
||||
) {
|
||||
update.messageId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
||||
* Only handles text, image, and thinking chunks for now.
|
||||
@@ -3056,6 +3466,12 @@ export function toAcpNotifications(
|
||||
parentToolUseId?: string | null;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
// Opaque id identifying the message these chunks belong to (ACP message ids
|
||||
// are opaque strings — no particular format is required). Attached to
|
||||
// user/agent message and thought chunks so clients can group streamed chunks
|
||||
// into a single message. Omit it (leave undefined) when unknown — never send
|
||||
// an explicit `null`.
|
||||
messageId?: string;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const taskState = options?.taskState ?? new Map();
|
||||
@@ -3069,6 +3485,7 @@ export function toAcpNotifications(
|
||||
text: content,
|
||||
},
|
||||
};
|
||||
applyMessageId(update, options?.messageId);
|
||||
|
||||
if (options?.parentToolUseId) {
|
||||
update._meta = {
|
||||
@@ -3144,42 +3561,41 @@ export function toAcpNotifications(
|
||||
} else {
|
||||
// Only register hooks on first encounter to avoid double-firing
|
||||
if (registerHooks && !alreadyCached) {
|
||||
// Capture the tool name in the closure rather than re-reading the
|
||||
// cache when the hook fires. The cache entry is pruned at
|
||||
// tool_result time, and a PostToolUse hook can fire after that, so
|
||||
// closing over the name keeps the diff working without depending on
|
||||
// (or pinning) the cache entry's lifetime.
|
||||
const toolName = chunk.name;
|
||||
registerHookCallback(chunk.id, {
|
||||
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
||||
const toolUse = toolUseCache[toolUseId];
|
||||
if (toolUse) {
|
||||
// Both `Edit` and `Write` produce a structuredPatch in their
|
||||
// PostToolUse tool_response. For Edit the diff replaces the
|
||||
// optimistic content built at tool_use time. For Write the
|
||||
// optimistic content (built from `input.content` alone with
|
||||
// `oldText: null`) shows "creation" semantics regardless of
|
||||
// whether the file existed; the structuredPatch from the
|
||||
// hook lets us emit the real diff for `type: "update"`. The
|
||||
// helper returns `{}` if the response shape isn't usable.
|
||||
const editDiff =
|
||||
toolUse.name === "Edit" || toolUse.name === "Write"
|
||||
? toolUpdateFromDiffToolResponse(toolResponse)
|
||||
: {};
|
||||
const update: SessionNotification["update"] = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolResponse,
|
||||
toolName: toolUse.name,
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: "tool_call_update",
|
||||
...editDiff,
|
||||
};
|
||||
await client.sessionUpdate({
|
||||
sessionId,
|
||||
update,
|
||||
});
|
||||
} else {
|
||||
logger.error(
|
||||
`[claude-agent-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
|
||||
);
|
||||
}
|
||||
// Both `Edit` and `Write` produce a structuredPatch in their
|
||||
// PostToolUse tool_response. For Edit the diff replaces the
|
||||
// optimistic content built at tool_use time. For Write the
|
||||
// optimistic content (built from `input.content` alone with
|
||||
// `oldText: null`) shows "creation" semantics regardless of
|
||||
// whether the file existed; the structuredPatch from the
|
||||
// hook lets us emit the real diff for `type: "update"`. The
|
||||
// helper returns `{}` if the response shape isn't usable.
|
||||
const editDiff =
|
||||
toolName === "Edit" || toolName === "Write"
|
||||
? toolUpdateFromDiffToolResponse(toolResponse)
|
||||
: {};
|
||||
const update: SessionNotification["update"] = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolResponse,
|
||||
toolName,
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: "tool_call_update",
|
||||
...editDiff,
|
||||
};
|
||||
await client.sessionUpdate({
|
||||
sessionId,
|
||||
update,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3316,6 +3732,11 @@ export function toAcpNotifications(
|
||||
...toolUpdate,
|
||||
};
|
||||
}
|
||||
// The tool_use is fully resolved now — drop it so a long session doesn't
|
||||
// retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
|
||||
// over the tool name and no longer reads the cache, so pruning here is
|
||||
// safe regardless of hook/result ordering.
|
||||
delete toolUseCache[chunk.tool_use_id];
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3330,6 +3751,7 @@ export function toAcpNotifications(
|
||||
case "compaction_delta":
|
||||
case "advisor_tool_result":
|
||||
case "mid_conv_system":
|
||||
case "fallback":
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -3346,6 +3768,7 @@ export function toAcpNotifications(
|
||||
},
|
||||
};
|
||||
}
|
||||
applyMessageId(update, options?.messageId);
|
||||
output.push({ sessionId, update });
|
||||
}
|
||||
}
|
||||
@@ -3363,6 +3786,7 @@ export function streamEventToAcpNotifications(
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
messageId?: string;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
@@ -3380,6 +3804,7 @@ export function streamEventToAcpNotifications(
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
messageId: options?.messageId,
|
||||
},
|
||||
);
|
||||
case "content_block_delta":
|
||||
@@ -3395,6 +3820,7 @@ export function streamEventToAcpNotifications(
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
messageId: options?.messageId,
|
||||
},
|
||||
);
|
||||
// No content. `ping` is a Messages-API keep-alive event that the SDK's
|
||||
@@ -3436,7 +3862,8 @@ function commonPrefixLength(a: string, b: string) {
|
||||
}
|
||||
|
||||
/** Best-effort first guess of a model's context window from its ID, used only
|
||||
* until a `result` message arrives with the authoritative `modelUsage` value.
|
||||
* as a fallback when the SDK's authoritative `getContextUsage` is unavailable
|
||||
* (and until a `result` message arrives with the `modelUsage` value).
|
||||
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
||||
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
||||
* matching things like "10m" or embedded substrings. */
|
||||
@@ -3445,6 +3872,45 @@ function inferContextWindowFromModel(model: string): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fetch the SDK's authoritative context-window occupancy via the
|
||||
* `getContextUsage` control request. Unlike the per-message API usage numbers
|
||||
* (which only count message tokens), this `totalTokens` includes the system
|
||||
* prompt, tool schemas, MCP tools, and memory-file overhead — the real
|
||||
* occupancy the user sees. Returns `null` on any control-request failure.
|
||||
*
|
||||
* Note: we deliberately do NOT use this response's window fields for `size`.
|
||||
* They have been observed to under-report extended (1M) context windows, so
|
||||
* the window keeps coming from `modelUsage` / `inferContextWindowFromModel`,
|
||||
* which handle the 1M variants correctly. */
|
||||
async function fetchContextUsedTokens(query: Query, logger: Logger): Promise<number | null> {
|
||||
try {
|
||||
const usage = await query.getContextUsage();
|
||||
return usage.totalTokens;
|
||||
} catch (error) {
|
||||
logger.error("Failed to fetch context usage from SDK:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
|
||||
* option. The `maxThinkingTokens` option it used to feed is deprecated and
|
||||
* reduced to on/off on current models, so map the value to explicit thinking
|
||||
* config instead: unset → `undefined` (SDK default, adaptive on models that
|
||||
* support it); `0` → disabled; a positive integer → a fixed token budget.
|
||||
* Anything else is ignored with a warning. */
|
||||
function resolveThinkingConfig(
|
||||
raw: string | undefined,
|
||||
logger: Logger,
|
||||
): ThinkingConfig | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
logger.error(`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${raw}'.`);
|
||||
return undefined;
|
||||
}
|
||||
return parsed === 0 ? { type: "disabled" } : { type: "enabled", budgetTokens: parsed };
|
||||
}
|
||||
|
||||
function parseModelConfig(
|
||||
raw: string | undefined,
|
||||
): { modelOverrides?: Record<string, string>; availableModels?: string[] } | undefined {
|
||||
|
||||
249
src/elicitation.ts
Normal file
249
src/elicitation.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ElicitationPropertySchema,
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
* (unstable) elicitation protocol.
|
||||
*
|
||||
* Two distinct SDK surfaces flow through here:
|
||||
*
|
||||
* 1. `onElicitation` — fired when an MCP server requests user input. These map
|
||||
* directly onto ACP `session/create_elicitation` (form or url mode).
|
||||
* 2. The built-in AskUserQuestion tool — when a `canUseTool` callback is
|
||||
* registered the SDK routes its permission check through `canUseTool`
|
||||
* (not the interactive `permission_ask_user_question` dialog). We render
|
||||
* its questions as an ACP form elicitation and feed the user's selections
|
||||
* back as the tool's `updatedInput`, which the tool's own `call()` reads.
|
||||
*/
|
||||
|
||||
/** Modes the connected client advertised support for. */
|
||||
export type ElicitationSupport = {
|
||||
form: boolean;
|
||||
url: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an MCP elicitation request (from the SDK's `onElicitation` callback)
|
||||
* into an ACP `CreateElicitationRequest`. Returns `null` when the request can't
|
||||
* be represented (e.g. a url-mode request with no url).
|
||||
*/
|
||||
export function mcpElicitationToCreateRequest(
|
||||
request: ElicitationRequest,
|
||||
sessionId: string,
|
||||
): CreateElicitationRequest | null {
|
||||
if (request.mode === "url") {
|
||||
if (!request.url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mode: "url",
|
||||
sessionId,
|
||||
message: request.message,
|
||||
url: request.url,
|
||||
// URL elicitations need a stable id so the client can correlate the
|
||||
// later `session/complete_elicitation` notification. MCP servers usually
|
||||
// provide one; fall back to a generated id if not.
|
||||
elicitationId: request.elicitationId ?? randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
// Form mode (the default). The MCP `requestedSchema` is already a JSON Schema
|
||||
// with primitive-typed properties, which is structurally what ACP expects.
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
message: request.message,
|
||||
requestedSchema: normalizeElicitationSchema(request.requestedSchema),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an ACP elicitation response back to the MCP `ElicitResult` the SDK expects
|
||||
* to hand back to the requesting server.
|
||||
*/
|
||||
export function createElicitationResponseToElicitResult(
|
||||
response: CreateElicitationResponse,
|
||||
): ElicitationResult {
|
||||
switch (response.action) {
|
||||
case "accept":
|
||||
return { action: "accept", content: response.content ?? {} };
|
||||
case "decline":
|
||||
return { action: "decline" };
|
||||
case "cancel":
|
||||
default:
|
||||
return { action: "cancel" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single question as supplied by the AskUserQuestion tool. Derived from the
|
||||
* SDK's input type so the shape stays in sync; the SDK validates the model's
|
||||
* tool call against this schema before it reaches us.
|
||||
*/
|
||||
export type AskUserQuestion = AskUserQuestionInput["questions"][number];
|
||||
|
||||
/**
|
||||
* Pull the well-formed questions out of an AskUserQuestion tool input. Returns
|
||||
* `null` when there are no usable questions — including the case where every
|
||||
* entry is malformed and filtering leaves an empty list — so callers can treat
|
||||
* "nothing to ask" uniformly.
|
||||
*/
|
||||
export function extractAskUserQuestions(input: Record<string, unknown>): AskUserQuestion[] | null {
|
||||
const questions = (input as { questions?: unknown }).questions;
|
||||
if (!Array.isArray(questions)) {
|
||||
return null;
|
||||
}
|
||||
const valid = questions.filter(
|
||||
(q): q is AskUserQuestion =>
|
||||
!!q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length > 0,
|
||||
);
|
||||
return valid.length > 0 ? valid : null;
|
||||
}
|
||||
|
||||
/** Stable form-field key for the question at the given index. */
|
||||
function questionFieldKey(index: number): string {
|
||||
return `question_${index}`;
|
||||
}
|
||||
|
||||
/** Form-field key for the optional free-text "custom answer" field. */
|
||||
const CUSTOM_ANSWER_FIELD = "customAnswer";
|
||||
|
||||
/**
|
||||
* Render the AskUserQuestion tool's questions as an ACP form elicitation.
|
||||
*
|
||||
* Fields are keyed by a short stable id (`question_<n>`) rather than the full
|
||||
* question text, so the question text appears in exactly one place per field.
|
||||
* Single-select questions use a titled `oneOf` enum; multi-select questions use
|
||||
* an array with a titled `anyOf` item enum. The enum `const` is always the
|
||||
* option label, since that is what the tool records as the answer.
|
||||
*
|
||||
* A trailing optional free-text field mirrors the CLI's custom-answer box: the
|
||||
* user can type their own answer instead of (or as well as) picking an option.
|
||||
* Nothing is marked required, so the user can also just skip — matching the
|
||||
* built-in tool, which always offers Skip + a free-text box.
|
||||
*/
|
||||
export function askUserQuestionsToCreateRequest(
|
||||
questions: AskUserQuestion[],
|
||||
sessionId: string,
|
||||
toolCallId: string | undefined,
|
||||
): CreateElicitationRequest {
|
||||
const single = questions.length === 1;
|
||||
const properties: Record<string, ElicitationPropertySchema> = {};
|
||||
|
||||
questions.forEach((question, index) => {
|
||||
const options: EnumOption[] = question.options.map((option) => ({
|
||||
const: option.label,
|
||||
title: option.description ? `${option.label} — ${option.description}` : option.label,
|
||||
}));
|
||||
|
||||
// For a single question the prompt is carried by `message`, so we don't
|
||||
// repeat it in the field description. With multiple questions each field
|
||||
// needs its own question text.
|
||||
const description = single ? undefined : question.question;
|
||||
const title = question.header || undefined;
|
||||
|
||||
properties[questionFieldKey(index)] = question.multiSelect
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
});
|
||||
|
||||
properties[CUSTOM_ANSWER_FIELD] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
description: "Type your own answer instead of choosing an option above (optional).",
|
||||
};
|
||||
|
||||
const requestedSchema: ElicitationSchema = {
|
||||
type: "object",
|
||||
properties,
|
||||
};
|
||||
|
||||
const message = single ? questions[0].question : "Please answer the following questions.";
|
||||
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
message,
|
||||
requestedSchema,
|
||||
};
|
||||
}
|
||||
|
||||
/** Outcome of an AskUserQuestion elicitation, decoupled from any transport. */
|
||||
export type AskUserQuestionOutcome =
|
||||
| { action: "answered"; updatedInput: Record<string, unknown> }
|
||||
| { action: "cancel" };
|
||||
|
||||
/**
|
||||
* Fold an ACP elicitation response into the AskUserQuestion tool's input.
|
||||
*
|
||||
* Selected labels are read back from the indexed form fields and written into
|
||||
* `answers` as a `{ [questionText]: label }` map (comma-joining multi-selects)
|
||||
* — the key shape the tool's own `call()` reads. Free text from the custom-
|
||||
* answer field becomes the tool's top-level `response`. Decline yields empty
|
||||
* answers (the model is told the user skipped rather than the turn aborting);
|
||||
* cancel aborts the tool call.
|
||||
*/
|
||||
export function applyAskElicitationResponse(
|
||||
response: CreateElicitationResponse,
|
||||
toolInput: Record<string, unknown>,
|
||||
questions: AskUserQuestion[],
|
||||
): AskUserQuestionOutcome {
|
||||
if (response.action === "cancel") {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
if (response.action === "decline") {
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
|
||||
}
|
||||
|
||||
const content = response.content ?? {};
|
||||
// Typed against the tool's own output schema so the answer/response shapes
|
||||
// stay in sync with what the built-in tool's call() expects to read back.
|
||||
const answers: AskUserQuestionOutput["answers"] = {};
|
||||
questions.forEach((question, index) => {
|
||||
const value = content[questionFieldKey(index)];
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
const text = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
if (text === "") {
|
||||
return;
|
||||
}
|
||||
answers[question.question] = text;
|
||||
});
|
||||
|
||||
const updatedInput: Record<string, unknown> = { ...toolInput, answers };
|
||||
const custom = content[CUSTOM_ANSWER_FIELD];
|
||||
if (typeof custom === "string" && custom.trim() !== "") {
|
||||
const response: AskUserQuestionOutput["response"] = custom;
|
||||
updatedInput.response = response;
|
||||
}
|
||||
|
||||
return { action: "answered", updatedInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
|
||||
* The two are structurally compatible JSON Schemas; we just guarantee the
|
||||
* `type: "object"` discriminator is present.
|
||||
*/
|
||||
function normalizeElicitationSchema(
|
||||
schema: Record<string, unknown> | undefined,
|
||||
): ElicitationSchema {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
return { ...(schema as ElicitationSchema), type: "object" };
|
||||
}
|
||||
@@ -585,7 +585,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not inherit display info across mismatched model family versions", async () => {
|
||||
it("does not inherit display info across mismatched model versions", async () => {
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
|
||||
// when the SDK's `opus` alias resolves to Opus 4.7, an allowlist entry
|
||||
// of `claude-opus-4-6` (or `claude-opus-4-6[1m]`) used to substring-match
|
||||
@@ -646,6 +646,172 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(byValue["claude-opus-4-7"].name).toBe("Opus 4.7");
|
||||
expect(byValue["claude-opus-4-7[1m]"].name).toMatch(/Opus 4\.7/);
|
||||
});
|
||||
|
||||
it("does not inherit display info across mismatched model families", async () => {
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
|
||||
// when the SDK's `opus` alias resolves to Opus 4.8, `claude-opus-4-6[1m]`
|
||||
// fails the version check against `opus` but the tokenized matcher
|
||||
// falls through to `sonnet[1m]` because the `1m` context hint alone
|
||||
// is enough to score a match.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-opus-4-6", "claude-opus-4-6[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
displayName: "Sonnet (1M context)",
|
||||
description: "Sonnet 4.6 for long sessions",
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
const byValue: Record<string, { name: string; description?: string }> = {};
|
||||
for (const opt of modelOption.options) {
|
||||
byValue[opt.value] = { name: opt.name, description: opt.description };
|
||||
}
|
||||
|
||||
expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]");
|
||||
expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("preserves ANTHROPIC_CUSTOM_MODEL_OPTION even when absent from the allowlist", async () => {
|
||||
// Per the model-config docs, ANTHROPIC_CUSTOM_MODEL_OPTION adds an entry
|
||||
// "without replacing the built-in aliases" and "appears at the bottom of
|
||||
// the /model picker", so it is exempt from the availableModels allowlist
|
||||
// (the same way the Default option is "not affected by availableModels").
|
||||
// ACP must match: a slim alias allowlist must not hide the custom model
|
||||
// row, and it appears last, after the allowlisted entries.
|
||||
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
|
||||
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["sonnet", "opus", "haiku"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
{ value: "opus", displayName: "Opus", description: "Claude Opus 4.6" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Claude Haiku 4.5" },
|
||||
{
|
||||
value: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"sonnet",
|
||||
"opus",
|
||||
"haiku",
|
||||
"claude-opus-4-8[1m]",
|
||||
]);
|
||||
const custom = modelOption.options.find((o: any) => o.value === "claude-opus-4-8[1m]");
|
||||
expect(custom.name).toBe("Opus 4.8");
|
||||
expect(custom.description).toBe("Claude Opus 4.8");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
} else {
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate the custom model option when also in the allowlist", async () => {
|
||||
// If the user lists the custom model's exact ID in availableModels AND it
|
||||
// is set as ANTHROPIC_CUSTOM_MODEL_OPTION, it must appear exactly once.
|
||||
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["sonnet", "claude-opus-4-8[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
{
|
||||
value: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"sonnet",
|
||||
"claude-opus-4-8[1m]",
|
||||
]);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
} else {
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ndJsonStream,
|
||||
NewSessionResponse,
|
||||
ReadTextFileRequest,
|
||||
@@ -33,10 +35,16 @@ import {
|
||||
claudeCliPath,
|
||||
describeAlwaysAllow,
|
||||
streamEventToAcpNotifications,
|
||||
messageIdForGrouping,
|
||||
type SDKMessageFilter,
|
||||
} from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { deleteSession, query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
deleteSession,
|
||||
getSessionMessages,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
@@ -83,6 +91,10 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
agent: Agent;
|
||||
files: Map<string, string> = new Map();
|
||||
receivedText: string = "";
|
||||
// Records for the AskUserQuestion elicitation test.
|
||||
elicitations: CreateElicitationRequest[] = [];
|
||||
permissionToolInputs: unknown[] = [];
|
||||
chosenAnswers: Record<string, string | string[]> = {};
|
||||
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
|
||||
availableCommandsPromise: Promise<AvailableCommand[]>;
|
||||
|
||||
@@ -101,11 +113,39 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
}
|
||||
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Record what asked for permission so a test can assert that
|
||||
// AskUserQuestion did NOT fall back to a generic permission prompt.
|
||||
this.permissionToolInputs.push(params.toolCall?.rawInput);
|
||||
const optionId = params.options.find((p) => p.kind === "allow_once")!.optionId;
|
||||
|
||||
return { outcome: { outcome: "selected", optionId } };
|
||||
}
|
||||
|
||||
async unstable_createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
this.elicitations.push(params);
|
||||
if (params.mode !== "form") {
|
||||
return { action: "decline" };
|
||||
}
|
||||
// Accept the first option of every choice field (skip the free-text one).
|
||||
const content: Record<string, string | string[]> = {};
|
||||
for (const [key, prop] of Object.entries(params.requestedSchema.properties ?? {})) {
|
||||
if (key === "customAnswer") continue;
|
||||
const p = prop as {
|
||||
oneOf?: Array<{ const: string }>;
|
||||
items?: { anyOf?: Array<{ const: string }> };
|
||||
};
|
||||
if (p.oneOf?.length) {
|
||||
content[key] = p.oneOf[0].const;
|
||||
} else if (p.items?.anyOf?.length) {
|
||||
content[key] = [p.items.anyOf[0].const];
|
||||
}
|
||||
}
|
||||
this.chosenAnswers = content;
|
||||
return { action: "accept", content };
|
||||
}
|
||||
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
console.error("RECEIVED", JSON.stringify(params, null, 4));
|
||||
|
||||
@@ -158,6 +198,9 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
readTextFile: true,
|
||||
writeTextFile: true,
|
||||
},
|
||||
elicitation: {
|
||||
form: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -170,7 +213,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
}
|
||||
|
||||
it("should connect to the ACP subprocess", async () => {
|
||||
const { client, connection, newSessionResponse } = await setupTestSession("./");
|
||||
const { client, connection, newSessionResponse } = await setupTestSession(process.cwd());
|
||||
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
@@ -261,6 +304,60 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
|
||||
expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed.");
|
||||
}, 60000);
|
||||
|
||||
// Regression guard for the SDK's AskUserQuestion routing. The built-in
|
||||
// AskUserQuestion tool is delivered to us through `canUseTool` (not the
|
||||
// interactive `onUserDialog` path), where we intercept it and render an ACP
|
||||
// form elicitation, returning the answer via `updatedInput`. If a future SDK
|
||||
// changes that routing — e.g. stops calling `canUseTool` for it, or no longer
|
||||
// reads answers back from `updatedInput` — this test fails: either no
|
||||
// elicitation arrives, the tool falls back to a permission prompt, or the
|
||||
// answer never reaches the model's reply.
|
||||
it("routes AskUserQuestion through ACP form elicitation and round-trips the answer", async () => {
|
||||
const { client, connection, newSessionResponse } = await setupTestSession(process.cwd());
|
||||
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Use the AskUserQuestion tool right now to ask me to choose a favorite color. " +
|
||||
"Offer exactly two options: 'Red' and 'Blue'. Do not use any other tool and do " +
|
||||
"not ask in plain text. After I answer, reply with one short sentence naming the " +
|
||||
"color I picked.",
|
||||
},
|
||||
],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
// The tool surfaced as an ACP form elicitation...
|
||||
expect(client.elicitations.length).toBeGreaterThan(0);
|
||||
const elicitation = client.elicitations[0];
|
||||
expect(elicitation.mode).toBe("form");
|
||||
|
||||
// ...built by our converter (indexed field key + free-text "Other" field),
|
||||
// which confirms our interception path produced it rather than some other
|
||||
// mechanism.
|
||||
const properties =
|
||||
elicitation.mode === "form" ? Object.keys(elicitation.requestedSchema.properties ?? {}) : [];
|
||||
expect(properties).toContain("question_0");
|
||||
expect(properties).toContain("customAnswer");
|
||||
|
||||
// AskUserQuestion must NOT fall back to a generic permission prompt: no
|
||||
// permission request should have carried AskUserQuestion's `questions`.
|
||||
const fellBackToPermission = client.permissionToolInputs.some(
|
||||
(input) =>
|
||||
!!input &&
|
||||
typeof input === "object" &&
|
||||
Array.isArray((input as { questions?: unknown }).questions),
|
||||
);
|
||||
expect(fellBackToPermission).toBe(false);
|
||||
|
||||
// The chosen answer round-trips: the model's reply names the picked option.
|
||||
const picked = String(Object.values(client.chosenAnswers)[0] ?? "");
|
||||
expect(picked).not.toEqual("");
|
||||
expect(client.takeReceivedText().toLowerCase()).toContain(picked.toLowerCase());
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("tool conversions", () => {
|
||||
@@ -1318,6 +1415,67 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
||||
const { value } = await q.next();
|
||||
expect(value).toMatchObject({ type: "system", session_id: sessionId });
|
||||
}, 10000);
|
||||
|
||||
// Pins the SDK invariant our `messageId` plumbing relies on: the Anthropic
|
||||
// API message id is available at `message_start` (before any delta), is the
|
||||
// same on the consolidated assistant message, and is recoverable from the
|
||||
// persisted transcript — so a turn keeps one stable id across streaming and
|
||||
// replay. The per-`stream_event` uuid is NOT used because it is unique per
|
||||
// event and never persisted; this test would fail if a future SDK regressed
|
||||
// any of those properties.
|
||||
it("uses the API message id as a stable anchor across streaming and replay", async () => {
|
||||
const sessionId = randomUUID();
|
||||
const q = query({
|
||||
prompt: "Reply with exactly these words and nothing else: hello there my friend",
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId,
|
||||
includePartialMessages: true,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
},
|
||||
});
|
||||
|
||||
let messageStartApiId: string | undefined;
|
||||
let consolidatedApiId: string | undefined;
|
||||
let sawDelta = false;
|
||||
let allPartialsTopLevel = true;
|
||||
|
||||
for await (const message of q) {
|
||||
if (message.type === "assistant") {
|
||||
consolidatedApiId = message.message.id;
|
||||
}
|
||||
if (message.type !== "stream_event") continue;
|
||||
// Every streaming partial must belong to the top-level agent
|
||||
// (parent_tool_use_id === null). Subagent work is folded into tool-result
|
||||
// messages rather than surfaced as partial streams, which is what lets us
|
||||
// track a single anchor without keying by parent_tool_use_id.
|
||||
if (message.parent_tool_use_id !== null) allPartialsTopLevel = false;
|
||||
if (message.event.type === "message_start") {
|
||||
messageStartApiId = message.event.message.id;
|
||||
} else if (message.event.type === "content_block_delta") {
|
||||
sawDelta = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The API message id is present at message_start (before deltas), so we can
|
||||
// tag every streamed chunk with it, and it is identical on the consolidated
|
||||
// assistant message.
|
||||
expect(messageStartApiId).toBeTruthy();
|
||||
expect(sawDelta).toBe(true);
|
||||
expect(allPartialsTopLevel).toBe(true);
|
||||
expect(consolidatedApiId).toBe(messageStartApiId);
|
||||
|
||||
// ...and the SAME id is recoverable from the persisted transcript, so chunks
|
||||
// grouped live keep their id when the session is replayed.
|
||||
const persisted = await getSessionMessages(sessionId);
|
||||
const replayedAssistant = persisted.find((m) => m.type === "assistant");
|
||||
expect(replayedAssistant).toBeDefined();
|
||||
expect((replayedAssistant!.message as { id?: string }).id).toBe(messageStartApiId);
|
||||
// The helper used in production must derive that same id from the replayed
|
||||
// message.
|
||||
expect(messageIdForGrouping(replayedAssistant!)).toBe(messageStartApiId);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("permission requests", () => {
|
||||
@@ -1548,6 +1706,8 @@ describe("stop reason propagation", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1692,6 +1852,8 @@ describe("stop reason propagation", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1851,6 +2013,8 @@ describe("session/close", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -1935,6 +2099,8 @@ describe("session/delete", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -1948,7 +2114,7 @@ describe("session/delete", () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "session-1");
|
||||
|
||||
const result = await agent.unstable_deleteSession({ sessionId: "session-1" });
|
||||
const result = await agent.deleteSession({ sessionId: "session-1" });
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(agent.sessions["session-1"]).toBeUndefined();
|
||||
@@ -1961,7 +2127,7 @@ describe("session/delete", () => {
|
||||
it("deletes a session from disk that is not currently active", async () => {
|
||||
const agent = createMockAgent();
|
||||
|
||||
const result = await agent.unstable_deleteSession({ sessionId: "not-active" });
|
||||
const result = await agent.deleteSession({ sessionId: "not-active" });
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(deleteSession).toHaveBeenCalledWith("not-active");
|
||||
@@ -1971,7 +2137,7 @@ describe("session/delete", () => {
|
||||
const agent = createMockAgent();
|
||||
vi.mocked(deleteSession).mockRejectedValueOnce(new Error("Session not found on disk"));
|
||||
|
||||
await expect(agent.unstable_deleteSession({ sessionId: "missing" })).rejects.toThrow(
|
||||
await expect(agent.deleteSession({ sessionId: "missing" })).rejects.toThrow(
|
||||
"Session not found on disk",
|
||||
);
|
||||
});
|
||||
@@ -1981,7 +2147,7 @@ describe("session/delete", () => {
|
||||
injectSession(agent, "session-a");
|
||||
injectSession(agent, "session-b");
|
||||
|
||||
await agent.unstable_deleteSession({ sessionId: "session-a" });
|
||||
await agent.deleteSession({ sessionId: "session-a" });
|
||||
|
||||
expect(agent.sessions["session-a"]).toBeUndefined();
|
||||
expect(agent.sessions["session-b"]).toBeDefined();
|
||||
@@ -2036,6 +2202,8 @@ describe("getOrCreateSession param change detection", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -2271,6 +2439,8 @@ describe("usage_update computation", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3108,6 +3278,309 @@ describe("usage_update computation", () => {
|
||||
// size should be 1000000 (Opus), not 200000 (the fallback if <synthetic> overrode the model)
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("compact_boundary uses authoritative getContextUsage for used, keeps session window for size", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
{ type: "system", subtype: "compact_boundary", session_id: "test-session" },
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
const session = agent.sessions["test-session"];
|
||||
// A 1M window learned earlier (e.g. from modelUsage) must survive compaction
|
||||
// — getContextUsage's window field under-reports it, so we don't use it.
|
||||
session.contextWindowSize = 1000000;
|
||||
(session.query as any).getContextUsage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ totalTokens: 12345, rawMaxTokens: 200000 });
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
expect(usageUpdate.update.used).toBe(12345);
|
||||
// size stays at the session's learned window, NOT getContextUsage's value.
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
expect(session.contextWindowSize).toBe(1000000);
|
||||
});
|
||||
|
||||
it("compact_boundary falls back to used:0 when getContextUsage fails", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
{ type: "system", subtype: "compact_boundary", session_id: "test-session" },
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
const session = agent.sessions["test-session"];
|
||||
session.contextWindowSize = 200000;
|
||||
(session.query as any).getContextUsage = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
expect(usageUpdate.update.used).toBe(0);
|
||||
expect(usageUpdate.update.size).toBe(200000);
|
||||
expect(session.contextWindowSize).toBe(200000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembled assistant text fallback", () => {
|
||||
const ZERO_USAGE = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
};
|
||||
|
||||
function createMockAgentWithCapture() {
|
||||
const updates: any[] = [];
|
||||
const mockClient = {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates };
|
||||
}
|
||||
|
||||
function messageStart(apiId: string) {
|
||||
return {
|
||||
type: "stream_event" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: {
|
||||
type: "message_start" as const,
|
||||
message: { id: apiId, model: "claude-sonnet-4-20250514", usage: ZERO_USAGE },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function textDelta(text: string) {
|
||||
return {
|
||||
type: "stream_event" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: {
|
||||
type: "content_block_delta" as const,
|
||||
index: 0,
|
||||
delta: { type: "text_delta" as const, text },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(apiId: string, content: any[], parentToolUseId: string | null = null) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
id: apiId,
|
||||
role: "assistant" as const,
|
||||
model: "claude-sonnet-4-20250514",
|
||||
content,
|
||||
usage: ZERO_USAGE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function result() {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: ZERO_USAGE,
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
const idle = { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
|
||||
const input = new Pushable<any>();
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage, done } = await iter.next();
|
||||
if (!done && userMessage) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
yield* messages;
|
||||
}
|
||||
agent.sessions["test-session"] = {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function messageChunkTexts(updates: any[]): string[] {
|
||||
return updates
|
||||
.filter((u) => u.update?.sessionUpdate === "agent_message_chunk")
|
||||
.map((u) => u.update.content.text);
|
||||
}
|
||||
|
||||
function thoughtChunkTexts(updates: any[]): string[] {
|
||||
return updates
|
||||
.filter((u) => u.update?.sessionUpdate === "agent_thought_chunk")
|
||||
.map((u) => u.update.content.text);
|
||||
}
|
||||
|
||||
it("emits the assembled text when no content_block_delta was streamed", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Gateway delivers a fully assembled message with no preceding deltas.
|
||||
injectSession(agent, [
|
||||
assistantMessage("msg-no-stream", [{ type: "text", text: "the final answer" }]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
expect(messageChunkTexts(updates)).toEqual(["the final answer"]);
|
||||
});
|
||||
|
||||
it("does not re-emit text already streamed via content_block_delta", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Normal streaming: deltas arrive, then the consolidated message repeats them.
|
||||
injectSession(agent, [
|
||||
messageStart("msg-streamed"),
|
||||
textDelta("hello "),
|
||||
textDelta("world"),
|
||||
assistantMessage("msg-streamed", [{ type: "text", text: "hello world" }]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
// Only the two streamed deltas — the assembled block is filtered out.
|
||||
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
|
||||
});
|
||||
|
||||
it("dedupes per block type: streamed text is dropped but an un-streamed thinking block in the same message is forwarded", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Gateway streams the text live but delivers the thinking block only in the
|
||||
// assembled message (no thinking_delta). The dedupe must be per-type so the
|
||||
// thinking survives. This also makes the test non-vacuous: if the fallback
|
||||
// were removed (text/thinking always dropped) the thought chunk disappears.
|
||||
injectSession(agent, [
|
||||
messageStart("msg-mixed"),
|
||||
textDelta("streamed text"),
|
||||
assistantMessage("msg-mixed", [
|
||||
{ type: "text", text: "streamed text" },
|
||||
{ type: "thinking", thinking: "private reasoning" },
|
||||
]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
// Streamed text appears once (delta only — assembled copy deduped).
|
||||
expect(messageChunkTexts(updates)).toEqual(["streamed text"]);
|
||||
// The un-streamed thinking block is forwarded despite text having streamed.
|
||||
expect(thoughtChunkTexts(updates)).toEqual(["private reasoning"]);
|
||||
});
|
||||
|
||||
it("does not leak subagent assistant text into the top-level feed", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Subagent assistant messages (parent_tool_use_id !== null) are never
|
||||
// streamed live; their text/thinking is internal to the tool call and must
|
||||
// stay filtered out, not surface as a fallback chunk.
|
||||
injectSession(agent, [
|
||||
assistantMessage(
|
||||
"msg-subagent",
|
||||
[{ type: "text", text: "subagent internal prose" }],
|
||||
"tool_use_1",
|
||||
),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
expect(messageChunkTexts(updates)).toEqual([]);
|
||||
expect(thoughtChunkTexts(updates)).toEqual([]);
|
||||
});
|
||||
|
||||
it("forwards distinct blocks that a gateway splits across same-id messages", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Observed with OpenAI-compatible gateways: one response id split into an
|
||||
// empty thinking block, then the real text — both with no deltas.
|
||||
injectSession(agent, [
|
||||
assistantMessage("msg-split", [{ type: "thinking", thinking: "" }]),
|
||||
assistantMessage("msg-split", [{ type: "text", text: "the real answer" }]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
// The text survives even though an earlier same-id message already triggered
|
||||
// the fallback for a different (thinking) block.
|
||||
expect(messageChunkTexts(updates)).toEqual(["the real answer"]);
|
||||
// The empty thinking block carries nothing and must not produce a stray
|
||||
// empty thought chunk.
|
||||
expect(thoughtChunkTexts(updates)).toEqual([]);
|
||||
});
|
||||
|
||||
it("re-forwards a block a gateway re-delivers (no content-keyed dedupe)", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// The fallback intentionally keys only on whether the id streamed live, not
|
||||
// on block content — so a gateway re-delivering the same assembled block
|
||||
// emits it twice. This is the accepted, cosmetic tradeoff for not caching
|
||||
// every fallback block's full text; see `streamedTextMessageIds`.
|
||||
injectSession(agent, [
|
||||
assistantMessage("msg-dup", [{ type: "text", text: "answer" }]),
|
||||
assistantMessage("msg-dup", [{ type: "text", text: "answer" }]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
expect(messageChunkTexts(updates)).toEqual(["answer", "answer"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitRawSDKMessages", () => {
|
||||
@@ -3171,6 +3644,8 @@ describe("emitRawSDKMessages", () => {
|
||||
emitRawSDKMessages,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3399,6 +3874,8 @@ describe("result origin handling", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3574,6 +4051,8 @@ describe("memory_recall handling", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3804,6 +4283,8 @@ describe("post-error recovery", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return { interrupt };
|
||||
}
|
||||
@@ -3949,6 +4430,8 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return { interrupt };
|
||||
}
|
||||
@@ -4088,4 +4571,166 @@ describe("streamEventToAcpNotifications", () => {
|
||||
expect(result).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("attaches the supplied messageId to streamed text chunks", () => {
|
||||
const messageId = randomUUID();
|
||||
const message = {
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "hello" },
|
||||
},
|
||||
} as Parameters<typeof streamEventToAcpNotifications>[0];
|
||||
|
||||
const result = streamEventToAcpNotifications(
|
||||
message,
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
{ messageId },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
sessionId: "test",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "hello" },
|
||||
messageId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications messageId", () => {
|
||||
const messageId = "11111111-2222-3333-4444-555555555555";
|
||||
|
||||
it("sets messageId on agent message chunks from string content", () => {
|
||||
const result = toAcpNotifications(
|
||||
"hello world",
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
{ messageId },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
sessionId: "test",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "hello world" },
|
||||
messageId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets messageId on user message chunks and thought chunks", () => {
|
||||
const userResult = toAcpNotifications(
|
||||
[{ type: "text", text: "hi" }],
|
||||
"user",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
{ messageId },
|
||||
);
|
||||
expect(userResult[0].update).toMatchObject({
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId,
|
||||
});
|
||||
|
||||
const thoughtResult = toAcpNotifications(
|
||||
[{ type: "thinking", thinking: "hmm", signature: "" }],
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
{ messageId },
|
||||
);
|
||||
expect(thoughtResult[0].update).toMatchObject({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits messageId when none is supplied", () => {
|
||||
const result = toAcpNotifications(
|
||||
"hello",
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
);
|
||||
expect(result[0].update).not.toHaveProperty("messageId");
|
||||
});
|
||||
|
||||
it("never sets messageId on non-chunk updates (tool_call)", () => {
|
||||
const result = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_abc",
|
||||
name: "Read",
|
||||
input: { file_path: "/tmp/x" },
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
{ messageId, registerHooks: false },
|
||||
);
|
||||
expect(result[0].update.sessionUpdate).toBe("tool_call");
|
||||
expect(result[0].update).not.toHaveProperty("messageId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageIdForGrouping", () => {
|
||||
it("uses the Anthropic API message id for assistant messages", () => {
|
||||
const message = {
|
||||
type: "assistant",
|
||||
uuid: "de242400-cdb3-4af7-9856-d3b114b20af9",
|
||||
message: { id: "msg_018DQGVuZbGYwVnvDakAP9Do", role: "assistant" },
|
||||
};
|
||||
// The API id is identical at message_start, on the consolidated message,
|
||||
// and in the persisted transcript — so it stays stable across replay,
|
||||
// unlike the per-message uuid.
|
||||
expect(messageIdForGrouping(message)).toBe("msg_018DQGVuZbGYwVnvDakAP9Do");
|
||||
});
|
||||
|
||||
it("falls back to the uuid for assistant messages without an API id", () => {
|
||||
const message = {
|
||||
type: "assistant",
|
||||
uuid: "de242400-cdb3-4af7-9856-d3b114b20af9",
|
||||
message: { role: "assistant" },
|
||||
};
|
||||
expect(messageIdForGrouping(message)).toBe("de242400-cdb3-4af7-9856-d3b114b20af9");
|
||||
});
|
||||
|
||||
it("uses the uuid for user messages (they carry no API id and aren't streamed)", () => {
|
||||
const message = {
|
||||
type: "user",
|
||||
uuid: "11111111-2222-3333-4444-555555555555",
|
||||
message: { id: "msg_should_be_ignored", role: "user" },
|
||||
};
|
||||
expect(messageIdForGrouping(message)).toBe("11111111-2222-3333-4444-555555555555");
|
||||
});
|
||||
|
||||
it("returns undefined when there is no usable id", () => {
|
||||
expect(messageIdForGrouping({ type: "system", message: {} })).toBeUndefined();
|
||||
expect(messageIdForGrouping({ type: "assistant", uuid: "", message: {} })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ vi.mock("../tools.js", async () => ({
|
||||
describe("additionalRoots", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
const tempDirs: string[] = [];
|
||||
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
|
||||
const newSession = (meta: Record<string, unknown>, cwd = process.cwd()) =>
|
||||
agent.newSession({ cwd, mcpServers: [], _meta: meta });
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -79,7 +79,7 @@ describe("additionalRoots", () => {
|
||||
|
||||
it("prefers the official ACP additionalDirectories field over _meta.additionalRoots", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
@@ -89,7 +89,7 @@ describe("additionalRoots", () => {
|
||||
|
||||
it("merges official ACP additionalDirectories with claudeCode SDK additionalDirectories", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { claudeCode: { options: { additionalDirectories: ["/from/sdk"] } } },
|
||||
@@ -99,7 +99,7 @@ describe("additionalRoots", () => {
|
||||
|
||||
it("falls back to _meta.additionalRoots when the official field is omitted", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
});
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -148,7 +148,7 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
@@ -65,7 +68,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -85,7 +88,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("works when user provides no disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -94,7 +97,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("works when user provides empty disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -110,7 +113,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("sets tools to empty array when disableBuiltInTools is true", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
@@ -133,7 +136,7 @@ describe("createSession options merging", () => {
|
||||
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -155,7 +158,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("inherits HOME and PATH from process.env when no env is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -165,7 +168,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("merges user-provided env vars on top of process.env", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -185,7 +188,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("allows user-provided env vars to override process.env entries", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -203,7 +206,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("defaults tools to claude_code preset when not provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -212,7 +215,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("passes through user-provided tools string array", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -228,7 +231,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
@@ -245,7 +248,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("passes through empty tools array to disable all built-in tools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -261,7 +264,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
describe("systemPrompt via _meta", () => {
|
||||
it("defaults to the claude_code preset when not provided", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
@@ -271,7 +274,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("replaces the preset when a string is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: "custom prompt" },
|
||||
});
|
||||
@@ -281,7 +284,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards append", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { append: "extra instructions" } },
|
||||
});
|
||||
@@ -295,7 +298,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards excludeDynamicSections", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { excludeDynamicSections: true } },
|
||||
});
|
||||
@@ -309,7 +312,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards append and excludeDynamicSections together", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
@@ -329,7 +332,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("ignores caller-provided type/preset overrides", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
@@ -369,7 +372,7 @@ describe("createSession options merging", () => {
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
@@ -381,7 +384,7 @@ describe("createSession options merging", () => {
|
||||
availableModels: ["opus", "sonnet"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
availableModels: ["opus", "sonnet"],
|
||||
@@ -394,7 +397,7 @@ describe("createSession options merging", () => {
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
@@ -403,7 +406,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
it("does not add settings when env var is not set", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toBeUndefined();
|
||||
});
|
||||
@@ -414,7 +417,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -438,13 +441,13 @@ describe("createSession options merging", () => {
|
||||
it("throws on invalid JSON", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = "not-json";
|
||||
|
||||
await expect(agent.newSession({ cwd: "/test", mcpServers: [] })).rejects.toThrow();
|
||||
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [
|
||||
{
|
||||
name: "acp-server",
|
||||
@@ -473,4 +476,140 @@ describe("createSession options merging", () => {
|
||||
// ACP-provided MCP server should also be present
|
||||
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
|
||||
});
|
||||
|
||||
describe("thinking config from MAX_THINKING_TOKENS", () => {
|
||||
let originalMaxThinking: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalMaxThinking = process.env.MAX_THINKING_TOKENS;
|
||||
delete process.env.MAX_THINKING_TOKENS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalMaxThinking !== undefined) {
|
||||
process.env.MAX_THINKING_TOKENS = originalMaxThinking;
|
||||
} else {
|
||||
delete process.env.MAX_THINKING_TOKENS;
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves thinking unset (SDK default) when env var is absent", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
// The deprecated option must not be set either.
|
||||
expect(capturedOptions!.maxThinkingTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps 0 to disabled thinking", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "0";
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "disabled" });
|
||||
});
|
||||
|
||||
it("maps a positive value to a fixed thinking budget", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "12000";
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "enabled", budgetTokens: 12000 });
|
||||
});
|
||||
|
||||
it("ignores a non-numeric value", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "lots";
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets a user-provided thinking option override the env default", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "12000";
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
thinking: { type: "adaptive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cwd validation", () => {
|
||||
it("rejects a relative cwd with invalidParams", async () => {
|
||||
await expect(
|
||||
agent.newSession({ cwd: "relative/path", mcpServers: [] }),
|
||||
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
|
||||
});
|
||||
|
||||
it("rejects a non-existent cwd with invalidParams", async () => {
|
||||
const missing = path.join(os.tmpdir(), "claude-acp-does-not-exist-xyz");
|
||||
await expect(agent.newSession({ cwd: missing, mcpServers: [] })).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a cwd that points at a file with invalidParams", async () => {
|
||||
const file = path.join(os.tmpdir(), "claude-acp-cwd-is-a-file.txt");
|
||||
fs.writeFileSync(file, "not a directory");
|
||||
try {
|
||||
await expect(agent.newSession({ cwd: file, mcpServers: [] })).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an existing absolute directory", async () => {
|
||||
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("elicitation", () => {
|
||||
it("keeps AskUserQuestion disabled and omits callbacks without elicitation capability", async () => {
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
expect(capturedOptions!.onElicitation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables AskUserQuestion and wires the elicitation callback when form is supported", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
||||
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
||||
});
|
||||
|
||||
it("wires callbacks for url-only elicitation but keeps AskUserQuestion disabled", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { url: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
||||
});
|
||||
|
||||
it("still merges user-provided disallowedTools when AskUserQuestion is enabled", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { disallowedTools: ["WebSearch"] } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
|
||||
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
309
src/tests/elicitation.test.ts
Normal file
309
src/tests/elicitation.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CreateElicitationRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk";
|
||||
import type { ElicitationRequest } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
applyAskElicitationResponse,
|
||||
askUserQuestionsToCreateRequest,
|
||||
AskUserQuestion,
|
||||
createElicitationResponseToElicitResult,
|
||||
extractAskUserQuestions,
|
||||
mcpElicitationToCreateRequest,
|
||||
} from "../elicitation.js";
|
||||
|
||||
const SESSION_ID = "session-1";
|
||||
|
||||
/**
|
||||
* Build a question matching the SDK's (strict) AskUserQuestion schema while
|
||||
* keeping test sites terse. Option descriptions default to "" (the tool always
|
||||
* provides one); pass a description to exercise the "label — description" title.
|
||||
*/
|
||||
function mkQuestion(
|
||||
question: string,
|
||||
options: Array<{ label: string; description?: string }>,
|
||||
opts: { header?: string; multiSelect?: boolean } = {},
|
||||
): AskUserQuestion {
|
||||
return {
|
||||
question,
|
||||
header: opts.header ?? "",
|
||||
multiSelect: opts.multiSelect ?? false,
|
||||
options: options.map((o) => ({ label: o.label, description: o.description ?? "" })),
|
||||
} as AskUserQuestion;
|
||||
}
|
||||
|
||||
describe("mcpElicitationToCreateRequest", () => {
|
||||
it("maps a form-mode request, defaulting the schema type to object", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Need your name",
|
||||
mode: "form",
|
||||
requestedSchema: { properties: { name: { type: "string" } } },
|
||||
};
|
||||
|
||||
const result = mcpElicitationToCreateRequest(request, SESSION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
mode: "form",
|
||||
sessionId: SESSION_ID,
|
||||
message: "Need your name",
|
||||
requestedSchema: { type: "object", properties: { name: { type: "string" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a url-mode request and preserves the elicitation id", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Authorize",
|
||||
mode: "url",
|
||||
url: "https://example.com/auth",
|
||||
elicitationId: "el-123",
|
||||
};
|
||||
|
||||
expect(mcpElicitationToCreateRequest(request, SESSION_ID)).toEqual({
|
||||
mode: "url",
|
||||
sessionId: SESSION_ID,
|
||||
message: "Authorize",
|
||||
url: "https://example.com/auth",
|
||||
elicitationId: "el-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("generates an elicitation id when a url request omits one", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Authorize",
|
||||
mode: "url",
|
||||
url: "https://example.com/auth",
|
||||
};
|
||||
|
||||
const result = mcpElicitationToCreateRequest(request, SESSION_ID);
|
||||
expect(result?.mode).toBe("url");
|
||||
expect((result as { elicitationId: string }).elicitationId).toMatch(/.+/);
|
||||
});
|
||||
|
||||
it("returns null for a url request with no url", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Authorize",
|
||||
mode: "url",
|
||||
};
|
||||
|
||||
expect(mcpElicitationToCreateRequest(request, SESSION_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createElicitationResponseToElicitResult", () => {
|
||||
it("maps accept with content", () => {
|
||||
const response = { action: "accept", content: { name: "Ada" } } as CreateElicitationResponse;
|
||||
expect(createElicitationResponseToElicitResult(response)).toEqual({
|
||||
action: "accept",
|
||||
content: { name: "Ada" },
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults accept content to an empty object", () => {
|
||||
const response = { action: "accept" } as CreateElicitationResponse;
|
||||
expect(createElicitationResponseToElicitResult(response)).toEqual({
|
||||
action: "accept",
|
||||
content: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps decline and cancel", () => {
|
||||
expect(
|
||||
createElicitationResponseToElicitResult({ action: "decline" } as CreateElicitationResponse),
|
||||
).toEqual({ action: "decline" });
|
||||
expect(
|
||||
createElicitationResponseToElicitResult({ action: "cancel" } as CreateElicitationResponse),
|
||||
).toEqual({ action: "cancel" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAskUserQuestions", () => {
|
||||
const question = mkQuestion("Which?", [{ label: "A" }, { label: "B" }]);
|
||||
|
||||
it("reads questions off the tool input", () => {
|
||||
expect(extractAskUserQuestions({ questions: [question] })).toEqual([question]);
|
||||
});
|
||||
|
||||
it("returns null when there are no questions", () => {
|
||||
expect(extractAskUserQuestions({})).toBeNull();
|
||||
expect(extractAskUserQuestions({ questions: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("filters out malformed questions (missing options, empty options, bad question)", () => {
|
||||
const input = {
|
||||
questions: [
|
||||
question,
|
||||
{ question: "missing options" },
|
||||
{ options: [] },
|
||||
{ options: [], question: "empty" },
|
||||
],
|
||||
} as unknown as Record<string, unknown>;
|
||||
expect(extractAskUserQuestions(input)).toEqual([question]);
|
||||
});
|
||||
|
||||
it("returns null when every question is filtered out", () => {
|
||||
const input = {
|
||||
questions: [{ question: "no options" }, { options: [{ label: "A" }] }],
|
||||
} as unknown as Record<string, unknown>;
|
||||
expect(extractAskUserQuestions(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("askUserQuestionsToCreateRequest", () => {
|
||||
it("keys a single-select question by a stable id and carries the prompt in message only", () => {
|
||||
const questions = [
|
||||
mkQuestion(
|
||||
"Which library?",
|
||||
[{ label: "date-fns", description: "Lightweight" }, { label: "luxon" }],
|
||||
{
|
||||
header: "Library",
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const result = askUserQuestionsToCreateRequest(questions, SESSION_ID, "tool-1");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mode: "form",
|
||||
sessionId: SESSION_ID,
|
||||
toolCallId: "tool-1",
|
||||
message: "Which library?",
|
||||
});
|
||||
const schema = (result as Extract<CreateElicitationRequest, { mode: "form" }>).requestedSchema;
|
||||
// Nothing is required; the user can pick, type a custom answer, or skip.
|
||||
expect(schema.required).toBeUndefined();
|
||||
// Single question → no duplicated description (prompt is in `message`).
|
||||
expect(schema.properties?.["question_0"]).toEqual({
|
||||
type: "string",
|
||||
title: "Library",
|
||||
description: undefined,
|
||||
oneOf: [
|
||||
{ const: "date-fns", title: "date-fns — Lightweight" },
|
||||
{ const: "luxon", title: "luxon" },
|
||||
],
|
||||
});
|
||||
// The full question text is not used as a property key.
|
||||
expect(schema.properties?.["Which library?"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes an optional free-text custom-answer field", () => {
|
||||
const questions = [mkQuestion("Which?", [{ label: "A" }, { label: "B" }])];
|
||||
const schema = (
|
||||
askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined) as Extract<
|
||||
CreateElicitationRequest,
|
||||
{ mode: "form" }
|
||||
>
|
||||
).requestedSchema;
|
||||
|
||||
expect(schema.properties?.["customAnswer"]).toMatchObject({ type: "string", title: "Other" });
|
||||
});
|
||||
|
||||
it("builds an array property for multi-select questions and includes per-field question text", () => {
|
||||
const questions = [
|
||||
mkQuestion("Pick one?", [{ label: "A" }, { label: "B" }]),
|
||||
mkQuestion("Which features?", [{ label: "auth" }, { label: "logging" }], {
|
||||
multiSelect: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined);
|
||||
const schema = (result as Extract<CreateElicitationRequest, { mode: "form" }>).requestedSchema;
|
||||
|
||||
expect(result).not.toHaveProperty("toolCallId");
|
||||
// Multiple questions → generic message, each field carries its own question.
|
||||
expect(result.message).toBe("Please answer the following questions.");
|
||||
expect(schema.properties?.["question_1"]).toEqual({
|
||||
type: "array",
|
||||
title: undefined,
|
||||
description: "Which features?",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: "auth", title: "auth" },
|
||||
{ const: "logging", title: "logging" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAskElicitationResponse", () => {
|
||||
const questions = [
|
||||
mkQuestion("Single?", [{ label: "A" }, { label: "B" }]),
|
||||
mkQuestion("Multi?", [{ label: "X" }, { label: "Y" }], { multiSelect: true }),
|
||||
];
|
||||
const toolInput = { questions, metadata: { source: "test" } };
|
||||
|
||||
it("writes selected labels (keyed by indexed fields) into updatedInput on accept", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", question_1: ["X", "Y"] },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "A", "Multi?": "X, Y" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the free-text custom-answer field to the tool's response", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", customAnswer: "something else entirely" },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "A" },
|
||||
response: "something else entirely",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores empty selections and blank custom answers", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_1: [], customAnswer: " " },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: { questions, metadata: { source: "test" }, answers: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats decline as answered with no answers", () => {
|
||||
const response = { action: "decline" } as CreateElicitationResponse;
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: { questions, metadata: { source: "test" }, answers: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancel on cancel", () => {
|
||||
const response = { action: "cancel" } as CreateElicitationResponse;
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "cancel",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits answers for questions the user left unanswered", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "B" },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
const result = applyAskElicitationResponse(response, toolInput, questions);
|
||||
expect(result.action).toBe("answered");
|
||||
expect((result as Extract<typeof result, { action: "answered" }>).updatedInput.answers).toEqual(
|
||||
{ "Single?": "B" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -148,13 +148,13 @@ describe("session config options", () => {
|
||||
|
||||
describe("newSession returns configOptions", () => {
|
||||
it("includes configOptions in the response", async () => {
|
||||
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(response.configOptions).toBeDefined();
|
||||
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
|
||||
});
|
||||
|
||||
it("includes mode and model config options", async () => {
|
||||
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const modeOption = response.configOptions?.find((o) => o.id === "mode");
|
||||
const modelOption = response.configOptions?.find((o) => o.id === "model");
|
||||
expect(modeOption).toBeDefined();
|
||||
@@ -173,7 +173,7 @@ describe("session config options", () => {
|
||||
(agent as unknown as { loadSession: typeof loadSessionSpy }).loadSession = loadSessionSpy;
|
||||
|
||||
const response = await agent.loadSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
sessionId: SESSION_ID,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { AgentSideConnection, ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
@@ -673,14 +673,19 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
|
||||
describe("toAcpNotifications with clientCapabilities", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
// Reset before each test: toAcpNotifications prunes the cache entry once it
|
||||
// maps the tool_result, so a shared object would be empty by the 2nd test.
|
||||
let toolUseCache: ToolUseCache;
|
||||
beforeEach(() => {
|
||||
toolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const bashResult: BetaBashCodeExecutionResultBlock = {
|
||||
type: "bash_code_execution_result",
|
||||
@@ -792,11 +797,21 @@ describe("Bash terminal output", () => {
|
||||
{ clientCapabilities: { _meta: { terminal_output: true } } },
|
||||
);
|
||||
|
||||
// Fresh cache: the withSupport call above already consumed the entry,
|
||||
// since toAcpNotifications prunes the tool_use once it maps the result.
|
||||
const toolUseCacheWithoutSupport: ToolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
const withoutSupport = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
toolUseCacheWithoutSupport,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
@@ -848,6 +863,31 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolUseCache pruning", () => {
|
||||
it("retains the tool_use entry until its result, then prunes it", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const toolUse = {
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_read",
|
||||
name: "Read",
|
||||
input: { file_path: "/tmp/x.txt" },
|
||||
};
|
||||
|
||||
// tool_use is cached and kept (the matching result hasn't arrived yet).
|
||||
toAcpNotifications([toolUse], "assistant", "s", toolUseCache, mockClient, mockLogger);
|
||||
expect(toolUseCache.toolu_read).toBeDefined();
|
||||
|
||||
// tool_result resolves it, so the entry is pruned to bound memory.
|
||||
const toolResult: ToolResultBlockParam = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_read",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
};
|
||||
toAcpNotifications([toolResult], "assistant", "s", toolUseCache, mockClient, mockLogger);
|
||||
expect(toolUseCache.toolu_read).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-tool-use hook sends diff content for Edit tool", () => {
|
||||
it("should include content and locations from structuredPatch in hook update", async () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
|
||||
19
src/tools.ts
19
src/tools.ts
@@ -8,6 +8,7 @@ import {
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
AskUserQuestionInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
@@ -415,6 +416,24 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "AskUserQuestion": {
|
||||
const input = toolUse.input as Partial<AskUserQuestionInput> | undefined;
|
||||
const questions = Array.isArray(input?.questions) ? input.questions : [];
|
||||
return {
|
||||
title:
|
||||
questions.length === 1 && questions[0]?.question
|
||||
? questions[0].question
|
||||
: "Asking for your input",
|
||||
kind: "other",
|
||||
content: questions
|
||||
.filter((q) => typeof q?.question === "string")
|
||||
.map((q) => ({
|
||||
type: "content" as const,
|
||||
content: { type: "text" as const, text: q.question },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
case "Other": {
|
||||
const input = toolUse.input;
|
||||
let output;
|
||||
|
||||
Reference in New Issue
Block a user