mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-05 17:01:44 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d877ee7133 | ||
|
|
18516a3088 | ||
|
|
ec14211056 | ||
|
|
398f763f71 | ||
|
|
23cbe6f58f | ||
|
|
7ffb78c572 | ||
|
|
f4704c168f | ||
|
|
49d73b2ad3 | ||
|
|
cffea4be28 | ||
|
|
add7e31704 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.40.0"
|
||||
".": "0.42.0"
|
||||
}
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** upgrade to @anthropic-ai/claude-agent-sdk@0.3.162 ([#740](https://github.com/agentclientprotocol/claude-agent-acp/issues/740)) ([add7e31](https://github.com/agentclientprotocol/claude-agent-acp/commit/add7e31704581aa70febb6341ca0e8cee5d93b43))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Force cancellation when SDK query hangs ([#742](https://github.com/agentclientprotocol/claude-agent-acp/issues/742)) ([cffea4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/cffea4be28e6def91027110433a99e80b1be8a0c)), closes [#680](https://github.com/agentclientprotocol/claude-agent-acp/issues/680)
|
||||
|
||||
## [0.40.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.39.0...v0.40.0) (2026-06-02)
|
||||
|
||||
|
||||
|
||||
137
package-lock.json
generated
137
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.40.0",
|
||||
"version": "0.42.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.40.0",
|
||||
"version": "0.42.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.24.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.160",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.165",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -41,22 +41,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.160",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.160.tgz",
|
||||
"integrity": "sha512-kJjgIkb09TIhYXbOEngQV+iERhYMbxQwjciXEfVYP/Zo27tacdWYS5MaIwzKxmPW+Yd1VL2BcvCIo1ES4F1tow==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.165.tgz",
|
||||
"integrity": "sha512-wEUJNTAWkE6KMV35abqGi30lwhZz+jQLMtLh4SuTN2Hllzsysq8kmQFgcWulza3FLHG/GHzGHPi0+Sp2fb8xlw==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.160",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.160",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.160",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.160",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.160",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.160",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.160",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.160"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.165",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.165"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -65,9 +65,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.160",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.160.tgz",
|
||||
"integrity": "sha512-lGd7aySWuFO9MmxXj8plnRobUPVSXztk+EYdews2sDiC28483SLeJ4Ft086GYCaCb/ILeYAsK6aKo25R7Gl9QA==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.165.tgz",
|
||||
"integrity": "sha512-obVodJmppNc6lgcM6Y5y3VCQLrYO2curOXrRaziKtjxYbuZP7kYsUhnonMvGoVAQh3uHKz2tivQDeztvWe3f9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -78,9 +78,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.160",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.160.tgz",
|
||||
"integrity": "sha512-ck9r+zCg8mvOnEMZhwWSsizkuAW1evB+UzRBFqOlTQCij7KJK5Nw2YRTVVdp4knesM7/q82/cGGZgFtAVxq6IQ==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.165.tgz",
|
||||
"integrity": "sha512-0jc1tlYLXzPvZIkHKGHzsEEKq2YqTS8oHSNFroqLgbhrIk1Zy05ZXbciI289VDAe1Fq2a+qcUhkXct8Parx1Rg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -91,12 +91,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.160",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.160.tgz",
|
||||
"integrity": "sha512-gzC5CGG2jiSoFEfj4MPc4+QkfQbvGKj8pKN6kpH9TLJeexKnoPNzDEGwy0T/+VGGn2zQwOxIl9sh0QBb+v01DA==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.165.tgz",
|
||||
"integrity": "sha512-t87HgDPPaRYMTTB5cqA0M36Fyq4DOny89yk71BMgA8hAzhOjV9bla8pMVZTuX3xYYPjsa/TOmxSzwI8GZLf4Aw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -104,12 +107,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.160",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.160.tgz",
|
||||
"integrity": "sha512-17rq9mNH26jndqIYKkCwNoZrThj/0wJosiixuGEGPEFSzjxv4LDNfLp+Rnpt553HvLBpW8a0IWhYyOyruvpsZQ==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.165.tgz",
|
||||
"integrity": "sha512-Rccmr5chZdZJVRvoB0nildB5PTKX+amatUho9JIcNOf1iX/6ej39fwf8q9W1MRHYP7AEc4t9GrSAGLcn7/JO4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -117,12 +123,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.160",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.160.tgz",
|
||||
"integrity": "sha512-WvAuHDekEwTr8O192WOVrCpDHO+cH2a1Dgm5DQAobiPMZxD64mQ9Bbo8otlSnuN3ziPGv/RbouyJHf6JFrotMA==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.165.tgz",
|
||||
"integrity": "sha512-Y8fEW0zKBn0XZI5AOQWHep0Srz0qsCauynTWkhsC6J2vSPxkTiOxv2hmb7qdfiNlFn0k1etCWVFoRkhhFJzGfg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -130,12 +139,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.160",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.160.tgz",
|
||||
"integrity": "sha512-85FfW+Heaop8U2Xq5LxIZzunVYcua8X3XSmcWgUX3DXFiAPMfXKyKwJOUDJC5PrVqlWduDZA2K30yTl2JvapcQ==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.165.tgz",
|
||||
"integrity": "sha512-Y9Acr1RmydfEX+t+3mFn0K9VOx6nfyo08QuQH9R6ap1YYZWuobze++pNUY/rzwbQjXqcbjORtPKbO/kLQtSr9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -143,9 +155,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.160",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.160.tgz",
|
||||
"integrity": "sha512-CEpNzmazlgAl/hCaY3Hc12QxuMdvG+kOhi0V0Cp706p5zUiHUojX/e0ahqURn4yBfTddLoAlGAq0VzlMriwsgA==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.165.tgz",
|
||||
"integrity": "sha512-4Q01L3xaDDCvlOhABf2MnO7v7yJxKwwDyiMr+DaneUSvuh1qH0YE7qErSYLf6D9VfH8TdRwKZXwQplVVwCoHWw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -156,9 +168,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.160",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.160.tgz",
|
||||
"integrity": "sha512-EVjDyTUem5xAjbPfYu/uBAeCK/qrZNjociW9u8wMvwxF1f0mmXTH+CdCVYeQ78RZBTG5SxV+OXAsgRX/OeWxOQ==",
|
||||
"version": "0.3.165",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.165.tgz",
|
||||
"integrity": "sha512-Y0uOx7b7ZnkguvFFI5T5fSLnRA/e0uvMC++gSnyz6XMpNekgWc3+Mny7Dv2NO22nKbV2YiFsj6MkYYFEd51BDw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -630,6 +642,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -647,6 +662,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -664,6 +682,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -681,6 +702,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -698,6 +722,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -715,6 +742,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -926,7 +956,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@@ -2661,6 +2691,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2682,6 +2715,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2703,6 +2739,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2724,6 +2763,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2954,15 +2996,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.2",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
|
||||
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
|
||||
"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",
|
||||
@@ -3304,9 +3349,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.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
||||
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.40.0",
|
||||
"version": "0.42.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -61,7 +61,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.24.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.160",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.165",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
526
src/acp-agent.ts
526
src/acp-agent.ts
@@ -36,8 +36,6 @@ import {
|
||||
CloseSessionResponse,
|
||||
DeleteSessionRequest,
|
||||
DeleteSessionResponse,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
StopReason,
|
||||
@@ -57,10 +55,12 @@ import {
|
||||
query,
|
||||
Settings,
|
||||
SDKAssistantMessageError,
|
||||
SDKMessage,
|
||||
SDKMessageOrigin,
|
||||
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";
|
||||
@@ -134,6 +134,17 @@ const ZERO_USAGE = Object.freeze({
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
|
||||
/** Floor after `session/cancel` before the adapter forces the active prompt
|
||||
* loop to return "cancelled". `query.interrupt()` normally makes the SDK
|
||||
* yield a trailing idle within milliseconds, and the loop returns through its
|
||||
* usual path — so this timer is armed and cleared, never fired, on healthy
|
||||
* cancels. It only trips when the SDK is genuinely wedged (e.g. a
|
||||
* `TaskOutput { block: true }` poll against a hung background task — issue
|
||||
* #680) and never yields. The value is deliberately loose: it's an
|
||||
* "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
|
||||
* pre-empt a slow-but-healthy interrupt. */
|
||||
const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
|
||||
|
||||
/** Internal model-selection state. Mirrors the shape the ACP SDK exposed as
|
||||
* `SessionModelState` before model selection moved entirely into
|
||||
* `SessionConfigOption` (category "model"). Retained internally to track the
|
||||
@@ -161,6 +172,17 @@ type Session = {
|
||||
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
|
||||
nextPendingOrder: number;
|
||||
abortController: AbortController;
|
||||
/** Per-turn signal the active prompt loop races `query.next()` against.
|
||||
* Aborted by cancel() (after a grace period) to force the loop to return
|
||||
* "cancelled" when the SDK is wedged and `query.next()` never yields again
|
||||
* (issue #680). Distinct from `abortController`: this only wakes the loop;
|
||||
* it does NOT touch the SDK query/subprocess. Undefined when no prompt is
|
||||
* actively consuming the query. */
|
||||
cancelController?: AbortController;
|
||||
/** Pending grace-period timer that aborts `cancelController`. Cleared when
|
||||
* the loop returns normally so the backstop never fires after a clean
|
||||
* cancel. */
|
||||
forceCancelTimer?: ReturnType<typeof setTimeout>;
|
||||
emitRawSDKMessages: boolean | SDKMessageFilter[];
|
||||
/** Context window size of the last top-level assistant model, carried across
|
||||
* prompts so mid-stream usage_update notifications report a correct `size`
|
||||
@@ -171,6 +193,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
|
||||
@@ -185,17 +231,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;
|
||||
@@ -524,16 +559,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
[key: string]: Session;
|
||||
};
|
||||
client: AgentSideConnection;
|
||||
toolUseCache: ToolUseCache;
|
||||
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
logger: Logger;
|
||||
gatewayAuthRequest?: GatewayAuthRequest;
|
||||
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
||||
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
||||
* tests can shrink it. */
|
||||
forceCancelGraceMs: number = DEFAULT_FORCE_CANCEL_GRACE_MS;
|
||||
|
||||
constructor(client: AgentSideConnection, logger?: Logger) {
|
||||
this.sessions = {};
|
||||
this.client = client;
|
||||
this.toolUseCache = {};
|
||||
this.logger = logger ?? console;
|
||||
}
|
||||
|
||||
@@ -790,6 +826,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// forward it to clients as structured `data`, sparing them from
|
||||
// pattern-matching on the human-readable message text.
|
||||
let lastAssistantError: SDKAssistantMessageError | undefined;
|
||||
// When a streaming classifier refuses a turn, the assistant message carries
|
||||
// stop_reason "refusal" and structured stop_details. We capture the
|
||||
// human-readable explanation here so the terminal `result` can surface it
|
||||
// to the user (the refused assistant message itself usually has no content)
|
||||
// and report ACP's dedicated `refusal` stop reason.
|
||||
let lastRefusalExplanation: string | null = null;
|
||||
// Tracks whether we're inside a compaction. The SDK emits the terminal
|
||||
// `status` (compact_result success/failed) twice for a single failed
|
||||
// compaction, and the two messages are indistinguishable — so we report the
|
||||
@@ -797,6 +839,17 @@ 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;
|
||||
|
||||
const userMessage = promptToClaude(params);
|
||||
|
||||
@@ -828,9 +881,27 @@ export class ClaudeAcpAgent implements Agent {
|
||||
let errored = false;
|
||||
let stopReason: StopReason = "end_turn";
|
||||
|
||||
// Wake-up channel so cancel() can force this loop to return "cancelled"
|
||||
// even when query.next() is wedged and never yields again (issue #680).
|
||||
const cancelController = new AbortController();
|
||||
session.cancelController = cancelController;
|
||||
const cancelled = new Promise<void>((resolve) => {
|
||||
cancelController.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value: message, done } = await session.query.next();
|
||||
const nextMessage = session.query.next();
|
||||
const next = await Promise.race([nextMessage, cancelled]);
|
||||
if (cancelController.signal.aborted) {
|
||||
// The SDK never yielded after interrupt() (e.g. a wedged TaskOutput
|
||||
// block). Abandon the in-flight next() — swallowing any later
|
||||
// rejection so it can't surface as an unhandled rejection — and
|
||||
// honor the cancel per the ACP contract.
|
||||
void nextMessage.catch(() => {});
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
const { value: message, done } = next as IteratorResult<SDKMessage, void>;
|
||||
|
||||
if (done || !message) {
|
||||
if (session.cancelled) {
|
||||
@@ -890,28 +961,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,
|
||||
},
|
||||
});
|
||||
@@ -976,6 +1054,65 @@ export class ClaudeAcpAgent implements Agent {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "commands_changed": {
|
||||
// Push the full slash-command list after a mid-session change
|
||||
// (e.g. skills discovered dynamically as the agent works in a
|
||||
// subdirectory). The client should REPLACE its cached command
|
||||
// list with this payload: supportedCommands() is captured once
|
||||
// at initialize and never reflects mid-session changes, so we
|
||||
// forward message.commands directly rather than re-querying.
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: getAvailableSlashCommands(message.commands),
|
||||
},
|
||||
});
|
||||
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":
|
||||
@@ -988,8 +1125,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "plugin_install":
|
||||
case "notification":
|
||||
case "api_retry":
|
||||
case "mirror_error":
|
||||
case "permission_denied":
|
||||
case "thinking_tokens":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
@@ -1048,6 +1183,26 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
|
||||
// A refusal can arrive on any result subtype (and may even set
|
||||
// is_error), so handle it before the subtype switch — otherwise the
|
||||
// is_error throw below would surface it as an internal error. The
|
||||
// refused assistant message carries no visible content, so surface
|
||||
// the classifier's explanation (when available) and report ACP's
|
||||
// dedicated `refusal` stop reason.
|
||||
if (message.stop_reason === "refusal" && !isTaskNotification) {
|
||||
if (lastRefusalExplanation) {
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: lastRefusalExplanation },
|
||||
},
|
||||
});
|
||||
}
|
||||
stopReason = "refusal";
|
||||
break;
|
||||
}
|
||||
|
||||
switch (message.subtype) {
|
||||
case "success": {
|
||||
if (message.result.includes("Please run /login")) {
|
||||
@@ -1074,7 +1229,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message.result,
|
||||
"assistant",
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
session.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
@@ -1121,6 +1276,12 @@ 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;
|
||||
}
|
||||
if (
|
||||
message.parent_tool_use_id === null &&
|
||||
(message.event.type === "message_start" || message.event.type === "message_delta")
|
||||
@@ -1175,13 +1336,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);
|
||||
@@ -1194,6 +1356,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) {
|
||||
@@ -1228,6 +1399,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (message.error) {
|
||||
lastAssistantError = message.error;
|
||||
}
|
||||
if (message.message.stop_reason === "refusal") {
|
||||
lastRefusalExplanation = message.message.stop_details?.explanation ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Strip <command-*>/<local-command-stdout> markers and render any
|
||||
@@ -1246,7 +1420,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
stripped,
|
||||
message.message.role,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
session.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{
|
||||
@@ -1254,6 +1428,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);
|
||||
@@ -1308,7 +1483,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
content,
|
||||
message.message.role,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
session.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{
|
||||
@@ -1316,17 +1491,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);
|
||||
@@ -1385,6 +1590,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
// The loop is returning — interrupt() succeeded or the prompt finished
|
||||
// — so disarm the force-cancel backstop and release the wake-up channel
|
||||
// (only if we still own it; a handoff installs the next prompt's).
|
||||
if (session.forceCancelTimer) {
|
||||
clearTimeout(session.forceCancelTimer);
|
||||
session.forceCancelTimer = undefined;
|
||||
}
|
||||
if (session.cancelController === cancelController) {
|
||||
session.cancelController = undefined;
|
||||
}
|
||||
if (!handedOff) {
|
||||
session.promptRunning = false;
|
||||
if (errored) {
|
||||
@@ -1422,6 +1637,33 @@ export class ClaudeAcpAgent implements Agent {
|
||||
pending.resolve(true);
|
||||
}
|
||||
session.pendingMessages.clear();
|
||||
|
||||
// Arm a backstop before interrupting: if a prompt is actively consuming
|
||||
// the query and interrupt() doesn't make the SDK yield (e.g. a wedged
|
||||
// TaskOutput block — issue #680), force the loop to return "cancelled"
|
||||
// after the floor elapses so the pending session/prompt still resolves per
|
||||
// the ACP cancellation contract instead of hanging forever. The loop's
|
||||
// `finally` clears this timer when interrupt() works and it returns through
|
||||
// the normal idle path, so on healthy cancels it is armed but never fires.
|
||||
//
|
||||
// Arm at most once per turn: the floor is an absolute ceiling from the
|
||||
// first cancel, so a client that re-sends cancel (each call still retries
|
||||
// interrupt() below) can't keep pushing the deadline out.
|
||||
if (
|
||||
session.promptRunning &&
|
||||
session.cancelController &&
|
||||
!session.cancelController.signal.aborted &&
|
||||
!session.forceCancelTimer
|
||||
) {
|
||||
const cancelController = session.cancelController;
|
||||
session.forceCancelTimer = setTimeout(() => {
|
||||
this.logger.error(
|
||||
`Session ${params.sessionId}: cancel floor elapsed without the SDK yielding; forcing "cancelled". The underlying query may still be wedged — a new session may be required.`,
|
||||
);
|
||||
cancelController.abort();
|
||||
}, this.forceCancelGraceMs);
|
||||
}
|
||||
|
||||
await session.query.interrupt();
|
||||
}
|
||||
|
||||
@@ -1433,6 +1675,18 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return;
|
||||
}
|
||||
await this.cancel({ sessionId });
|
||||
// cancel() arms the force-cancel floor and interrupts gracefully, but a
|
||||
// wedged prompt loop only wakes when `cancelController` aborts — closing
|
||||
// the query/abortController below doesn't touch it. Since we're tearing the
|
||||
// session down anyway, wake the loop now so the in-flight prompt() resolves
|
||||
// immediately instead of after the floor, and clear the timer so it can't
|
||||
// outlive the deleted session (it isn't unref'd and would otherwise keep
|
||||
// the event loop alive until it fires).
|
||||
if (session.forceCancelTimer) {
|
||||
clearTimeout(session.forceCancelTimer);
|
||||
session.forceCancelTimer = undefined;
|
||||
}
|
||||
session.cancelController?.abort();
|
||||
session.settingsManager.dispose();
|
||||
session.abortController.abort();
|
||||
session.query.close();
|
||||
@@ -1579,6 +1833,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
|
||||
@@ -1601,6 +1865,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);
|
||||
@@ -2026,10 +2291,8 @@ 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);
|
||||
@@ -2055,7 +2318,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
|
||||
@@ -2283,7 +2546,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
effortLevel: initialEffort.currentValue as Settings["effortLevel"],
|
||||
});
|
||||
}
|
||||
|
||||
this.sessions[sessionId] = {
|
||||
query: q,
|
||||
input: input,
|
||||
@@ -2309,6 +2571,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
contextWindowSize:
|
||||
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
taskState,
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -2600,11 +2864,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;
|
||||
}
|
||||
|
||||
@@ -2900,6 +3167,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.
|
||||
@@ -2917,6 +3232,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();
|
||||
@@ -2930,6 +3251,7 @@ export function toAcpNotifications(
|
||||
text: content,
|
||||
},
|
||||
};
|
||||
applyMessageId(update, options?.messageId);
|
||||
|
||||
if (options?.parentToolUseId) {
|
||||
update._meta = {
|
||||
@@ -3005,42 +3327,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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3177,6 +3498,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;
|
||||
}
|
||||
|
||||
@@ -3207,6 +3533,7 @@ export function toAcpNotifications(
|
||||
},
|
||||
};
|
||||
}
|
||||
applyMessageId(update, options?.messageId);
|
||||
output.push({ sessionId, update });
|
||||
}
|
||||
}
|
||||
@@ -3224,6 +3551,7 @@ export function streamEventToAcpNotifications(
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
messageId?: string;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
@@ -3241,6 +3569,7 @@ export function streamEventToAcpNotifications(
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
messageId: options?.messageId,
|
||||
},
|
||||
);
|
||||
case "content_block_delta":
|
||||
@@ -3256,6 +3585,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
|
||||
@@ -3297,7 +3627,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. */
|
||||
@@ -3306,6 +3637,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 {
|
||||
|
||||
@@ -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,60 @@ 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("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
|
||||
@@ -33,10 +33,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) => {
|
||||
@@ -1318,6 +1324,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 +1615,8 @@ describe("stop reason propagation", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1692,6 +1761,8 @@ describe("stop reason propagation", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1851,6 +1922,8 @@ describe("session/close", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -1935,6 +2008,8 @@ describe("session/delete", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -2036,6 +2111,8 @@ describe("getOrCreateSession param change detection", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -2271,6 +2348,8 @@ describe("usage_update computation", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3108,6 +3187,49 @@ 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("emitRawSDKMessages", () => {
|
||||
@@ -3171,6 +3293,8 @@ describe("emitRawSDKMessages", () => {
|
||||
emitRawSDKMessages,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3399,6 +3523,8 @@ describe("result origin handling", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3574,6 +3700,8 @@ describe("memory_recall handling", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3804,6 +3932,8 @@ describe("post-error recovery", () => {
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return { interrupt };
|
||||
}
|
||||
@@ -3877,6 +4007,186 @@ describe("post-error recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
// Generator that replays the prompt's user message and then blocks forever,
|
||||
// simulating the SDK wedged in a `TaskOutput { block: true }` poll against a
|
||||
// hung background task. `interrupt()` is a no-op — it does NOT unblock the
|
||||
// generator, matching the SDK behavior described in the issue.
|
||||
function injectWedgedSession(agent: ClaudeAcpAgent, opts: { interruptUnblocks?: boolean } = {}) {
|
||||
const input = new Pushable<any>();
|
||||
const interrupt = vi.fn(async () => {});
|
||||
const close = vi.fn();
|
||||
// A promise the wedged poll awaits. When `interruptUnblocks` is set, the
|
||||
// mocked interrupt() resolves it so the generator yields a trailing idle —
|
||||
// the normal, healthy interrupt path.
|
||||
let releaseBlock!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
releaseBlock = resolve;
|
||||
});
|
||||
if (opts.interruptUnblocks) {
|
||||
interrupt.mockImplementation(async () => {
|
||||
releaseBlock();
|
||||
});
|
||||
}
|
||||
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const first = await iter.next();
|
||||
if (!first.done && first.value) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: first.value.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: first.value.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
// Wedge: never yield again unless interrupt() releases us.
|
||||
await blocked;
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
|
||||
const gen = Object.assign(messageGenerator(), { interrupt, close });
|
||||
agent.sessions["test-session"] = {
|
||||
query: gen 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(),
|
||||
};
|
||||
return { interrupt };
|
||||
}
|
||||
|
||||
it("resolves the pending prompt with cancelled when the SDK never yields after interrupt", async () => {
|
||||
const agent = createMockAgent();
|
||||
// Shrink the grace period so the test doesn't wait the production default.
|
||||
agent.forceCancelGraceMs = 20;
|
||||
const { interrupt } = injectWedgedSession(agent);
|
||||
|
||||
const promptPromise = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "run cargo test" }],
|
||||
});
|
||||
|
||||
// Let the loop consume the replay and block on the wedged query.next().
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
|
||||
const response = await promptPromise;
|
||||
expect(response.stopReason).toBe("cancelled");
|
||||
expect(interrupt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns cancelled through the normal idle path without waiting the grace period when interrupt works", async () => {
|
||||
const agent = createMockAgent();
|
||||
// Large grace so that if the test ever falls through to the backstop it
|
||||
// would hang past the test timeout instead of passing by accident.
|
||||
agent.forceCancelGraceMs = 60_000;
|
||||
const { interrupt } = injectWedgedSession(agent, { interruptUnblocks: true });
|
||||
|
||||
const promptPromise = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "run cargo test" }],
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
|
||||
const response = await promptPromise;
|
||||
expect(response.stopReason).toBe("cancelled");
|
||||
expect(interrupt).toHaveBeenCalled();
|
||||
// Backstop timer must have been cleared so it can't fire later.
|
||||
expect(agent.sessions["test-session"].forceCancelTimer).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not arm the backstop when no prompt is running", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectWedgedSession(agent);
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
|
||||
const session = agent.sessions["test-session"];
|
||||
expect(session.cancelled).toBe(true);
|
||||
expect(session.forceCancelTimer).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reset the force-cancel floor on repeated cancels", async () => {
|
||||
const agent = createMockAgent();
|
||||
// Long floor so the timer handle stays observable across both cancels.
|
||||
agent.forceCancelGraceMs = 60_000;
|
||||
injectWedgedSession(agent);
|
||||
|
||||
const promptPromise = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "run cargo test" }],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
const firstTimer = agent.sessions["test-session"].forceCancelTimer;
|
||||
expect(firstTimer).toBeDefined();
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
// Same handle: the second cancel did not clear-and-rearm (which would push
|
||||
// the floor out). The deadline stays anchored to the first cancel.
|
||||
expect(agent.sessions["test-session"].forceCancelTimer).toBe(firstTimer);
|
||||
|
||||
// Clean up the wedged prompt + long timer.
|
||||
await agent.closeSession({ sessionId: "test-session" });
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
});
|
||||
|
||||
it("resolves an in-flight wedged prompt immediately when the session is closed", async () => {
|
||||
const agent = createMockAgent();
|
||||
// Large floor: if closeSession relied on the force-cancel timer this would
|
||||
// hang past the test timeout. Teardown must wake the loop via
|
||||
// cancelController instead.
|
||||
agent.forceCancelGraceMs = 60_000;
|
||||
injectWedgedSession(agent);
|
||||
|
||||
const promptPromise = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "run cargo test" }],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
await agent.closeSession({ sessionId: "test-session" });
|
||||
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
expect(agent.sessions["test-session"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamEventToAcpNotifications", () => {
|
||||
it("treats `ping` keep-alive events as no-ops without logging to stderr", () => {
|
||||
const errors: unknown[][] = [];
|
||||
@@ -3910,4 +4220,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,4 +473,62 @@ 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: "/test", 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: "/test", 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: "/test", 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: "/test", 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: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
thinking: { type: "adaptive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
Reference in New Issue
Block a user