mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-30 14:01:44 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3500ef736f | ||
|
|
8b366d8e9a | ||
|
|
5144aabc52 | ||
|
|
f664ced76d | ||
|
|
e944ceddea | ||
|
|
32b93501d7 | ||
|
|
f08c4521ec | ||
|
|
f3d8ae3eb3 | ||
|
|
b03318f59c |
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -50,8 +50,6 @@ jobs:
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.57.0"
|
||||
".": "0.58.1"
|
||||
}
|
||||
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## [0.58.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.58.0...v0.58.1) (2026-07-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Use valid npm version for publish ([#855](https://github.com/agentclientprotocol/claude-agent-acp/issues/855)) ([8b366d8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8b366d8e9ad55e9237bf2d618cfc99b57fac570d))
|
||||
|
||||
## [0.58.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.57.0...v0.58.0) (2026-07-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.3.205 ([#854](https://github.com/agentclientprotocol/claude-agent-acp/issues/854)) ([f664ced](https://github.com/agentclientprotocol/claude-agent-acp/commit/f664ced76df00674d88aedd018a4261da3bc3f3a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Preserve live model on resumed sessions ([#848](https://github.com/agentclientprotocol/claude-agent-acp/issues/848)) ([f3d8ae3](https://github.com/agentclientprotocol/claude-agent-acp/commit/f3d8ae3eb389ee9367f48c8762562a55280ade0a)), closes [#845](https://github.com/agentclientprotocol/claude-agent-acp/issues/845)
|
||||
* Report usage for cancelled active turns ([#846](https://github.com/agentclientprotocol/claude-agent-acp/issues/846)) ([b03318f](https://github.com/agentclientprotocol/claude-agent-acp/commit/b03318f59c6165146c8d8e036227f35c3abd0ff4)), closes [#844](https://github.com/agentclientprotocol/claude-agent-acp/issues/844)
|
||||
* tolerate missing text in streamed thinking chunks ([#852](https://github.com/agentclientprotocol/claude-agent-acp/issues/852)) ([e944ced](https://github.com/agentclientprotocol/claude-agent-acp/commit/e944ceddea79950d6bfb6339266862507c4b964f))
|
||||
* Use SDK guards for elicitation validation ([#850](https://github.com/agentclientprotocol/claude-agent-acp/issues/850)) ([32b9350](https://github.com/agentclientprotocol/claude-agent-acp/commit/32b93501d7a35a3b405b5022b4caaad492eed11c))
|
||||
|
||||
## [0.57.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.56.0...v0.57.0) (2026-07-07)
|
||||
|
||||
|
||||
|
||||
242
package-lock.json
generated
242
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.202",
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.205",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -20,7 +20,7 @@
|
||||
"@anthropic-ai/sdk": "0.110.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.5",
|
||||
"@types/node": "26.1.0",
|
||||
"@types/node": "26.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"eslint": "10.6.0",
|
||||
@@ -36,31 +36,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.0.tgz",
|
||||
"integrity": "sha512-rJ3766M2eouZsNCaK21JbPD++JLBZ0PWq38dzj1sA4/i0njaQEkiC2F5v7MlizFW6akX+qu5Ks8R00Xw+YMHKA==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.2.1.tgz",
|
||||
"integrity": "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz",
|
||||
"integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==",
|
||||
"version": "0.3.205",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.205.tgz",
|
||||
"integrity": "sha512-ft6iBw9kXudsusiXNpeybIPBJ07Z3tqp1ROSg5cEJqgA+9i+JJj2sRfQth+QD+lyenbbAU8yPieLxIimvfBhtw==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.205",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.205",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.205",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.205",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.205",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.205",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.205",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.205"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -69,9 +69,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz",
|
||||
"integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==",
|
||||
"version": "0.3.205",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.205.tgz",
|
||||
"integrity": "sha512-lrfJ4eVtzfPkCpbSkBOGSMQCBbvmW6nbPzgHE4IwMN3scZlpuFMUFqh2aaJa/X2SAcWD9H2S0t2WWvSRgM7BjA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -82,9 +82,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz",
|
||||
"integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==",
|
||||
"version": "0.3.205",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.205.tgz",
|
||||
"integrity": "sha512-G6ETPmL5mNzJ2DFsWxG3jmsmrXgZX1N2ZCJvxaGUUpjTsKZJ4Tup1cWYvcd/m7o5fYZmx9REmgzTwsAIc1fdPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -95,9 +95,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz",
|
||||
"integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==",
|
||||
"version": "0.3.205",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.205.tgz",
|
||||
"integrity": "sha512-CXzySK3PV3EizCRPXnxPqeaAtgrBFDnMFOVpMe36oC3U16yDb1b1tAJGqZi/7uFrVvAiaXvnSFxhUWnDDSaO+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -111,9 +111,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz",
|
||||
"integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==",
|
||||
"version": "0.3.205",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.205.tgz",
|
||||
"integrity": "sha512-91fgdG4aTnQ29sKOcUqgH4+tKCW2ut6PWGRSYmXNDbROasJm1rAlPdzC5brdu/e4c0CDSNV6TWyE5JCjaS/jlQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -127,9 +127,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz",
|
||||
"integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==",
|
||||
"version": "0.3.205",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.205.tgz",
|
||||
"integrity": "sha512-siS+1iNqBSlGFZZvJY6+mhzZ/6/ec/TbX9GMuwmTF0E6fxGhIIp797jJxR1q8r6FAq7d39mEoRNhC0Ffo60uNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -143,9 +143,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz",
|
||||
"integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==",
|
||||
"version": "0.3.205",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.205.tgz",
|
||||
"integrity": "sha512-vvsb7GlnA8CTSVvvTkrXjcSeRKqxSM7p/tU3Od9ICAZeWHglptekEyzLEApzLuLbI5ewfFF/F0q3NwOBbo18dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -159,9 +159,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz",
|
||||
"integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==",
|
||||
"version": "0.3.205",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.205.tgz",
|
||||
"integrity": "sha512-SpP5zF68weFez/6pKrGzq/UVAJDMDNphWqmkLfOpWTDBL5xy6XlIZw5Bl4EXoVnfi2VLFkwuffNeFe+9SdX7kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -172,9 +172,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.202",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz",
|
||||
"integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==",
|
||||
"version": "0.3.205",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.205.tgz",
|
||||
"integrity": "sha512-kg2kkXyeSoFLruO3Ic2IruLxzBR0xCUtmlJHdWi3SYW7JhAKNJg4fcrdJsWcardmEw23Y2UDGDJbRyxqSVx6wg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -544,9 +544,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.138.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -554,9 +554,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -571,9 +571,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -588,9 +588,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -605,9 +605,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz",
|
||||
"integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -622,9 +622,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz",
|
||||
"integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -639,9 +639,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -659,9 +659,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -679,9 +679,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -699,9 +699,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -719,9 +719,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz",
|
||||
"integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -739,9 +739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz",
|
||||
"integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -759,9 +759,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz",
|
||||
"integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -776,9 +776,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz",
|
||||
"integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -795,9 +795,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -812,9 +812,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz",
|
||||
"integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -934,9 +934,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
|
||||
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3324,13 +3324,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.138.0",
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3340,21 +3340,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.4",
|
||||
"@rolldown/binding-darwin-x64": "1.1.4",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.4",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.4",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.4",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.4",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.4",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.4",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.4",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -3590,9 +3590,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3826,16 +3826,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.3",
|
||||
"rolldown": "~1.1.4",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -63,15 +63,15 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.202",
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.205",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.110.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.5",
|
||||
"@types/node": "26.1.0",
|
||||
"@types/node": "26.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"eslint": "10.6.0",
|
||||
|
||||
340
src/acp-agent.ts
340
src/acp-agent.ts
@@ -229,8 +229,11 @@ type Session = {
|
||||
* the SDK still runs it and emits a result with no uuid we can match. Because
|
||||
* the SDK processes input FIFO, those orphan results arrive (in submission
|
||||
* order) before the next live turn's, so skipping exactly this many leaves
|
||||
* the genuine head untouched. Reset to 0 on every activation as a backstop
|
||||
* against an SDK that drops queued input on interrupt (no orphan emitted). */
|
||||
* the genuine head untouched. On CLIs with the interrupt receipt, orphans
|
||||
* the interrupt dropped (absent from `still_queued`) are uncounted as soon
|
||||
* as the receipt arrives (see cancel()). Reset to 0 on every activation as
|
||||
* a backstop against a dropped queued input this can't see (older CLIs, a
|
||||
* receipt lost to a failed control round-trip). */
|
||||
pendingOrphanResults?: number;
|
||||
/** The long-lived consumer task. Lazily started on the first `prompt()` and
|
||||
* kept alive for the session so between-turn/background messages are still
|
||||
@@ -1340,7 +1343,7 @@ export class ClaudeAcpAgent {
|
||||
if (session.activeTurn && !session.activeTurn.settled) {
|
||||
session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
|
||||
}
|
||||
settleActive({ stopReason: "cancelled" });
|
||||
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
||||
// If the session is being torn down, abandon the in-flight next()
|
||||
// (swallowing any later rejection so it can't surface as unhandled)
|
||||
// and stop; otherwise re-arm and keep consuming — `pendingNext`
|
||||
@@ -1368,11 +1371,10 @@ export class ClaudeAcpAgent {
|
||||
//
|
||||
// Settle the turn that was in flight so its prompt() doesn't hang:
|
||||
// cancelled if a cancel is pending, otherwise the accumulated outcome.
|
||||
settleActive(
|
||||
session.cancelled
|
||||
? { stopReason: "cancelled" }
|
||||
: { stopReason, usage: sessionUsage(session) },
|
||||
);
|
||||
settleActive({
|
||||
stopReason: session.cancelled ? "cancelled" : stopReason,
|
||||
usage: sessionUsage(session),
|
||||
});
|
||||
// Queued turns the SDK never started never ran, so reject them rather
|
||||
// than reporting a success (end_turn) — or a misleading "cancelled" —
|
||||
// for a prompt that produced no output. (A cancel already settled the
|
||||
@@ -1517,8 +1519,14 @@ export class ClaudeAcpAgent {
|
||||
// the turn NOW so its session/prompt gets a terminal
|
||||
// response, instead of leaving it hanging until the next
|
||||
// prompt drains the wreckage.
|
||||
// A cancelled turn still consumed tokens: its dropped result
|
||||
// already fed the accumulator (the usage tally at the result
|
||||
// handler runs before the `session.cancelled` guard), so
|
||||
// report it — clients metering spend would otherwise lose
|
||||
// the interrupted turn's tokens entirely (issue #844). Zero
|
||||
// when the cancel pre-empted the result (wedge/force-cancel).
|
||||
if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
|
||||
settleActive({ stopReason: "cancelled" });
|
||||
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
||||
} else if (owedTrailingIdles > 0) {
|
||||
// Absorb a settled turn's trailing idle. Also covers a
|
||||
// cancel that landed between a turn's counted result and
|
||||
@@ -1772,6 +1780,15 @@ export class ClaudeAcpAgent {
|
||||
lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
|
||||
}
|
||||
break;
|
||||
// `control_request_progress` only reports on side_question
|
||||
// control requests, which this adapter never issues.
|
||||
// `background_tasks_changed` is a level signal (the full live
|
||||
// background-task set on every membership change) for surfaces
|
||||
// that render a background-activity indicator; turn lifecycle
|
||||
// here is driven by results/idle, so there is nothing to track.
|
||||
case "control_request_progress":
|
||||
case "background_tasks_changed":
|
||||
break;
|
||||
default:
|
||||
unreachable(message, this.logger);
|
||||
break;
|
||||
@@ -2024,7 +2041,7 @@ export class ClaudeAcpAgent {
|
||||
// create a block the consolidated handler's `text.length > 0`
|
||||
// guard can never consume, stalling the diff cursor and
|
||||
// re-emitting the next block as a duplicate.
|
||||
if (chunk && chunk.text.length > 0) {
|
||||
if (chunk?.text) {
|
||||
const index = message.event.index;
|
||||
const last = streamedBlocks[streamedBlocks.length - 1];
|
||||
if (last && last.index === index && last.type === chunk.type) {
|
||||
@@ -2142,7 +2159,9 @@ export class ClaudeAcpAgent {
|
||||
// as the freshly-activated turn ending without a result
|
||||
// (which would false-fail a healthy turn — issue #825).
|
||||
owedTrailingIdles++;
|
||||
settleActive({ stopReason: "cancelled" });
|
||||
// Before activateTurn resets the accumulator, so the
|
||||
// usage still belongs to the cancelled turn.
|
||||
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
||||
} else {
|
||||
settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
|
||||
}
|
||||
@@ -2381,13 +2400,10 @@ export class ClaudeAcpAgent {
|
||||
// `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
|
||||
// flows) is safe to drop: turn lifecycle here is driven by
|
||||
// results/idle, and the client owns its own transcript view.
|
||||
// `control_request_progress` only reports on side_question control
|
||||
// requests, which this adapter never issues.
|
||||
case "tool_use_summary":
|
||||
case "auth_status":
|
||||
case "prompt_suggestion":
|
||||
case "conversation_reset":
|
||||
case "control_request_progress":
|
||||
break;
|
||||
default:
|
||||
unreachable(message);
|
||||
@@ -2449,20 +2465,22 @@ export class ClaudeAcpAgent {
|
||||
// they have no in-flight SDK work to interrupt. The active turn is settled
|
||||
// by the consumer when it observes the interrupt's trailing idle (or via the
|
||||
// backstop below). Mirrors the old pendingMessages cancellation.
|
||||
const orphanedUuids: string[] = [];
|
||||
if (session.turnQueue) {
|
||||
let orphaned = 0;
|
||||
for (const turn of session.turnQueue) {
|
||||
if (turn !== session.activeTurn && !turn.settled) {
|
||||
turn.settled = true;
|
||||
// Deliberately no `usage`: a queued turn never ran, so the session
|
||||
// accumulator (the active turn's tally) is not its spend.
|
||||
turn.resolve({ stopReason: "cancelled" });
|
||||
orphaned++;
|
||||
orphanedUuids.push(turn.promptUuid);
|
||||
}
|
||||
}
|
||||
// Each removed queued turn's user message was already pushed to the SDK,
|
||||
// which processes input FIFO and will still emit a result for it with no
|
||||
// uuid to match. Count those so the consumer skips them (see
|
||||
// ensureActiveTurn) rather than misattributing them to the head.
|
||||
session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphaned;
|
||||
session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + orphanedUuids.length;
|
||||
session.turnQueue = session.turnQueue.filter(
|
||||
(turn) => turn === session.activeTurn && !turn.settled,
|
||||
);
|
||||
@@ -2494,7 +2512,29 @@ export class ClaudeAcpAgent {
|
||||
}, this.forceCancelGraceMs);
|
||||
}
|
||||
|
||||
await session.query.interrupt();
|
||||
const receipt = await session.query.interrupt();
|
||||
// On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
|
||||
// lists exactly which queued messages survive the interrupt and will still
|
||||
// run. An orphaned turn whose uuid is absent was dropped by the interrupt
|
||||
// and will never emit a result — uncount it now instead of leaving a stale
|
||||
// skip that activateTurn's reset only clears once a later live ECHO
|
||||
// arrives: an echo-less result in between (a local-only command like
|
||||
// `/context`) would be wrongly swallowed by the leftover count. Subtracting
|
||||
// a count (rather than tracking uuids) stays race-safe against the
|
||||
// consumer draining concurrently: dropped uuids produce no results, so the
|
||||
// consumer's decrements only ever consume the still-queued share. Unknown
|
||||
// uuids in the receipt (internally-enqueued messages) are ignored, per its
|
||||
// contract. Older CLIs resolve `undefined` (guard the FIELD, not just the
|
||||
// receipt, so a bare `{}` success from a gateway can't read as "everything
|
||||
// was dropped") — keep the count-everything behavior and its
|
||||
// activation-time self-heal.
|
||||
if (Array.isArray(receipt?.still_queued) && orphanedUuids.length > 0) {
|
||||
const stillQueued = new Set(receipt.still_queued);
|
||||
const dropped = orphanedUuids.filter((uuid) => !stillQueued.has(uuid)).length;
|
||||
if (dropped > 0) {
|
||||
session.pendingOrphanResults = Math.max(0, (session.pendingOrphanResults ?? 0) - dropped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark a session's SDK query stream as permanently ended and release the
|
||||
@@ -2634,18 +2674,38 @@ export class ClaudeAcpAgent {
|
||||
: [];
|
||||
let validValue = allValues.find((o) => o.value === params.value);
|
||||
|
||||
// The option's reported currentValue is always a valid target, even when
|
||||
// it has no options entry: a session running an out-of-picker model
|
||||
// (resumed onto an allowlist-excluded model, or a refusal fallback)
|
||||
// reports a currentValue that isn't selectable, and a client
|
||||
// round-tripping it must not get "Invalid value". It flows through the
|
||||
// normal apply path below — re-asserting an already-current value is
|
||||
// harmless and can repair SDK drift.
|
||||
if (!validValue && option.currentValue === params.value) {
|
||||
validValue = { value: params.value, name: params.value };
|
||||
}
|
||||
|
||||
// For model options, fall back to resolveModelPreference when the exact
|
||||
// value doesn't match. This lets callers use human-friendly aliases like
|
||||
// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
|
||||
// Resolve against session.modelInfos first: those entries carry
|
||||
// `resolvedModel`, so a full model id (in either hint spelling) lands on
|
||||
// the right row via the exact tier instead of a fuzzier one picking a
|
||||
// same-family sibling from a different context lane. The options-derived
|
||||
// list (which never carries `resolvedModel`) remains as a fallback for
|
||||
// resolutions that don't map back onto a selectable option (e.g. a fuzzy
|
||||
// hit on an out-of-picker verbatim entry).
|
||||
if (!validValue && params.configId === MODEL_CONFIG_ID) {
|
||||
const modelInfos: ModelInfo[] = allValues.map((o) => ({
|
||||
value: o.value,
|
||||
displayName: o.name,
|
||||
description: o.description ?? "",
|
||||
}));
|
||||
const resolved = resolveModelPreference(modelInfos, params.value);
|
||||
if (resolved) {
|
||||
validValue = allValues.find((o) => o.value === resolved.value);
|
||||
const toOptionValue = (resolved: ModelInfo | null) =>
|
||||
resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
|
||||
validValue = toOptionValue(resolveModelPreference(session.modelInfos, params.value));
|
||||
if (!validValue) {
|
||||
const optionInfos: ModelInfo[] = allValues.map((o) => ({
|
||||
value: o.value,
|
||||
displayName: o.name,
|
||||
description: o.description ?? "",
|
||||
}));
|
||||
validValue = toOptionValue(resolveModelPreference(optionInfos, params.value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3793,11 +3853,44 @@ export class ClaudeAcpAgent {
|
||||
initializationResult.models,
|
||||
settingsManager,
|
||||
this.logger,
|
||||
creationOpts.resume !== undefined,
|
||||
);
|
||||
|
||||
// Gate `auto` (and future model-specific modes) on the resolved model's
|
||||
// `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
|
||||
const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
|
||||
// A resumed session can be running a model outside the `availableModels`
|
||||
// allowlist (currentModelId is then the verbatim live id, see
|
||||
// `matchResumedModel`); its capabilities are still known to the SDK's
|
||||
// unfiltered list, so fall back to that before treating the model as
|
||||
// unknown — otherwise auto mode would be spuriously clamped and the
|
||||
// Fast-mode/Effort options hidden for a model that supports them.
|
||||
const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
|
||||
const fallbackModelInfo = allowlistedModelInfo
|
||||
? undefined
|
||||
: (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
|
||||
const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo;
|
||||
// Register the fallback-resolved capabilities under the verbatim live id
|
||||
// so every modelInfos consumer (buildConfigOptions' effort lookup, later
|
||||
// rebuilds via session.modelInfos) agrees with the gating below. The
|
||||
// picker options themselves come from `models.availableModels`, so this
|
||||
// adds no selectable entry. The spread keeps every capability flag
|
||||
// (current and future); the identity fields are overridden because the
|
||||
// fuzzy-matched sibling's resolvedModel/displayName/description can
|
||||
// describe a different context lane and would poison later resolvedModel
|
||||
// matching (syncModelAfterRefusalFallback) and context-window inference
|
||||
// (applyConfigOptionValue) if they traveled under this id.
|
||||
const modelInfos = fallbackModelInfo
|
||||
? [
|
||||
...allowedModels,
|
||||
{
|
||||
...fallbackModelInfo,
|
||||
value: models.currentModelId,
|
||||
displayName: models.currentModelId,
|
||||
description: "",
|
||||
resolvedModel: undefined,
|
||||
},
|
||||
]
|
||||
: allowedModels;
|
||||
const availableModes = buildAvailableModes(currentModelInfo);
|
||||
|
||||
// Clamp `permissionMode` if the resolved session does not offer it. The
|
||||
@@ -3863,7 +3956,7 @@ export class ClaudeAcpAgent {
|
||||
const configOptions = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
allowedModels,
|
||||
modelInfos,
|
||||
settingsManager.getSettings().effortLevel,
|
||||
agents,
|
||||
currentAgent,
|
||||
@@ -3896,7 +3989,7 @@ export class ClaudeAcpAgent {
|
||||
},
|
||||
modes,
|
||||
models,
|
||||
modelInfos: allowedModels,
|
||||
modelInfos,
|
||||
configOptions,
|
||||
agents,
|
||||
currentAgent,
|
||||
@@ -3904,10 +3997,15 @@ export class ClaudeAcpAgent {
|
||||
abortController,
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
contextWindowSize:
|
||||
// Deliberately keyed to the allowlisted entry: a fallback-resolved
|
||||
// sibling's displayName/description can describe a different context
|
||||
// lane than the verbatim live id (e.g. an "opus[1m]" row matched for
|
||||
// a bare 200k id), so on the fallback path only the id itself is a
|
||||
// trustworthy window signal.
|
||||
inferContextWindowFromModel(
|
||||
models.currentModelId,
|
||||
currentModelInfo?.displayName,
|
||||
currentModelInfo?.description,
|
||||
allowlistedModelInfo?.displayName,
|
||||
allowlistedModelInfo?.description,
|
||||
) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
taskState,
|
||||
toolUseCache: {},
|
||||
@@ -4325,15 +4423,37 @@ export function buildConfigOptions(
|
||||
// but the SDK model list uses IDs like "claude-opus-4-6-1m".
|
||||
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
|
||||
|
||||
// The id-suffix spelling of a context hint ("-1m" in "claude-opus-4-6-1m");
|
||||
// shared by the strip and canonicalize helpers below so the two can't drift.
|
||||
const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
|
||||
|
||||
/** Remove context-window hints — the display form "[1m]" and the SDK id
|
||||
* suffix form "-1m" — from a model string. Those digits describe context
|
||||
* size, not model identity or generation version. */
|
||||
function stripContextHints(s: string): string {
|
||||
return s.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
|
||||
}
|
||||
|
||||
/** Canonicalize a model id for exact comparison: trimmed, lowercased, with
|
||||
* the id-suffix hint spelling unified to the bracket form ("-1m" → "[1m]").
|
||||
* The hint itself is kept — bare and 1M ids must stay distinct. */
|
||||
function canonicalizeModelId(s: string): string {
|
||||
return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
|
||||
}
|
||||
|
||||
/** The context hint a model string carries ("1m" for either spelling), or
|
||||
* null for a bare id. */
|
||||
function contextHintOf(s: string): string | null {
|
||||
return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
|
||||
}
|
||||
|
||||
// Captures a model family version: `4-6`/`4.7` for dated generations, or a
|
||||
// bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
|
||||
// `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
|
||||
const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
|
||||
|
||||
function extractModelFamilyVersion(s: string): string | null {
|
||||
// Strip "[1m]"-style context hints first — that digit is context window
|
||||
// size, not a model generation version.
|
||||
const match = s.replace(/\[\d+m\]/gi, "").match(MODEL_FAMILY_VERSION_PATTERN);
|
||||
const match = stripContextHints(s).match(MODEL_FAMILY_VERSION_PATTERN);
|
||||
if (!match) return null;
|
||||
return match[2] ? `${match[1]}.${match[2]}` : match[1];
|
||||
}
|
||||
@@ -4387,11 +4507,13 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
// Exact match on value or display name
|
||||
// Exact match on value or display name. Values compare on the canonical
|
||||
// hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa).
|
||||
const canonicalPreference = canonicalizeModelId(trimmed);
|
||||
const directMatch = models.find(
|
||||
(model) =>
|
||||
model.value === trimmed ||
|
||||
model.value.toLowerCase() === lower ||
|
||||
canonicalizeModelId(model.value) === canonicalPreference ||
|
||||
model.displayName.toLowerCase() === lower,
|
||||
);
|
||||
if (directMatch) return directMatch;
|
||||
@@ -4399,18 +4521,27 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
|
||||
// Exact match on the alias's canonical resolved id (e.g. a pinned
|
||||
// "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK-
|
||||
// reported and unambiguous, so it's tried before the fuzzier tiers below.
|
||||
// "default" is skipped first since it shares a resolvedModel with
|
||||
// whichever alias the CLI currently recommends — a specific pin should
|
||||
// land on that named alias, not "default".
|
||||
// Compared on the canonical hint spelling so a "-1m"-suffix pin matches a
|
||||
// "[1m]"-spelled resolvedModel instead of falling into the substring tier
|
||||
// (which would land on the bare 200k sibling). "default" is skipped first
|
||||
// since it shares a resolvedModel with whichever alias the CLI currently
|
||||
// recommends — a specific pin should land on that named alias, not
|
||||
// "default".
|
||||
const matchesResolved = (model: ModelInfo) =>
|
||||
model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
|
||||
const resolvedMatch =
|
||||
models.find(
|
||||
(model) => model.value !== "default" && model.resolvedModel?.toLowerCase() === lower,
|
||||
) ?? models.find((model) => model.resolvedModel?.toLowerCase() === lower);
|
||||
models.find((model) => model.value !== "default" && matchesResolved(model)) ??
|
||||
models.find(matchesResolved);
|
||||
if (resolvedMatch) return resolvedMatch;
|
||||
|
||||
// Substring match
|
||||
// Substring match. Skips candidates whose context hint disagrees with the
|
||||
// preference's — a bare row must not absorb a 1M-hinted preference (nor
|
||||
// vice versa); such pairs fall through to the tokenized tier, which
|
||||
// weighs hints in its scoring and still finds the best same-family row.
|
||||
const preferenceHint = contextHintOf(trimmed);
|
||||
const includesMatch = models.find((model) => {
|
||||
if (!modelVersionsCompatible(trimmed, model)) return false;
|
||||
if (contextHintOf(model.value) !== preferenceHint) return false;
|
||||
const value = model.value.toLowerCase();
|
||||
const display = model.displayName.toLowerCase();
|
||||
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
||||
@@ -4435,6 +4566,61 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/** Map the live model reported by a resumed session onto the picker's model
|
||||
* list. The CLI restores a resumed session's model from the transcript's
|
||||
* last assistant message, which records the concrete API id (e.g.
|
||||
* "claude-opus-4-6") with any "[1m]" context hint dropped. Tiers, in order:
|
||||
* 1. Exact match with the Default entry's resolution — when a named alias
|
||||
* shares Default's resolvedModel verbatim, the live id can't tell the
|
||||
* two apart, and a never-customized session should stay on Default.
|
||||
* 2. Exact resolvedModel match on a named row. Checked before the
|
||||
* hint-stripped Default comparison so a live "claude-sonnet-5[1m]" lands
|
||||
* on the "sonnet[1m]" row rather than a Default that resolves to the
|
||||
* bare "claude-sonnet-5" — the two rows differ in context window, which
|
||||
* drives `contextWindowSize` and capability gating downstream.
|
||||
* 3. Hint-stripped match with Default's resolution — a session that never
|
||||
* left the default resumes as the bare transcript id, and shouldn't show
|
||||
* a concrete picker entry.
|
||||
* 4. `resolveModelPreference` over the picker entries.
|
||||
* 5. A model with no picker counterpart (e.g. excluded by an
|
||||
* `availableModels` allowlist) is tracked verbatim, mirroring
|
||||
* `syncModelAfterRefusalFallback`: the picker shows no selection, but the
|
||||
* model-dependent bookkeeping stays truthful to what the SDK is running. */
|
||||
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {
|
||||
const live = canonicalizeModelId(liveModel);
|
||||
const defaultEntry = models.find((m) => m.value === "default");
|
||||
const defaultResolved = defaultEntry?.resolvedModel
|
||||
? canonicalizeModelId(defaultEntry.resolvedModel)
|
||||
: undefined;
|
||||
|
||||
if (defaultEntry && defaultResolved === live) {
|
||||
return defaultEntry;
|
||||
}
|
||||
|
||||
// No default-row exclusion needed: a default row matching `live` exactly
|
||||
// already returned at the tier above.
|
||||
const exactMatch = models.find(
|
||||
(m) => m.resolvedModel && canonicalizeModelId(m.resolvedModel) === live,
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
if (
|
||||
defaultEntry &&
|
||||
defaultResolved &&
|
||||
stripContextHints(defaultResolved) === stripContextHints(live)
|
||||
) {
|
||||
return defaultEntry;
|
||||
}
|
||||
|
||||
return (
|
||||
resolveModelPreference(models, liveModel) ?? {
|
||||
value: liveModel,
|
||||
displayName: liveModel,
|
||||
description: "",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSettingsModel(
|
||||
models: ModelInfo[],
|
||||
settingsModel: unknown,
|
||||
@@ -4527,12 +4713,32 @@ export function applyAvailableModelsAllowlist(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Read the model a resumed session is actually running (via the
|
||||
* `getContextUsage` control request — the same source `/context` prints) and
|
||||
* map it onto the picker. Best-effort: a control-request failure is logged
|
||||
* and returns null so callers keep their current choice; failing the whole
|
||||
* session/load over an unreadable report would be worse. */
|
||||
async function readResumedLiveModel(
|
||||
query: Query,
|
||||
models: ModelInfo[],
|
||||
logger: Logger,
|
||||
): Promise<ModelInfo | null> {
|
||||
try {
|
||||
const liveModel = (await query.getContextUsage()).model;
|
||||
return liveModel ? matchResumedModel(models, liveModel) : null;
|
||||
} catch (error) {
|
||||
logger.error("Failed to read the resumed session's live model:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailableModels(
|
||||
query: Query,
|
||||
models: ModelInfo[],
|
||||
sdkModels: ModelInfo[],
|
||||
settingsManager: SettingsManager,
|
||||
logger: Logger,
|
||||
isResumedSession: boolean,
|
||||
): Promise<SessionModelState> {
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
@@ -4542,7 +4748,8 @@ async function getAvailableModels(
|
||||
// Model priority (highest to lowest):
|
||||
// 1. ANTHROPIC_MODEL environment variable
|
||||
// 2. settings.model (user configuration)
|
||||
// 3. models[0] (default first model)
|
||||
// 3. the resumed session's live model (resumed sessions only)
|
||||
// 4. models[0] (default first model)
|
||||
if (process.env.ANTHROPIC_MODEL) {
|
||||
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
|
||||
if (match) {
|
||||
@@ -4557,22 +4764,49 @@ async function getAvailableModels(
|
||||
}
|
||||
}
|
||||
|
||||
// A resumed session restores the model it was previously running (the CLI
|
||||
// re-reads it from the transcript), so without an env/settings override the
|
||||
// freshly-computed default above can disagree with what the session actually
|
||||
// runs — session/load then reports a model the session isn't using (issue
|
||||
// #845). Ask the CLI for the live model and reflect it. No `setModel` here:
|
||||
// the SDK is already running this model, and pushing a picker alias back
|
||||
// (e.g. "opus[1m]") could change the live model rather than describe it.
|
||||
if (resolvedFromInput === undefined && isResumedSession) {
|
||||
currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
|
||||
}
|
||||
|
||||
// Skip the setModel round-trip when we can prove the SDK has already landed
|
||||
// on the same model. Two cases qualify:
|
||||
// (a) No override applied — currentModel stayed at models[0]; the SDK is on
|
||||
// its own default and we have nothing to sync.
|
||||
// (a) No override applied — currentModel is the SDK's own default (or, on
|
||||
// resume, the live model read back from the SDK above); nothing to sync.
|
||||
// (b) The resolver returned the user's input verbatim AND that value exists
|
||||
// in the SDK's original model list — meaning no fuzzy match or
|
||||
// allowlist rewrite was involved, and the SDK (which reads the same
|
||||
// ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
|
||||
// This only holds for fresh sessions: a resumed session lands on the
|
||||
// transcript's model regardless of env/settings, so the override must
|
||||
// be re-asserted to keep the reported model truthful.
|
||||
// Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
|
||||
// setModel call so we don't drift from the user's intended pin.
|
||||
const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
|
||||
const skipSetModel =
|
||||
resolvedFromInput === undefined ||
|
||||
(currentModel.value === resolvedFromInput && sdkSawSameValue);
|
||||
(!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
|
||||
if (!skipSetModel) {
|
||||
await query.setModel(currentModel.value);
|
||||
try {
|
||||
await query.setModel(currentModel.value);
|
||||
} catch (error) {
|
||||
// On a fresh session the pin is a defining option — fail loudly. A
|
||||
// resumed session already runs fine on the transcript's model, so
|
||||
// failing the whole session/load over the re-assert would be worse
|
||||
// than loading with the pin unapplied (mirrors the setPermissionMode
|
||||
// containment in createSession). The SDK then stayed on the
|
||||
// transcript's model, so read that back rather than reporting the
|
||||
// pin the session isn't running.
|
||||
if (!isResumedSession) throw error;
|
||||
logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
|
||||
currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -4888,8 +5122,8 @@ export function toAcpNotifications(
|
||||
let update: SessionNotification["update"] | null = null;
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
case "text_delta":
|
||||
if (chunk.text.length > 0) {
|
||||
case "text_delta": {
|
||||
if (chunk.text) {
|
||||
update = {
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
@@ -4899,6 +5133,7 @@ export function toAcpNotifications(
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "image":
|
||||
update = {
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
@@ -4911,10 +5146,10 @@ export function toAcpNotifications(
|
||||
};
|
||||
break;
|
||||
case "thinking":
|
||||
case "thinking_delta":
|
||||
case "thinking_delta": {
|
||||
// Recent models default `thinking.display` to "omitted", which streams
|
||||
// signature-only thinking blocks whose text is empty.
|
||||
if (chunk.thinking.length > 0) {
|
||||
if (chunk.thinking) {
|
||||
update = {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
@@ -4924,6 +5159,7 @@ export function toAcpNotifications(
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_use":
|
||||
case "server_tool_use":
|
||||
case "mcp_tool_use": {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { CreateElicitationResponse } from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ElicitationAcceptAction,
|
||||
ElicitationContentValue,
|
||||
ElicitationPropertySchema,
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
@@ -70,43 +69,18 @@ export function mcpElicitationToCreateRequest(
|
||||
};
|
||||
}
|
||||
|
||||
/** A wire value allowed in elicitation form content. */
|
||||
function isElicitationContentValue(value: unknown): value is ElicitationContentValue {
|
||||
return (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
(Array.isArray(value) && value.every((item) => typeof item === "string"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of an accepted elicitation response.
|
||||
*
|
||||
* The response union includes a custom/future-action variant whose `action` is
|
||||
* plain `string`, so a positive `=== "accept"` check can never narrow it away
|
||||
* and `content` reads as `unknown`. Rather than asserting which union member
|
||||
* this is, validate the values: the SDK's own wire validation has the same
|
||||
* blind spot (an accept response with malformed content parses via the
|
||||
* catch-all variant), so ill-typed values are dropped here either way.
|
||||
* Uses the SDK's validating guard rather than an `action === "accept"` check:
|
||||
* the guard both narrows past the union's custom/future variant and validates
|
||||
* the payload, so a malformed accept (right tag, ill-typed content) yields
|
||||
* empty content — the same classification the SDK's wire validators apply.
|
||||
*/
|
||||
function acceptedElicitationContent(
|
||||
response: CreateElicitationResponse,
|
||||
): NonNullable<ElicitationAcceptAction["content"]> {
|
||||
if (response.action !== "accept") {
|
||||
return {};
|
||||
}
|
||||
const { content } = response;
|
||||
if (!content || typeof content !== "object") {
|
||||
return {};
|
||||
}
|
||||
const validated: NonNullable<ElicitationAcceptAction["content"]> = {};
|
||||
for (const [key, value] of Object.entries(content)) {
|
||||
if (isElicitationContentValue(value)) {
|
||||
validated[key] = value;
|
||||
}
|
||||
}
|
||||
return validated;
|
||||
return CreateElicitationResponse.isAccept(response) ? (response.content ?? {}) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
38
src/sdk-shims.d.ts
vendored
38
src/sdk-shims.d.ts
vendored
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Shims for types the `@anthropic-ai/claude-agent-sdk` 0.3.198 `sdk.d.ts`
|
||||
* references in its `SDKMessage` union but never declares
|
||||
* (`SDKControlRequestProgressMessage`, `SDKConversationResetMessage`). With
|
||||
* `skipLibCheck` the dangling references silently become error-`any`, which
|
||||
* poisons narrowing on the whole union — every exhaustive switch over
|
||||
* `SDKMessage` stops compiling.
|
||||
*
|
||||
* These must stay GLOBAL declarations (no top-level import/export in this
|
||||
* file): unqualified names inside the SDK's module declaration fall back to
|
||||
* the global scope, which is the only way a consumer can supply them. Shapes
|
||||
* are transcribed from the zod schemas embedded in the bundled CLI binary.
|
||||
* Delete this file once the SDK ships the declarations itself — a proper
|
||||
* module-scoped declaration will shadow these.
|
||||
*/
|
||||
|
||||
/** Progress for a long-running client-originated control request (currently
|
||||
* only `side_question`), correlated by `request_id`. */
|
||||
declare type SDKControlRequestProgressMessage = {
|
||||
type: "control_request_progress";
|
||||
request_id: string;
|
||||
status: "started" | "api_retry";
|
||||
attempt?: number;
|
||||
max_retries?: number;
|
||||
retry_delay_ms?: number;
|
||||
error_status?: number | null;
|
||||
uuid: import("crypto").UUID;
|
||||
session_id: string;
|
||||
};
|
||||
|
||||
/** Emitted by `/clear`, plan-mode exit, and fresh-session flows; the surface
|
||||
* should mount a fresh transcript under `new_conversation_id`. */
|
||||
declare type SDKConversationResetMessage = {
|
||||
type: "conversation_reset";
|
||||
new_conversation_id: import("crypto").UUID;
|
||||
uuid: import("crypto").UUID;
|
||||
session_id: string;
|
||||
};
|
||||
@@ -82,6 +82,17 @@ function userEcho(u: any) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The `usage` a cancelled active turn settles with when the cancel pre-empted
|
||||
* its result: all zeros, since only a turn's terminal result feeds the
|
||||
* accumulator (issue #844). */
|
||||
const cancelledTurnUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
|
||||
/** Wrap a mock async generator with the `Query` methods the agent calls outside
|
||||
* of iteration — `close()` (teardown/closeQueryStream), `interrupt()` (cancel),
|
||||
* and `setModel()` — so a bare generator doesn't trip "x is not a function". */
|
||||
@@ -204,9 +215,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
params: CreateElicitationRequest,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
this.elicitations.push(params);
|
||||
// The `in` check also excludes the custom/future-mode variant, whose
|
||||
// `mode: string` would otherwise survive the literal comparison.
|
||||
if (params.mode !== "form" || !("requestedSchema" in params)) {
|
||||
if (!CreateElicitationRequest.isForm(params)) {
|
||||
return { action: "decline" };
|
||||
}
|
||||
// Accept the first option of every choice field (skip the free-text one).
|
||||
@@ -437,10 +446,9 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
// ...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" && "requestedSchema" in elicitation
|
||||
? Object.keys(elicitation.requestedSchema.properties ?? {})
|
||||
: [];
|
||||
const properties = CreateElicitationRequest.isForm(elicitation)
|
||||
? Object.keys(elicitation.requestedSchema.properties ?? {})
|
||||
: [];
|
||||
expect(properties).toContain("question_0");
|
||||
expect(properties).toContain("question_0_custom");
|
||||
|
||||
@@ -4808,6 +4816,22 @@ describe("assembled assistant text fallback", () => {
|
||||
expect(messageChunkTexts(updates)).toEqual(["real answer"]);
|
||||
});
|
||||
|
||||
it("dedupes a streamed text block even when a thinking delta omits text", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
messageStart("msg-missing-thinking"),
|
||||
thinkingDelta(undefined as any),
|
||||
textDelta("real answer"),
|
||||
assistantMessage("msg-missing-thinking", [{ type: "text", text: "real answer" }]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
expect(messageChunkTexts(updates)).toEqual(["real answer"]);
|
||||
});
|
||||
|
||||
it("does not re-emit the next turn's text after a turn is cancelled mid-stream", async () => {
|
||||
// Regression: streamedBlocks is reset inside the consolidated-assistant
|
||||
// branch, but a cancelled turn `break`s out before reaching it (the
|
||||
@@ -4863,7 +4887,10 @@ describe("assembled assistant text fallback", () => {
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
releaseCancel();
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "second" }] });
|
||||
|
||||
@@ -5791,10 +5818,55 @@ describe("post-error recovery", () => {
|
||||
prompt: [{ type: "text", text: "second" }],
|
||||
});
|
||||
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
await expect(second).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" }));
|
||||
});
|
||||
|
||||
it("exposes the accumulated usage on a cancelled turn's PromptResponse (issue #844)", async () => {
|
||||
// The interrupted turn's result is dropped at the `session.cancelled`
|
||||
// guard, but its usage was already accumulated — the cancelled settle must
|
||||
// report it so clients metering token spend don't lose the round-trips
|
||||
// that completed before the cancel.
|
||||
const agent = createMockAgent();
|
||||
let releaseAfterCancel!: () => void;
|
||||
const afterCancel = new Promise<void>((resolve) => (releaseAfterCancel = resolve));
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value); // turn 1 active
|
||||
await afterCancel; // wait until the test has cancelled turn 1
|
||||
// The interrupt still yields the turn's result (usage 10/5) before the
|
||||
// trailing idle that settles it cancelled.
|
||||
yield createResultMessage({ subtype: "success", stop_reason: "end_turn", is_error: false });
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const first = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
releaseAfterCancel();
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
totalTokens: 15,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores cancel() after the query stream has closed (no interrupt on a dead query)", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
@@ -5893,7 +5965,10 @@ describe("post-error recovery", () => {
|
||||
|
||||
releaseEnd(); // stream ends -> done branch settles turn 1 + rejects turn 2
|
||||
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
await expect(second).rejects.toThrow(/start a new session/);
|
||||
});
|
||||
|
||||
@@ -5934,7 +6009,10 @@ describe("post-error recovery", () => {
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
releaseAfterCancel();
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
|
||||
// session.cancelled is still true here (turn 1 settled, nothing re-activated).
|
||||
// The /compact result must still settle via head-promotion.
|
||||
@@ -5997,7 +6075,12 @@ describe("post-error recovery", () => {
|
||||
});
|
||||
afterCancelAndQueue();
|
||||
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
// Turn 1 ran (and was cancelled mid-flight) so it reports its usage;
|
||||
// turn 2 never ran, so its cancelled settle carries none.
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
await expect(second).resolves.toEqual({ stopReason: "cancelled" });
|
||||
const thirdResult = await third;
|
||||
expect(thirdResult.stopReason).toBe("end_turn");
|
||||
@@ -6065,7 +6148,10 @@ describe("post-error recovery", () => {
|
||||
});
|
||||
release();
|
||||
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
await expect(second).resolves.toEqual({ stopReason: "cancelled" });
|
||||
const compactResult = await compact;
|
||||
expect(compactResult.stopReason).toBe("end_turn");
|
||||
@@ -6074,6 +6160,145 @@ describe("post-error recovery", () => {
|
||||
expect(compactResult.usage?.inputTokens).toBe(10);
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
});
|
||||
|
||||
it("uncounts an orphan the interrupt receipt reports dropped, so a no-echo result isn't swallowed", async () => {
|
||||
// interrupt_receipt_v1 CLIs: interrupt() resolves with `still_queued` —
|
||||
// the queued messages that will still run. Here the interrupt DROPS turn
|
||||
// 2's queued message (still_queued: []), so its orphan result never
|
||||
// arrives. Without the receipt reconciliation the stale count (1) would
|
||||
// swallow the next echo-less result — the /compact below — and hang its
|
||||
// prompt; activation's reset can't help because /compact never echoes.
|
||||
const agent = createMockAgent();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value); // turn 1 active
|
||||
await iter.next(); // turn 2's pushed message (cancelled; interrupt drops it)
|
||||
await gate; // wait until the test cancels and sends /compact
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" }; // turn 1 settles cancelled
|
||||
// NO orphan result for turn 2 — the interrupt dropped its queued message.
|
||||
await iter.next(); // /compact's pushed message — never echoes its uuid
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "status",
|
||||
status: "compacting",
|
||||
session_id: "test-session",
|
||||
};
|
||||
yield createResultMessage({ subtype: "success", stop_reason: "end_turn", is_error: false });
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
// New-CLI interrupt: nothing queued survives the interrupt.
|
||||
agent.sessions["test-session"]!.query.interrupt = vi.fn(async () => ({ still_queued: [] }));
|
||||
|
||||
const first = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
const second = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "second" }],
|
||||
});
|
||||
await waitFor(() => (agent.sessions["test-session"]?.turnQueue?.length ?? 0) >= 2);
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" }); // counts turn 2, then the receipt uncounts it
|
||||
expect(agent.sessions["test-session"]?.pendingOrphanResults).toBe(0);
|
||||
const compact = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
});
|
||||
release();
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
await expect(second).resolves.toEqual({ stopReason: "cancelled" });
|
||||
const compactResult = await compact;
|
||||
expect(compactResult.stopReason).toBe("end_turn");
|
||||
expect(compactResult.usage?.inputTokens).toBe(10);
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
});
|
||||
|
||||
it("keeps counting an orphan the interrupt receipt reports still queued", async () => {
|
||||
// The receipt lists turn 2's uuid in still_queued — its message survives
|
||||
// the interrupt, runs, and emits an orphan result that must still be
|
||||
// skipped (not promoted onto the later /compact).
|
||||
const agent = createMockAgent();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => (release = resolve));
|
||||
|
||||
const orphanResult = createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
});
|
||||
orphanResult.usage.input_tokens = 999;
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value); // turn 1 active
|
||||
await iter.next(); // turn 2's pushed message (cancelled, but survives the interrupt)
|
||||
await gate;
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" }; // turn 1 settles cancelled
|
||||
yield orphanResult; // turn 2's orphan — the kept count skips it
|
||||
await iter.next(); // /compact's pushed message — never echoes its uuid
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "status",
|
||||
status: "compacting",
|
||||
session_id: "test-session",
|
||||
};
|
||||
yield createResultMessage({ subtype: "success", stop_reason: "end_turn", is_error: false });
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
const first = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
const second = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "second" }],
|
||||
});
|
||||
await waitFor(() => (agent.sessions["test-session"]?.turnQueue?.length ?? 0) >= 2);
|
||||
|
||||
// Report turn 2's queued message as surviving the interrupt. Captured
|
||||
// before cancel() filters the queue (interrupt runs after the filter).
|
||||
const session = agent.sessions["test-session"]!;
|
||||
const survivingUuid = session.turnQueue![1].promptUuid;
|
||||
session.query.interrupt = vi.fn(async () => ({ still_queued: [survivingUuid] }));
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
expect(agent.sessions["test-session"]?.pendingOrphanResults).toBe(1);
|
||||
const compact = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
});
|
||||
release();
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
await expect(second).resolves.toEqual({ stopReason: "cancelled" });
|
||||
const compactResult = await compact;
|
||||
expect(compactResult.stopReason).toBe("end_turn");
|
||||
// /compact settled with its OWN result, proving the surviving orphan was
|
||||
// skipped rather than promoted onto it (which would leak the 999).
|
||||
expect(compactResult.usage?.inputTokens).toBe(10);
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
@@ -6233,7 +6458,10 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
|
||||
// Clean up the wedged prompt + long timer.
|
||||
await agent.closeSession({ sessionId: "test-session" });
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(promptPromise).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves an in-flight wedged prompt immediately when the session is closed", async () => {
|
||||
@@ -6252,7 +6480,10 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
|
||||
await agent.closeSession({ sessionId: "test-session" });
|
||||
|
||||
await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(promptPromise).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
expect(agent.sessions["test-session"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6370,7 +6601,10 @@ describe("turn abandoned by the SDK (issue #825)", () => {
|
||||
prompt: [{ type: "text", text: "second" }],
|
||||
});
|
||||
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
const secondResult = await second;
|
||||
expect(secondResult.stopReason).toBe("end_turn");
|
||||
expect(secondResult.usage?.inputTokens).toBe(10);
|
||||
@@ -6416,7 +6650,10 @@ describe("turn abandoned by the SDK (issue #825)", () => {
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
releaseAfterCancel();
|
||||
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
await expect(second).resolves.toEqual({ stopReason: "cancelled" });
|
||||
const third = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
@@ -6461,7 +6698,10 @@ describe("turn abandoned by the SDK (issue #825)", () => {
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
await agent.cancel({ sessionId: "test-session" }); // backstop (10ms) settles turn 1
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
await expect(first).resolves.toEqual({
|
||||
stopReason: "cancelled",
|
||||
usage: cancelledTurnUsage,
|
||||
});
|
||||
|
||||
// Queue turn 2 BEFORE the SDK recovers, so the stale result races it.
|
||||
const second = agent.prompt({
|
||||
@@ -6710,6 +6950,36 @@ describe("toAcpNotifications thinking chunks", () => {
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips thinking chunks without string content", () => {
|
||||
const result = toAcpNotifications(
|
||||
[
|
||||
{ type: "thinking", signature: "abc" },
|
||||
{ type: "thinking_delta", estimated_tokens: 0 },
|
||||
{ type: "thinking_delta", thinking: null, estimated_tokens: 0 },
|
||||
] as any,
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
console,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips text deltas without string content", () => {
|
||||
const result = toAcpNotifications(
|
||||
[{ type: "text_delta" }, { type: "text_delta", text: null }] as any,
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
console,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageIdForGrouping", () => {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { resolveModelPreference, applyAvailableModelsAllowlist } from "../acp-agent.js";
|
||||
import {
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
matchResumedModel,
|
||||
} from "../acp-agent.js";
|
||||
|
||||
// Mirrors a real `supportedModels()` response: alias rows carry
|
||||
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
|
||||
@@ -68,6 +72,124 @@ describe("resolveModelPreference - resolvedModel matching", () => {
|
||||
it("returns null for a preference that matches nothing", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches a '-1m' id-suffix preference against a '[1m]'-spelled resolvedModel", () => {
|
||||
// Without hint canonicalization the exact tier misses and the substring
|
||||
// tier lands on the bare 200k sibling, silently downgrading a 1M pin.
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 · Efficient for routine tasks",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
resolvedModel: "claude-sonnet-5[1m]",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 with 1M context",
|
||||
},
|
||||
];
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5-1m")?.value).toBe("sonnet[1m]");
|
||||
});
|
||||
|
||||
it("matches a '-1m'-suffix alias directly against a '[1m]'-spelled value", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "opus-1m")?.value).toBe("opus[1m]");
|
||||
});
|
||||
|
||||
// Rows without resolvedModel (older SDKs, allowlist-synthesized entries,
|
||||
// option-derived lists) can't use the exact resolved tier, so the hinted
|
||||
// preference must not be absorbed by the bare sibling in the substring
|
||||
// tier — the tokenized tier scores the hinted row correctly instead.
|
||||
it("does not let a bare sibling absorb a hinted preference when resolvedModel is absent", () => {
|
||||
const models: ModelInfo[] = [
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "" },
|
||||
{ value: "sonnet[1m]", displayName: "Sonnet", description: "" },
|
||||
];
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5[1m]")?.value).toBe("sonnet[1m]");
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5-1m")?.value).toBe("sonnet[1m]");
|
||||
// Bare preferences still land on the bare row via the substring tier.
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5")?.value).toBe("sonnet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchResumedModel", () => {
|
||||
// Environment shape from issue #845: the default resolves to Sonnet, the
|
||||
// session was explicitly switched to Opus before the process restart.
|
||||
const SONNET_DEFAULT_MODELS: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Use the default model (currently Sonnet 5)",
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
resolvedModel: "claude-opus-4-6[1m]",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 · Efficient for routine tasks",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
resolvedModel: "claude-sonnet-5[1m]",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 with 1M context",
|
||||
},
|
||||
];
|
||||
|
||||
it("maps an explicitly-switched session's live model to its picker alias", () => {
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-opus-4-6").value).toBe("opus");
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-opus-4-6[1m]").value).toBe("opus");
|
||||
});
|
||||
|
||||
it("keeps Default selected when the live model is what Default resolves to", () => {
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-sonnet-5").value).toBe("default");
|
||||
});
|
||||
|
||||
it("prefers an exact named-row resolvedModel match over the hint-stripped Default tier", () => {
|
||||
// "claude-sonnet-5[1m]" differs from Default's "claude-sonnet-5" only by
|
||||
// the context hint; stripping both would misreport the explicit 1M row as
|
||||
// Default (and infer the wrong context window downstream). Both hint
|
||||
// spellings must land on the row regardless of which one it uses.
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-sonnet-5[1m]").value).toBe(
|
||||
"sonnet[1m]",
|
||||
);
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-sonnet-5-1m").value).toBe("sonnet[1m]");
|
||||
});
|
||||
|
||||
it("normalizes the '-1m' id-suffix hint form and casing against Default's resolution", () => {
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-opus-4-8-1m").value).toBe("default");
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "Claude-Opus-4-8[1M]").value).toBe("default");
|
||||
});
|
||||
|
||||
it("prefers Default over a named row sharing the identical resolvedModel", () => {
|
||||
// "opus[1m]" carries the same resolvedModel as Default; the live id can't
|
||||
// tell the two apart, so a never-customized session stays on Default.
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-opus-4-8[1m]").value).toBe("default");
|
||||
});
|
||||
|
||||
it("ignores '[1m]' context hints when comparing against Default's resolution", () => {
|
||||
// The transcript-restored model drops the "[1m]" context hint, so a
|
||||
// session that never left the 1M-context default resumes as the bare id.
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-opus-4-8").value).toBe("default");
|
||||
});
|
||||
|
||||
it("still resolves non-default live models against the picker entries", () => {
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-sonnet-5").value).toBe("sonnet");
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-haiku-4-5-20251001").value).toBe("haiku");
|
||||
});
|
||||
|
||||
it("tracks a live model with no picker counterpart verbatim", () => {
|
||||
const result = matchResumedModel(LIVE_SHAPED_MODELS, "claude-gpt-99");
|
||||
expect(result.value).toBe("claude-gpt-99");
|
||||
expect(result.displayName).toBe("claude-gpt-99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - resolvedModel matching", () => {
|
||||
|
||||
@@ -819,6 +819,85 @@ describe("session config options", () => {
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// Option entries carry no resolvedModel, so alias resolution must consult
|
||||
// session.modelInfos — otherwise a full model id in either hint spelling
|
||||
// ("[1m]"/"-1m") falls to the substring tier and lands on the bare 200k
|
||||
// sibling, silently downgrading the session's context lane.
|
||||
it("resolves a full model id onto its hinted row via session.modelInfos", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.models = {
|
||||
currentModelId: "sonnet",
|
||||
availableModels: [
|
||||
{ modelId: "sonnet", name: "Sonnet", description: "" },
|
||||
{ modelId: "sonnet[1m]", name: "Sonnet", description: "" },
|
||||
],
|
||||
};
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
resolvedModel: "claude-sonnet-5[1m]",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
session.configOptions = session.configOptions.map((o: { id: string }) =>
|
||||
o.id === "model"
|
||||
? {
|
||||
...o,
|
||||
currentValue: "sonnet",
|
||||
options: [
|
||||
{ value: "sonnet", name: "Sonnet" },
|
||||
{ value: "sonnet[1m]", name: "Sonnet" },
|
||||
],
|
||||
}
|
||||
: o,
|
||||
);
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-5[1m]",
|
||||
});
|
||||
expect(setModelSpy).toHaveBeenCalledWith("sonnet[1m]");
|
||||
|
||||
setModelSpy.mockClear();
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-5-1m",
|
||||
});
|
||||
expect(setModelSpy).toHaveBeenCalledWith("sonnet[1m]");
|
||||
});
|
||||
|
||||
// A session can be running a model with no picker entry (resumed onto a
|
||||
// model excluded by the availableModels allowlist, or a refusal
|
||||
// fallback); its verbatim id is then the option's currentValue. A client
|
||||
// round-tripping that reported value must not get "Invalid value".
|
||||
it("accepts the reported currentValue even when it has no options entry", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.models = { ...session.models, currentModelId: "claude-offlist-9" };
|
||||
session.configOptions = session.configOptions.map((o: { id: string }) =>
|
||||
o.id === "model" ? { ...o, currentValue: "claude-offlist-9" } : o,
|
||||
);
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-offlist-9",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-offlist-9");
|
||||
expect(response.configOptions?.find((o) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-offlist-9",
|
||||
);
|
||||
});
|
||||
|
||||
it("setSessionMode also syncs configOptions", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
function createMockClient(): AcpClient {
|
||||
function createMockClient(
|
||||
sessionUpdate: (notification: SessionNotification) => Promise<void> = async () => {},
|
||||
): AcpClient {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
sessionUpdate,
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
@@ -330,4 +332,52 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
}
|
||||
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
// Regression test for https://github.com/agentclientprotocol/claude-agent-acp/issues/845
|
||||
// On resume the CLI restores the model the session's transcript was running,
|
||||
// so loadSession must report that live model instead of recomputing the
|
||||
// env/settings/default choice from scratch. Haiku is used because no account
|
||||
// resolves its default there, so the restored model provably differs from
|
||||
// the freshly-computed default. Requires no ANTHROPIC_MODEL env var and no
|
||||
// "model" key in settings.json (either would pin the model on resume and
|
||||
// mask the reconciliation this test covers).
|
||||
it("ACP: loadSession reports the model the resumed session is actually running", async () => {
|
||||
const agentChunks: string[] = [];
|
||||
const client = createMockClient(async (notification) => {
|
||||
if (notification.update.sessionUpdate === "agent_message_chunk") {
|
||||
const content = notification.update.content;
|
||||
if (content.type === "text") agentChunks.push(content.text);
|
||||
}
|
||||
});
|
||||
|
||||
const modelOf = (opts: { id: string; currentValue?: unknown }[] | null | undefined) =>
|
||||
opts?.find((o) => o.id === "model")?.currentValue;
|
||||
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
const { sessionId, configOptions } = await agent.newSession({ cwd, mcpServers: [] });
|
||||
expect(modelOf(configOptions)).not.toBe("haiku");
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "haiku" });
|
||||
// A real turn on Haiku, so the transcript's last assistant message —
|
||||
// which is what the CLI restores the model from — records it.
|
||||
await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "Reply with only the word OK" }],
|
||||
});
|
||||
await agent.closeSession({ sessionId });
|
||||
|
||||
const loadResp = await agent.loadSession({ sessionId, cwd, mcpServers: [] });
|
||||
expect(modelOf(loadResp.configOptions)).toBe("haiku");
|
||||
|
||||
// And the reported option agrees with the live session (`/context`).
|
||||
agentChunks.length = 0;
|
||||
await agent.prompt({ sessionId, prompt: [{ type: "text", text: "/context" }] });
|
||||
const liveModel = agentChunks.join("\n").match(/\*\*Model:\*\*\s*(\S+)/)?.[1];
|
||||
expect(liveModel).toContain("haiku");
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user