mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-04 00:12:14 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5e1b7fa72 | ||
|
|
960f62d765 | ||
|
|
508453c288 | ||
|
|
8dc8c86426 | ||
|
|
b4b7f561e2 | ||
|
|
41cde99fd7 | ||
|
|
5729c471f9 | ||
|
|
a759e64ef6 | ||
|
|
15fdf26fc7 | ||
|
|
9f38cb67df | ||
|
|
744b2d4112 | ||
|
|
2554c7bf98 | ||
|
|
1c80bf8e56 | ||
|
|
ad3b5fe745 | ||
|
|
794aa84684 | ||
|
|
12d34e64e5 | ||
|
|
59a098c2b5 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.46.0"
|
||||
".": "0.49.0"
|
||||
}
|
||||
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,5 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## [0.49.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.48.0...v0.49.0) (2026-06-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.3.185 ([#798](https://github.com/agentclientprotocol/claude-agent-acp/issues/798)) ([8dc8c86](https://github.com/agentclientprotocol/claude-agent-acp/commit/8dc8c864263fa50d03bec7ebff7aee776604cb3d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Deduplicate streamed assistant blocks by content ([#800](https://github.com/agentclientprotocol/claude-agent-acp/issues/800)) ([960f62d](https://github.com/agentclientprotocol/claude-agent-acp/commit/960f62d76582ae5c9c5575ab66974809049ce1d0))
|
||||
* Infer 1M context from model descriptions ([#799](https://github.com/agentclientprotocol/claude-agent-acp/issues/799)) ([508453c](https://github.com/agentclientprotocol/claude-agent-acp/commit/508453c288b4a12701abd507199e7fa0ab172171))
|
||||
|
||||
## [0.48.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.47.0...v0.48.0) (2026-06-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Agent selection dropdown in config options ([#794](https://github.com/agentclientprotocol/claude-agent-acp/issues/794)) ([5729c47](https://github.com/agentclientprotocol/claude-agent-acp/commit/5729c471f92e1d2a191eb2a6d18c2be47821e9ec))
|
||||
* **deps:** bump the minor group with 11 updates ([#787](https://github.com/agentclientprotocol/claude-agent-acp/issues/787)) ([ad3b5fe](https://github.com/agentclientprotocol/claude-agent-acp/commit/ad3b5fe74527f83964695a1e8056d010b981fc79))
|
||||
* Update to claude-agent-sdk 0.3.183 ([#791](https://github.com/agentclientprotocol/claude-agent-acp/issues/791)) ([744b2d4](https://github.com/agentclientprotocol/claude-agent-acp/commit/744b2d41128f67091d4136283594c0db11ea4db5))
|
||||
* Update to new ACP SDK patterns ([#790](https://github.com/agentclientprotocol/claude-agent-acp/issues/790)) ([2554c7b](https://github.com/agentclientprotocol/claude-agent-acp/commit/2554c7bf980472760a6e7810b826f030d2c3af25))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* duplicate assistant text when turn activates mid-message ([#789](https://github.com/agentclientprotocol/claude-agent-acp/issues/789)) ([1c80bf8](https://github.com/agentclientprotocol/claude-agent-acp/commit/1c80bf8e56a9279dc799e7bbdcae87241e99c18b))
|
||||
* Skip empty thinking chunks ([#793](https://github.com/agentclientprotocol/claude-agent-acp/issues/793)) ([15fdf26](https://github.com/agentclientprotocol/claude-agent-acp/commit/15fdf26fc7d3a6c51e89d3e56fc91dd00cb3d7ae))
|
||||
* surface Bash tool image output instead of dropping it ([#617](https://github.com/agentclientprotocol/claude-agent-acp/issues/617)) ([a759e64](https://github.com/agentclientprotocol/claude-agent-acp/commit/a759e64ef6d9b7c5bfe9a6e6b182db835f8ed3b6))
|
||||
|
||||
## [0.47.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.46.0...v0.47.0) (2026-06-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.3.179 ([#783](https://github.com/agentclientprotocol/claude-agent-acp/issues/783)) ([59a098c](https://github.com/agentclientprotocol/claude-agent-acp/commit/59a098c2b530bbae034e9a2dfbd31f8b4ef2a4d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Duplicate assistant messages in feed ([#785](https://github.com/agentclientprotocol/claude-agent-acp/issues/785)) ([12d34e6](https://github.com/agentclientprotocol/claude-agent-acp/commit/12d34e64e53564602ac1c38a30127e234c5c25ff))
|
||||
|
||||
## [0.46.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.45.1...v0.46.0) (2026-06-16)
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export default [
|
||||
"default-case": "error",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
eqeqeq: ["error", "smart"],
|
||||
curly: ["error", "all"],
|
||||
},
|
||||
},
|
||||
|
||||
126
package-lock.json
generated
126
package-lock.json
generated
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.46.0",
|
||||
"version": "0.49.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.46.0",
|
||||
"version": "0.49.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.26.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.178",
|
||||
"@agentclientprotocol/sdk": "0.28.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.185",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.104.2",
|
||||
"@anthropic-ai/sdk": "0.105.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.9.3",
|
||||
"@types/node": "26.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"eslint": "10.5.0",
|
||||
@@ -32,31 +32,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.26.0.tgz",
|
||||
"integrity": "sha512-ialrcI+RzKOYe+fw+TfpyTdRmEoqIkXLlwbTi6XgaXXfdhNcdod7TmE1VsTnG3yTlox8TMTSMQgWbLLbz3r86Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
|
||||
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.178",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.178.tgz",
|
||||
"integrity": "sha512-PNsz20jWuahDWq7OU+pjrgYzr2TnpC1oj5yCZDxGWJ8OvucXIdD2AYlf/vUo7oE2JJwGbMTBFqXErNrfPi6Ffw==",
|
||||
"version": "0.3.185",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.185.tgz",
|
||||
"integrity": "sha512-UDDd0cInvOufmR88btR/Ursnh71sb5scoutNlRS2L0LBdkt7JEqYJ0jt7DnOYHEVhMx8AAcg0eYgvXztcWUnFA==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.178",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.178",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.178",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.178",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.178",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.178",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.178",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.178"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.185",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.185",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.185",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.185",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.185",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.185",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.185",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.185"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -65,9 +65,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.178",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.178.tgz",
|
||||
"integrity": "sha512-RmIJRoZfjwcrd7cHR3fJOL1d4L8SN7oB0REcQPbIuPM1vav2Ft3g5hBX7I86u52A4LLFKBc3SMom3+E6lR1YtQ==",
|
||||
"version": "0.3.185",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.185.tgz",
|
||||
"integrity": "sha512-P2Svxx1IFrS9aw4ylJBtcoJrc/OAOVnDs+o05x4TV7HoHUQUcKZ9XhWOGnadyVT0KLyoktgjyjnaK7Zdtc0Ldg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -78,9 +78,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.178",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.178.tgz",
|
||||
"integrity": "sha512-wfNl7JoaUk9IzKWQPr+hyEOX8qnkM+e4GBZnKISh60xVhW9wOOp5RdfnYj5MoJzaLYivMSCbo5rb4ThWguhOMw==",
|
||||
"version": "0.3.185",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.185.tgz",
|
||||
"integrity": "sha512-H69m2hIJawzSkCDNO/CPLQCYvDGdlbZdzGTgGF8/IQuB4O81sv8WSAh6TBXwtFXOgEi4HtrUYlxiFDmTHt7hMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -91,9 +91,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.178",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.178.tgz",
|
||||
"integrity": "sha512-ktBU6EdJoZivf67AxRXe2uSwj0g5tCe20So9QvWVKuEEtRA4sXW5EpgxSWT6LXkgfwUoeNsJK7VAOuVjZumKmg==",
|
||||
"version": "0.3.185",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.185.tgz",
|
||||
"integrity": "sha512-KtXMCoprGYTSPb/A0/kjrO+PpDJAFbHhDd8rjqA4izU51OYjTDkahsGCcSTFM2jA8krOhPqzx/SNVuAVWmexwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -107,9 +107,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.178",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.178.tgz",
|
||||
"integrity": "sha512-Z3U0fNatVK3vkki3e5sjlLJMjros+cT6uq//tdohB2kjNK1CugUuPQFQxd6GU1Lq1DokmdSEPL4OGdKiHckNdQ==",
|
||||
"version": "0.3.185",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.185.tgz",
|
||||
"integrity": "sha512-1GRpKYrg5foomW+qLrxm/k2R/5PEU7RAuEdX65HV6lFMJR3HsGj8qfBq4KCJHTS3r4YdVrFLvPQ/mUqbY6klIA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -123,9 +123,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.178",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.178.tgz",
|
||||
"integrity": "sha512-CPTivDz27LMn5m9iQnJSoWkztLo41e8QTbuYKlAC+CTr98gaYy3Mao0M2tirEK/nGno0xRJw/EpZ1G61yEM3yw==",
|
||||
"version": "0.3.185",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.185.tgz",
|
||||
"integrity": "sha512-8U5wu6dNcyzRkwoae0Gu5ZHs6lRSu8CXqd9yrilX3fHR9kKICrm76bLu5q2auuSc/8dx6VL5ZXqSzFKIMp//zw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -139,9 +139,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.178",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.178.tgz",
|
||||
"integrity": "sha512-K84Ybyr0Olsslg2I1Tzd7KIcSkZgFqqDHn7q1Dfqi7bXVxjMjJ4SaV+LnEAeHSM2es7H8A7ejoTEl89xMZde4Q==",
|
||||
"version": "0.3.185",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.185.tgz",
|
||||
"integrity": "sha512-YAky0Oez+YYrlcas/xDiaCm9AUX0z1YwM+pnk+CFxEr/ICmc7MbiFsnEIXFLPHaox9bE5iOzw4LS4b/c1tIJRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -155,9 +155,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.178",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.178.tgz",
|
||||
"integrity": "sha512-uKH3vgv7cQV3d9BPU4lrUKrFgoU/flmy/1QI62j9JzgE6uWVQKWC+BI4V7iceJ36tYVneRhjjuux+l+Q8egTHA==",
|
||||
"version": "0.3.185",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.185.tgz",
|
||||
"integrity": "sha512-pJEOaCLQQQWSyBeseR/GAn4eEp4WtMDP/o5yPMuuAsNWeoJXM7cY4j/N3KBE6GZofgnYdWEXILF2uw3SjbtINA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -168,9 +168,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.178",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.178.tgz",
|
||||
"integrity": "sha512-wSC5qG6UA1laWGylOU7axuC4NQxZJtibGQ79sYeOQCc05G7CfkUKSzszLOzsPP3eK63cKi/bX5PA6dd4nA5Wow==",
|
||||
"version": "0.3.185",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.185.tgz",
|
||||
"integrity": "sha512-/Kf9XneSuIkSHNwrxOVY0XT7RovAEcx5nDASKzku4994cZz54otU5mT2jT6dwevDLEStfgts5x/A4z0AePwVTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -181,9 +181,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.104.2",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz",
|
||||
"integrity": "sha512-s1wEVDAtEwkS7Ajgep6PZKJLFqybRkmD3Byz+iVVsSpbDY0gjROXE9aOft6V3PMqynn3NTcycV5whga9tCzmKA==",
|
||||
"version": "0.105.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz",
|
||||
"integrity": "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
@@ -923,13 +923,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||
"version": "26.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
|
||||
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
@@ -2360,9 +2360,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.25",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
||||
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
||||
"version": "4.12.26",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
|
||||
"integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2954,9 +2954,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3366,9 +3366,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -3766,9 +3766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
10
package.json
10
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.46.0",
|
||||
"version": "0.49.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -60,14 +60,14 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.26.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.178",
|
||||
"@agentclientprotocol/sdk": "0.28.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.185",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.104.2",
|
||||
"@anthropic-ai/sdk": "0.105.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.9.3",
|
||||
"@types/node": "26.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"eslint": "10.5.0",
|
||||
|
||||
426
src/acp-agent.ts
426
src/acp-agent.ts
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
agent as acpAgent,
|
||||
AgentContext,
|
||||
AuthenticateRequest,
|
||||
AuthMethod,
|
||||
AvailableCommand,
|
||||
CancelNotification,
|
||||
ClientCapabilities,
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ForkSessionRequest,
|
||||
ForkSessionResponse,
|
||||
InitializeRequest,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
ListSessionsResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
methods,
|
||||
ndJsonStream,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
@@ -23,6 +27,8 @@ import {
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestError,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
SessionConfigOption,
|
||||
@@ -41,6 +47,7 @@ import {
|
||||
StopReason,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
AgentInfo,
|
||||
CanUseTool,
|
||||
deleteSession,
|
||||
getSessionMessages,
|
||||
@@ -225,6 +232,14 @@ type Session = {
|
||||
models: SessionModelState;
|
||||
modelInfos: ModelInfo[];
|
||||
configOptions: SessionConfigOption[];
|
||||
/** Custom main-thread agent personas the user (or a plugin/project) has
|
||||
* configured, discovered via `supportedAgents()` with Claude Code's built-in
|
||||
* subagents filtered out. Empty when none are configured, in which case the
|
||||
* "agent" config option is omitted entirely. */
|
||||
agents: AgentInfo[];
|
||||
/** The currently selected main-thread agent name, or "default" for the
|
||||
* standard Claude Code agent (no `agent` flag applied). */
|
||||
currentAgent: string;
|
||||
abortController: AbortController;
|
||||
/** Signal the consumer races `query.next()` against. Aborted by cancel()
|
||||
* (after a grace period) to force the active turn to settle "cancelled" when
|
||||
@@ -612,12 +627,66 @@ export function describeAlwaysAllow(
|
||||
return `Always Allow ${parts.join(" and ")}`;
|
||||
}
|
||||
|
||||
// Implement the ACP Agent interface
|
||||
export class ClaudeAcpAgent implements Agent {
|
||||
/**
|
||||
* Client-facing surface the agent calls back into. This is the subset of ACP
|
||||
* client methods the agent actually uses, expressed as a narrow interface so
|
||||
* tests can supply lightweight mocks. In production it is backed by
|
||||
* {@link ClientConnection} over the SDK's typed `AgentContext`.
|
||||
*/
|
||||
export interface AcpClient {
|
||||
sessionUpdate(params: SessionNotification): Promise<void>;
|
||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse>;
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
|
||||
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse>;
|
||||
unstable_completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
|
||||
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
|
||||
* valid for the entire connection lifetime, so it is captured once at
|
||||
* construction.
|
||||
*/
|
||||
class ClientConnection implements AcpClient {
|
||||
constructor(private readonly ctx: AgentContext) {}
|
||||
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.session.update, params);
|
||||
}
|
||||
|
||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
return this.ctx.request(methods.client.session.requestPermission, params);
|
||||
}
|
||||
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return this.ctx.request(methods.client.fs.readTextFile, params);
|
||||
}
|
||||
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return this.ctx.request(methods.client.fs.writeTextFile, params);
|
||||
}
|
||||
|
||||
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
|
||||
return this.ctx.request(methods.client.elicitation.create, params);
|
||||
}
|
||||
|
||||
unstable_completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.ctx.notify(methods.client.elicitation.complete, params);
|
||||
}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
return this.ctx.notify(method, params);
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeAcpAgent {
|
||||
sessions: {
|
||||
[key: string]: Session;
|
||||
};
|
||||
client: AgentSideConnection;
|
||||
client: AcpClient;
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
logger: Logger;
|
||||
gatewayAuthRequest?: GatewayAuthRequest;
|
||||
@@ -626,7 +695,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
* tests can shrink it. */
|
||||
forceCancelGraceMs: number = DEFAULT_FORCE_CANCEL_GRACE_MS;
|
||||
|
||||
constructor(client: AgentSideConnection, logger?: Logger) {
|
||||
constructor(client: AcpClient, logger?: Logger) {
|
||||
this.sessions = {};
|
||||
this.client = client;
|
||||
this.logger = logger ?? console;
|
||||
@@ -955,13 +1024,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// (whose delta events don't carry it) can all be tagged with the same,
|
||||
// replay-stable id.
|
||||
let currentStreamMessageId: string | undefined;
|
||||
// Per-message-id record of which assistant content actually streamed live
|
||||
// via `stream_event` deltas, split by block type, so the consolidated
|
||||
// `assistant` message can drop the duplicate blocks that already reached the
|
||||
// client as chunks while still forwarding any that a non-streaming gateway
|
||||
// delivered only as an assembled block.
|
||||
let streamedTextIds = new Set<string>();
|
||||
let streamedThinkingIds = new Set<string>();
|
||||
// The text/thinking blocks that have actually streamed live as
|
||||
// `stream_event` deltas for the message the next consolidated `assistant`
|
||||
// will repeat, in stream order, each accumulated to its full streamed text.
|
||||
// The consolidated handler diffs each assembled block against these and
|
||||
// forwards only the un-streamed remainder — nothing if it streamed in full
|
||||
// (the common case), the whole block if it never streamed (a non-streaming
|
||||
// gateway), or just the tail if the stream was cut short mid-block. Matching
|
||||
// on content rather than the Anthropic message id makes dedupe robust to
|
||||
// gateways that don't carry a stable/matching id across the stream and the
|
||||
// consolidated message. Reset after each consolidated message consumes it.
|
||||
const streamedBlocks: { index: number; type: "text" | "thinking"; text: string }[] = [];
|
||||
// Stop reason accumulated for the active turn (result subtype, refusal,
|
||||
// max_tokens, …). Reset per turn; read when the turn settles at idle.
|
||||
let stopReason: StopReason = "end_turn";
|
||||
@@ -973,9 +1046,14 @@ export class ClaudeAcpAgent implements Agent {
|
||||
lastAssistantError = undefined;
|
||||
lastRefusalExplanation = null;
|
||||
compactionInProgress = false;
|
||||
currentStreamMessageId = undefined;
|
||||
streamedTextIds = new Set<string>();
|
||||
streamedThinkingIds = new Set<string>();
|
||||
// Do NOT reset currentStreamMessageId or streamedBlocks here. Turn
|
||||
// activation can fire mid-message (the replayed user echo with
|
||||
// --replay-user-messages lands between a message's blocks); clearing the
|
||||
// streamed-content record on activation would drop the blocks that
|
||||
// streamed before the echo, so the consolidated assistant message would
|
||||
// re-emit them as duplicates. streamedBlocks is bounded instead by being
|
||||
// cleared when each consolidated message consumes it. #785 stopped
|
||||
// resetting the streamed-content tracking here but left this line.
|
||||
stopReason = "end_turn";
|
||||
session.accumulatedUsage = {
|
||||
inputTokens: 0,
|
||||
@@ -1611,21 +1689,52 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// it) can all be tagged with the same, replay-stable id.
|
||||
if (message.event.type === "message_start") {
|
||||
currentStreamMessageId = message.event.message.id || undefined;
|
||||
// A new top-level message starts: clear any streamed-content
|
||||
// residue from a prior message that never reached its
|
||||
// consolidated reset — a cancelled turn breaks out before the
|
||||
// reset, and the synthetic-auth/system/local-command paths
|
||||
// `break` early too. Block indices restart at 0 each message, so
|
||||
// leftover entries would otherwise collide with this message's
|
||||
// blocks and re-emit (or truncate) already-streamed text. Gated on
|
||||
// `parent_tool_use_id === null` so a subagent stream can't clear
|
||||
// the top-level record. Fires once, before any of this message's
|
||||
// blocks, so it doesn't disturb the mid-message turn-activation
|
||||
// path the way resetting on turn activation would.
|
||||
if (message.parent_tool_use_id === null) {
|
||||
streamedBlocks.length = 0;
|
||||
}
|
||||
}
|
||||
// Record that this top-level message id actually streamed text/
|
||||
// thinking, so the `assistant` case below knows its assembled blocks
|
||||
// are duplicates (filter them) rather than the only copy (forward
|
||||
// them). Gated on `parent_tool_use_id === null` so a subagent stream
|
||||
// can't attribute its content to the top-level message id.
|
||||
// Accumulate the text/thinking actually streamed live, so the
|
||||
// `assistant` case below can diff its assembled blocks against what
|
||||
// already reached the client as chunks and forward only the
|
||||
// remainder. Gated on `parent_tool_use_id === null` so a subagent
|
||||
// stream can't attribute its content to the top-level message.
|
||||
// Contiguous deltas of the same block (same index and type) extend
|
||||
// the current entry; anything else opens a new one.
|
||||
if (
|
||||
currentStreamMessageId &&
|
||||
message.parent_tool_use_id === null &&
|
||||
message.event.type === "content_block_delta"
|
||||
) {
|
||||
if (message.event.delta.type === "text_delta") {
|
||||
streamedTextIds.add(currentStreamMessageId);
|
||||
} else if (message.event.delta.type === "thinking_delta") {
|
||||
streamedThinkingIds.add(currentStreamMessageId);
|
||||
const delta = message.event.delta;
|
||||
const chunk =
|
||||
delta.type === "text_delta"
|
||||
? { type: "text" as const, text: delta.text }
|
||||
: delta.type === "thinking_delta"
|
||||
? { type: "thinking" as const, text: delta.thinking }
|
||||
: undefined;
|
||||
// Skip empty deltas (some gateways emit empty thinking chunks —
|
||||
// #793): appending "" is a no-op, but pushing a "" entry would
|
||||
// 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) {
|
||||
const index = message.event.index;
|
||||
const last = streamedBlocks[streamedBlocks.length - 1];
|
||||
if (last && last.index === index && last.type === chunk.type) {
|
||||
last.text += chunk.text;
|
||||
} else {
|
||||
streamedBlocks.push({ index, type: chunk.type, text: chunk.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -1836,34 +1945,66 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
let content: typeof message.message.content;
|
||||
if (message.type === "assistant" && message.parent_tool_use_id === null) {
|
||||
// Top-level assistant message: drop text/thinking blocks already
|
||||
// streamed live as chunks, and forward (as a fallback) any that were
|
||||
// not, so non-streaming gateways still deliver the final answer.
|
||||
const id = messageIdForGrouping(message);
|
||||
content = message.message.content.filter((item) => {
|
||||
// Non-text blocks (tool_use, etc.) always pass through; their own
|
||||
// dedupe (`toolUseCache`) collapses the streamed/assembled pair.
|
||||
// Top-level assistant message: each text/thinking block may have
|
||||
// already been streamed live as deltas. Diff each against what
|
||||
// streamed (`streamedBlocks`, in document order) and forward only
|
||||
// the un-streamed remainder — nothing if it streamed in full (the
|
||||
// common case), the whole block if it never streamed (a
|
||||
// non-streaming gateway), or just the tail if the stream was cut
|
||||
// short mid-block. `streamPos` walks the streamed blocks in step
|
||||
// with the assembled text/thinking blocks; tool_use and other
|
||||
// blocks pass through untouched (their own `toolUseCache` collapses
|
||||
// the streamed/assembled pair) without advancing it.
|
||||
const blocks = message.message.content;
|
||||
const kept: typeof blocks = [];
|
||||
let streamPos = 0;
|
||||
for (const item of blocks) {
|
||||
if (item.type !== "text" && item.type !== "thinking") {
|
||||
return true;
|
||||
kept.push(item);
|
||||
continue;
|
||||
}
|
||||
// Already delivered live as a chunk of this exact type — drop the
|
||||
// duplicate. Checked per type so streaming one (e.g. text) doesn't
|
||||
// suppress an un-streamed block of the other (e.g. thinking).
|
||||
const streamedLive =
|
||||
id !== undefined &&
|
||||
(item.type === "text" ? streamedTextIds : streamedThinkingIds).has(id);
|
||||
if (streamedLive) {
|
||||
return false;
|
||||
const full = item.type === "text" ? item.text : item.thinking;
|
||||
// Empty assembled blocks carry nothing (some gateways emit an
|
||||
// empty `thinking` block before the real text) — drop them.
|
||||
if (full.length === 0) {
|
||||
continue;
|
||||
}
|
||||
// Empty assembled blocks carry nothing (some gateways emit an empty
|
||||
// `thinking` block before the real text) — don't forward stray
|
||||
// empty chunks.
|
||||
const text = item.type === "text" ? item.text : item.thinking;
|
||||
if (text.length === 0) {
|
||||
return false;
|
||||
// A streamed block of the same type whose accumulated text is a
|
||||
// prefix of this one was already (at least partly) delivered as
|
||||
// chunks; consume it and forward only what's left. A non-empty
|
||||
// streamed text is required so an empty/aborted streamed block
|
||||
// doesn't swallow the assembled copy.
|
||||
const streamed = streamedBlocks[streamPos];
|
||||
if (
|
||||
streamed &&
|
||||
streamed.type === item.type &&
|
||||
streamed.text.length > 0 &&
|
||||
full.startsWith(streamed.text)
|
||||
) {
|
||||
streamPos++;
|
||||
const remainder = full.slice(streamed.text.length);
|
||||
if (remainder.length === 0) {
|
||||
continue;
|
||||
}
|
||||
// Overwrite in place with just the un-streamed tail (the
|
||||
// assembled message isn't read again after this) so the block
|
||||
// keeps its exact SDK type.
|
||||
if (item.type === "text") {
|
||||
item.text = remainder;
|
||||
} else {
|
||||
item.thinking = remainder;
|
||||
}
|
||||
kept.push(item);
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Not matched: never streamed (or the stream diverged from the
|
||||
// assembled text) — forward the block in full.
|
||||
kept.push(item);
|
||||
}
|
||||
content = kept;
|
||||
// Consumed: reset so the next message's blocks accumulate fresh and
|
||||
// the record stays bounded to the in-flight message.
|
||||
streamedBlocks.length = 0;
|
||||
} else if (message.type === "assistant") {
|
||||
// Subagent assistant message (`parent_tool_use_id !== null`). It is
|
||||
// never streamed live and its text/thinking is internal to the tool
|
||||
@@ -2574,19 +2715,27 @@ export class ClaudeAcpAgent implements Agent {
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
} else if (configId === "model") {
|
||||
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
|
||||
// modes below; its `displayName`/`description` also let us infer the
|
||||
// context window for semantic aliases (e.g. `default`) whose ID alone
|
||||
// carries no "1m" token.
|
||||
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
||||
if (session.models.currentModelId !== value) {
|
||||
// The cached context window was learned for the previous model; reset
|
||||
// to the new model's heuristic so mid-stream updates between now and
|
||||
// the next `result` reflect the user's selection instead of the old
|
||||
// model's window.
|
||||
session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW;
|
||||
session.contextWindowSize =
|
||||
inferContextWindowFromModel(
|
||||
value,
|
||||
newModelInfo?.displayName,
|
||||
newModelInfo?.description,
|
||||
) ?? DEFAULT_CONTEXT_WINDOW;
|
||||
}
|
||||
session.models = { ...session.models, currentModelId: value };
|
||||
|
||||
// Recompute availableModes for the new model and clamp the current
|
||||
// mode if the SDK no longer offers it (today: "auto" on Haiku).
|
||||
// `ModelInfo.supportsAutoMode` is the canonical SDK signal.
|
||||
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
||||
const newAvailableModes = buildAvailableModes(newModelInfo);
|
||||
// Capture BEFORE mutating session.modes so the log message reflects
|
||||
// the invalidated mode rather than "default".
|
||||
@@ -2623,6 +2772,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
session.models,
|
||||
session.modelInfos,
|
||||
currentEffort,
|
||||
session.agents,
|
||||
session.currentAgent,
|
||||
);
|
||||
|
||||
// Sync effort with the SDK if it changed after the model switch
|
||||
@@ -2650,6 +2801,19 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (configId === "agent") {
|
||||
// Live agent switch — no subprocess restart needed. Apply the SDK flag
|
||||
// first so a rejected control request leaves both `currentAgent` and the
|
||||
// config option untouched (no UI/SDK desync). Passing `null` clears the
|
||||
// flag layer back to the standard Claude Code agent; the change takes
|
||||
// effect on the next turn (SDK >= 0.3.161).
|
||||
await session.query.applyFlagSettings({
|
||||
agent: value === DEFAULT_AGENT_ID ? null : value,
|
||||
});
|
||||
session.currentAgent = value;
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
} else {
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
@@ -3072,11 +3236,25 @@ export class ClaudeAcpAgent implements Agent {
|
||||
availableModes,
|
||||
};
|
||||
|
||||
const agents = await discoverCustomAgents(q);
|
||||
// Only adopt the requested agent as the selected value if it's one we
|
||||
// actually surface in the picker. A built-in (filtered out above) or
|
||||
// otherwise-unknown name would leave the config option's `currentValue`
|
||||
// pointing at an entry not in its own `options` list, which clients render
|
||||
// as a blank/invalid selection.
|
||||
const requestedAgent = userProvidedOptions?.agent;
|
||||
const currentAgent =
|
||||
requestedAgent && agents.some((a) => a.name === requestedAgent)
|
||||
? requestedAgent
|
||||
: DEFAULT_AGENT_ID;
|
||||
|
||||
const configOptions = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
allowedModels,
|
||||
settingsManager.getSettings().effortLevel,
|
||||
agents,
|
||||
currentAgent,
|
||||
);
|
||||
|
||||
// Apply the initial effort level to the SDK so it matches the UI default
|
||||
@@ -3107,10 +3285,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
models,
|
||||
modelInfos: allowedModels,
|
||||
configOptions,
|
||||
agents,
|
||||
currentAgent,
|
||||
abortController,
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
contextWindowSize:
|
||||
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
inferContextWindowFromModel(
|
||||
models.currentModelId,
|
||||
currentModelInfo?.displayName,
|
||||
currentModelInfo?.description,
|
||||
) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
taskState,
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -3288,11 +3472,47 @@ function toSdkEffortLevel(value: string | undefined): Settings["effortLevel"] |
|
||||
return value === undefined || value === "default" ? null : (value as Settings["effortLevel"]);
|
||||
}
|
||||
|
||||
function buildConfigOptions(
|
||||
// `supportedAgents()` always returns Claude Code's built-in subagents — the
|
||||
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
|
||||
// has configured none of their own. Those aren't meaningful *main-thread*
|
||||
// personas, so we filter them out and only surface the Agent picker when the
|
||||
// user (or a plugin/project) has configured custom agents. Update this set if
|
||||
// the SDK's built-in roster changes.
|
||||
export const BUILTIN_AGENT_NAMES = new Set([
|
||||
"claude",
|
||||
"general-purpose",
|
||||
"Explore",
|
||||
"Plan",
|
||||
"statusline-setup",
|
||||
]);
|
||||
|
||||
// Value of the synthetic "Default" entry in the agent picker, which maps to the
|
||||
// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
|
||||
// reserved sentinel: a custom agent named exactly this would collide with it
|
||||
// (two options sharing the value, selection silently routing to `null`), so we
|
||||
// exclude that name from discovery.
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
|
||||
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
||||
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
||||
* list if discovery fails so a flaky control request never blocks session
|
||||
* creation. */
|
||||
export async function discoverCustomAgents(q: Query): Promise<AgentInfo[]> {
|
||||
try {
|
||||
const agents = await q.supportedAgents();
|
||||
return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function buildConfigOptions(
|
||||
modes: SessionModeState,
|
||||
models: SessionModelState,
|
||||
modelInfos: ModelInfo[],
|
||||
currentEffortLevel?: string,
|
||||
agents: AgentInfo[] = [],
|
||||
currentAgent: string = DEFAULT_AGENT_ID,
|
||||
): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [
|
||||
{
|
||||
@@ -3356,6 +3576,28 @@ function buildConfigOptions(
|
||||
});
|
||||
}
|
||||
|
||||
// Only surface the Agent picker when there's a real choice — i.e. the user
|
||||
// has configured at least one custom agent (built-ins are filtered out in
|
||||
// discoverCustomAgents). With none configured, "Default" would be the only
|
||||
// entry, so we omit the option entirely.
|
||||
if (agents.length > 0) {
|
||||
options.push({
|
||||
id: "agent",
|
||||
name: "Agent",
|
||||
description: "Main-thread agent persona",
|
||||
type: "select",
|
||||
currentValue: currentAgent,
|
||||
options: [
|
||||
{ value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
|
||||
...agents.map((a) => ({
|
||||
value: a.name,
|
||||
name: a.name,
|
||||
description: a.description || undefined,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -3780,7 +4022,7 @@ export function toAcpNotifications(
|
||||
role: "assistant" | "user",
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AgentSideConnection,
|
||||
client: AcpClient,
|
||||
logger: Logger,
|
||||
options?: {
|
||||
registerHooks?: boolean;
|
||||
@@ -3850,13 +4092,17 @@ export function toAcpNotifications(
|
||||
break;
|
||||
case "thinking":
|
||||
case "thinking_delta":
|
||||
update = {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.thinking,
|
||||
},
|
||||
};
|
||||
// Recent models default `thinking.display` to "omitted", which streams
|
||||
// signature-only thinking blocks whose text is empty.
|
||||
if (chunk.thinking.length > 0) {
|
||||
update = {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.thinking,
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
case "tool_use":
|
||||
case "server_tool_use":
|
||||
@@ -4102,7 +4348,7 @@ export function streamEventToAcpNotifications(
|
||||
message: SDKPartialAssistantMessage,
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AgentSideConnection,
|
||||
client: AcpClient,
|
||||
logger: Logger,
|
||||
options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
@@ -4167,11 +4413,34 @@ export function runAcp() {
|
||||
const output = nodeToWebReadable(process.stdin);
|
||||
|
||||
const stream = ndJsonStream(input, output);
|
||||
let agent!: ClaudeAcpAgent;
|
||||
const connection = new AgentSideConnection((client) => {
|
||||
agent = new ClaudeAcpAgent(client);
|
||||
return agent;
|
||||
}, stream);
|
||||
|
||||
// `connect(...)` returns a connection-scoped peer handle (`connection.client`)
|
||||
// that stays valid for the whole connection, so the agent captures it once.
|
||||
// Handlers close over `agent`, which is assigned synchronously right after
|
||||
// `connect()` returns — before the connection processes any inbound message.
|
||||
// It cannot be `const`: its value depends on `connection.client`, which does
|
||||
// not exist until `connect()` has been called.
|
||||
// eslint-disable-next-line prefer-const
|
||||
let agent: ClaudeAcpAgent;
|
||||
const connection = acpAgent({ name: "claude-code-acp" })
|
||||
.onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
|
||||
.onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
|
||||
.onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params))
|
||||
.onRequest(methods.agent.session.fork, (ctx) => agent.unstable_forkSession(ctx.params))
|
||||
.onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
|
||||
.onRequest(methods.agent.session.delete, (ctx) => agent.deleteSession(ctx.params))
|
||||
.onRequest(methods.agent.session.resume, (ctx) => agent.resumeSession(ctx.params))
|
||||
.onRequest(methods.agent.session.close, (ctx) => agent.closeSession(ctx.params))
|
||||
.onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
|
||||
.onRequest(methods.agent.session.setConfigOption, (ctx) =>
|
||||
agent.setSessionConfigOption(ctx.params),
|
||||
)
|
||||
.onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
|
||||
.onRequest(methods.agent.session.prompt, (ctx) => agent.prompt(ctx.params))
|
||||
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
||||
.connect(stream);
|
||||
|
||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
||||
return { connection, agent };
|
||||
}
|
||||
|
||||
@@ -4183,14 +4452,21 @@ function commonPrefixLength(a: string, b: string) {
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Best-effort first guess of a model's context window from its ID, used only
|
||||
* as a fallback when the SDK's authoritative `getContextUsage` is unavailable
|
||||
* (and until a `result` message arrives with the `modelUsage` value).
|
||||
/** Best-effort first guess of a model's context window, used only as a
|
||||
* fallback when the SDK's authoritative `getContextUsage` is unavailable (and
|
||||
* until a `result` message arrives with the `modelUsage` value).
|
||||
*
|
||||
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
||||
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
||||
* matching things like "10m" or embedded substrings. */
|
||||
function inferContextWindowFromModel(model: string): number | null {
|
||||
if (/\b1m\b/i.test(model)) return 1_000_000;
|
||||
* matching things like "10m" or embedded substrings. Semantic aliases like
|
||||
* `default` carry no such token in the ID, but the SDK's human-facing
|
||||
* `displayName`/`description` do (e.g. "Opus 4.7 (1M context)"), so callers
|
||||
* pass those too — the same `\b1m\b` token appears in "1M context". The SDK's
|
||||
* `ModelInfo` exposes no structured context-window field, so this text scan is
|
||||
* the only pre-`result` signal available. A miss falls back to the default
|
||||
* window and is corrected by `result.modelUsage` within one turn. */
|
||||
function inferContextWindowFromModel(...texts: Array<string | undefined>): number | null {
|
||||
if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({
|
||||
querySpy: vi.fn(),
|
||||
@@ -21,13 +20,13 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
function createMockClient(): AcpClient {
|
||||
return {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
function mockQuery() {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
AvailableCommand,
|
||||
Client,
|
||||
ClientSideConnection,
|
||||
client as acpClient,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
methods,
|
||||
ndJsonStream,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionRequest,
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
describeAlwaysAllow,
|
||||
streamEventToAcpNotifications,
|
||||
messageIdForGrouping,
|
||||
buildConfigOptions,
|
||||
discoverCustomAgents,
|
||||
type AcpClient,
|
||||
type SDKMessageFilter,
|
||||
} from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
@@ -105,6 +108,8 @@ function mockSessionState(overrides: Record<string, any> = {}) {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
@@ -158,8 +163,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
child.kill();
|
||||
});
|
||||
|
||||
class TestClient implements Client {
|
||||
agent: Agent;
|
||||
class TestClient {
|
||||
files: Map<string, string> = new Map();
|
||||
receivedText: string = "";
|
||||
// Records for the AskUserQuestion elicitation test.
|
||||
@@ -169,8 +173,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
resolveAvailableCommands: (commands: AvailableCommand[]) => void;
|
||||
availableCommandsPromise: Promise<AvailableCommand[]>;
|
||||
|
||||
constructor(agent: Agent) {
|
||||
this.agent = agent;
|
||||
constructor() {
|
||||
this.resolveAvailableCommands = () => {};
|
||||
this.availableCommandsPromise = new Promise((resolve) => {
|
||||
this.resolveAvailableCommands = resolve;
|
||||
@@ -248,21 +251,35 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
}
|
||||
}
|
||||
|
||||
type TestConnection = {
|
||||
prompt(params: PromptRequest): Promise<PromptResponse>;
|
||||
};
|
||||
|
||||
async function setupTestSession(cwd: string): Promise<{
|
||||
client: TestClient;
|
||||
connection: ClientSideConnection;
|
||||
connection: TestConnection;
|
||||
newSessionResponse: NewSessionResponse;
|
||||
}> {
|
||||
let client;
|
||||
const input = nodeToWebWritable(child.stdin!);
|
||||
const output = nodeToWebReadable(child.stdout!);
|
||||
const stream = ndJsonStream(input, output);
|
||||
const connection = new ClientSideConnection((agent) => {
|
||||
client = new TestClient(agent);
|
||||
return client;
|
||||
}, stream);
|
||||
|
||||
await connection.initialize({
|
||||
const client = new TestClient();
|
||||
// `connect(...)` keeps the connection open and exposes the agent-side peer
|
||||
// handle as `connection.agent`, valid for the lifetime of the connection.
|
||||
const { agent: ctx } = acpClient({ name: "test-client" })
|
||||
.onNotification(methods.client.session.update, (c) => client.sessionUpdate(c.params))
|
||||
.onRequest(methods.client.session.requestPermission, (c) =>
|
||||
client.requestPermission(c.params),
|
||||
)
|
||||
.onRequest(methods.client.fs.readTextFile, (c) => client.readTextFile(c.params))
|
||||
.onRequest(methods.client.fs.writeTextFile, (c) => client.writeTextFile(c.params))
|
||||
.onRequest(methods.client.elicitation.create, (c) =>
|
||||
client.unstable_createElicitation(c.params),
|
||||
)
|
||||
.connect(stream);
|
||||
|
||||
await ctx.request(methods.agent.initialize, {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: {
|
||||
@@ -275,12 +292,16 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
},
|
||||
});
|
||||
|
||||
const newSessionResponse = await connection.newSession({
|
||||
const newSessionResponse = await ctx.request(methods.agent.session.new, {
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
return { client: client!, connection, newSessionResponse };
|
||||
const connection: TestConnection = {
|
||||
prompt: (params) => ctx.request(methods.agent.session.prompt, params),
|
||||
};
|
||||
|
||||
return { client, connection, newSessionResponse };
|
||||
}
|
||||
|
||||
it("should connect to the ACP subprocess", async () => {
|
||||
@@ -848,7 +869,7 @@ describe("tool conversions", () => {
|
||||
received.message.role,
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
{} as AcpClient,
|
||||
console,
|
||||
),
|
||||
).toStrictEqual([
|
||||
@@ -1766,7 +1787,7 @@ describe("stop reason propagation", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
@@ -2096,7 +2117,7 @@ describe("stop reason propagation", () => {
|
||||
sessionUpdate: async (u: any) => {
|
||||
sessionUpdates.push(u);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
|
||||
const input = new Pushable<any>();
|
||||
@@ -2263,7 +2284,7 @@ describe("session/close", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
@@ -2293,6 +2314,8 @@ describe("session/close", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
@@ -2352,7 +2375,7 @@ describe("session/delete", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
@@ -2376,6 +2399,8 @@ describe("session/delete", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
@@ -2439,7 +2464,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
@@ -2476,6 +2501,8 @@ describe("getOrCreateSession param change detection", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
@@ -2664,7 +2691,7 @@ describe("usage_update computation", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates };
|
||||
}
|
||||
@@ -3045,6 +3072,26 @@ describe("usage_update computation", () => {
|
||||
expect(usageUpdates[1].update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("infers the 1M window from a model's description when the ID lacks a 1m token (issue #596)", async () => {
|
||||
// Semantic aliases like `default` resolve to a 1M-context model but carry
|
||||
// no "1m" token in the modelId — the SDK signals 1M only via the
|
||||
// human-facing displayName/description (e.g. "Opus 4.7 with 1M context").
|
||||
// Inference must read those so the session reports the correct window from
|
||||
// the first mid-stream update instead of the 200k placeholder.
|
||||
const { agent } = createMockAgentWithCapture();
|
||||
injectSession(agent, [{ type: "system", subtype: "session_state_changed", state: "idle" }]);
|
||||
const session = agent.sessions["test-session"];
|
||||
session.models = { currentModelId: "claude-sonnet-4-6", availableModels: [] };
|
||||
session.modelInfos = [
|
||||
{ value: "default", displayName: "Default", description: "Opus 4.7 with 1M context" },
|
||||
] as any;
|
||||
expect(session.contextWindowSize).toBe(200000);
|
||||
|
||||
await (agent as any).applyConfigOptionValue("test-session", session, "model", "default");
|
||||
|
||||
expect(session.contextWindowSize).toBe(1000000);
|
||||
});
|
||||
|
||||
it("result with no matching modelUsage preserves the learned window", async () => {
|
||||
// A turn whose `result.modelUsage` doesn't contain the current top-level
|
||||
// model (e.g. no top-level assistant message, or only a subagent ran) must
|
||||
@@ -3586,7 +3633,7 @@ describe("assembled assistant text fallback", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates };
|
||||
}
|
||||
@@ -3618,6 +3665,20 @@ describe("assembled assistant text fallback", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function thinkingDelta(thinking: string) {
|
||||
return {
|
||||
type: "stream_event" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: {
|
||||
type: "content_block_delta" as const,
|
||||
index: 0,
|
||||
delta: { type: "thinking_delta" as const, thinking },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(apiId: string, content: any[], parentToolUseId: string | null = null) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
@@ -3679,6 +3740,36 @@ describe("assembled assistant text fallback", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Like injectSession, but the user-message echo is yielded at the position of
|
||||
// the "ECHO" sentinel in `messages` rather than always first — so a test can
|
||||
// reproduce the production ordering where the assistant stream arrives before
|
||||
// the SDK replays the user message.
|
||||
function injectSessionEchoAt(agent: ClaudeAcpAgent, messages: any[]) {
|
||||
const input = new Pushable<any>();
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage } = await iter.next();
|
||||
for (const m of messages) {
|
||||
if (m === "ECHO") {
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
} else {
|
||||
yield m;
|
||||
}
|
||||
}
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({
|
||||
query: wrapQuery(messageGenerator()),
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
function messageChunkTexts(updates: any[]): string[] {
|
||||
return updates
|
||||
.filter((u) => u.update?.sessionUpdate === "agent_message_chunk")
|
||||
@@ -3723,6 +3814,59 @@ describe("assembled assistant text fallback", () => {
|
||||
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
|
||||
});
|
||||
|
||||
it("dedupes streamed text even when the stream arrives before the user echo", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Production ordering: the SDK emits the assistant's stream events before it
|
||||
// replays the user message that activates the turn. The streamed-id tracking
|
||||
// must survive activation, or the consolidated block is re-emitted as a
|
||||
// duplicate (regression from the persistent-consumer rework).
|
||||
injectSessionEchoAt(agent, [
|
||||
messageStart("msg-streamed"),
|
||||
textDelta("hello "),
|
||||
textDelta("world"),
|
||||
"ECHO",
|
||||
assistantMessage("msg-streamed", [{ type: "text", text: "hello world" }]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
// Still just the two streamed deltas — no duplicated assembled block.
|
||||
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
|
||||
});
|
||||
|
||||
it("dedupes streamed text when the user echo activates the turn mid-message, between a thinking and a text block", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Production ordering captured with: inside a single message id, the
|
||||
// thinking block streams, THEN the SDK replays the user message that
|
||||
// activates the turn, THEN the text block streams. Turn activation runs
|
||||
// `resetTurnScratch()`; if that nulls `currentStreamMessageId`, every text
|
||||
// delta after the echo streams untracked, so the consolidated `assistant`
|
||||
// text fails dedupe and is re-emitted as a duplicate. #785 fixed the
|
||||
// stream-before-echo case but left this residual mid-message path.
|
||||
injectSessionEchoAt(agent, [
|
||||
messageStart("msg-mixed"),
|
||||
thinkingDelta("private reasoning"),
|
||||
"ECHO",
|
||||
textDelta("Starting now."),
|
||||
assistantMessage("msg-mixed", [
|
||||
{ type: "thinking", thinking: "private reasoning" },
|
||||
{ type: "text", text: "Starting now." },
|
||||
]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
// The text streamed once; the consolidated copy must be deduped, not doubled.
|
||||
expect(messageChunkTexts(updates)).toEqual(["Starting now."]);
|
||||
// The thinking streamed before the echo (still tracked) so it is deduped —
|
||||
// mirrors the production signature where only the text block doubled.
|
||||
expect(thoughtChunkTexts(updates)).toEqual(["private reasoning"]);
|
||||
});
|
||||
|
||||
it("dedupes per block type: streamed text is dropped but an un-streamed thinking block in the same message is forwarded", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Gateway streams the text live but delivers the thinking block only in the
|
||||
@@ -3748,6 +3892,134 @@ describe("assembled assistant text fallback", () => {
|
||||
expect(thoughtChunkTexts(updates)).toEqual(["private reasoning"]);
|
||||
});
|
||||
|
||||
it("forwards only the un-streamed remainder when the stream is cut short mid-block", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// The stream stops partway ("hello ") but the consolidated message carries
|
||||
// the whole block ("hello world"). The streamed prefix must not be re-sent,
|
||||
// and the un-streamed tail must still reach the client — dropping the whole
|
||||
// assembled block would truncate the answer to "hello ".
|
||||
injectSession(agent, [
|
||||
messageStart("msg-partial"),
|
||||
textDelta("hello "),
|
||||
assistantMessage("msg-partial", [{ type: "text", text: "hello world" }]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
// The streamed prefix, then just the tail from the consolidated message.
|
||||
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
|
||||
});
|
||||
|
||||
it("dedupes streamed text even when the consolidated message carries a different id", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Some gateways assign one id during the stream and a different one (or only
|
||||
// a uuid) on the assembled message. Dedupe must key on content, not the id,
|
||||
// or the consolidated block re-emits already-streamed text as a duplicate.
|
||||
injectSession(agent, [
|
||||
messageStart("msg-stream-id"),
|
||||
textDelta("hello "),
|
||||
textDelta("world"),
|
||||
assistantMessage("msg-DIFFERENT-id", [{ type: "text", text: "hello world" }]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
// Only the streamed deltas — the assembled copy is deduped despite the id
|
||||
// mismatch.
|
||||
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
|
||||
});
|
||||
|
||||
it("dedupes a streamed text block even when an empty thinking delta precedes it", async () => {
|
||||
// An empty thinking delta (some gateways emit them — #793) must not create
|
||||
// a zero-length streamedBlocks entry: that entry can never satisfy the
|
||||
// consolidated handler's `text.length > 0` guard, so it would stall the
|
||||
// diff cursor and re-emit the real, already-streamed text as a duplicate.
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
messageStart("msg-empty-thinking"),
|
||||
thinkingDelta(""),
|
||||
textDelta("real answer"),
|
||||
assistantMessage("msg-empty-thinking", [{ type: "text", text: "real answer" }]),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
// The streamed text appears once; the consolidated copy is deduped.
|
||||
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
|
||||
// `if (session.cancelled) break;` guard), and streamedBlocks is
|
||||
// session-scoped — so a cancelled turn's streamed text used to leak into
|
||||
// the next turn. Block indices restart at 0 per message, so the leftover
|
||||
// "Hello there" would fuse with turn 2's first block and make its
|
||||
// consolidated copy fail the prefix dedupe, re-emitting "Second answer" as
|
||||
// a duplicate. The fix resets streamedBlocks on each top-level
|
||||
// `message_start`, bounding the record to one in-flight message.
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
|
||||
let releaseCancel!: () => void;
|
||||
const cancelled = new Promise<void>((resolve) => {
|
||||
releaseCancel = resolve;
|
||||
});
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value); // activate turn 1
|
||||
yield messageStart("msg-1");
|
||||
yield textDelta("Hello ");
|
||||
yield textDelta("there"); // streamedBlocks = [{ index: 0, text: "Hello there" }]
|
||||
await cancelled; // hold until the test has cancelled turn 1
|
||||
// Turn 1's consolidated message arrives while cancelled → hits the
|
||||
// `if (session.cancelled) break;` guard, skipping the streamedBlocks
|
||||
// reset. The leftover entry must not survive into turn 2.
|
||||
yield assistantMessage("msg-1", [{ type: "text", text: "Hello there" }]);
|
||||
yield idle; // settles turn 1 as cancelled
|
||||
const u2 = await iter.next();
|
||||
yield userEcho(u2.value); // activate turn 2
|
||||
yield messageStart("msg-2"); // resets streamedBlocks (the fix)
|
||||
yield textDelta("Second answer");
|
||||
yield assistantMessage("msg-2", [{ type: "text", text: "Second answer" }]);
|
||||
yield result();
|
||||
yield idle;
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const first = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
});
|
||||
// Wait until turn 1's deltas have streamed before cancelling.
|
||||
const deadline = Date.now() + 1000;
|
||||
while (!messageChunkTexts(updates).includes("there")) {
|
||||
if (Date.now() > deadline) throw new Error("turn 1 stream never arrived");
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
}
|
||||
|
||||
await agent.cancel({ sessionId: "test-session" });
|
||||
releaseCancel();
|
||||
await expect(first).resolves.toEqual({ stopReason: "cancelled" });
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "second" }] });
|
||||
|
||||
// Turn 2's text appears exactly once (the live delta); the consolidated copy
|
||||
// is deduped despite the cancelled turn's leftover streamed text.
|
||||
expect(messageChunkTexts(updates).filter((t) => t === "Second answer")).toEqual([
|
||||
"Second answer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not leak subagent assistant text into the top-level feed", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Subagent assistant messages (parent_tool_use_id !== null) are never
|
||||
@@ -3820,7 +4092,7 @@ describe("emitRawSDKMessages", () => {
|
||||
extNotification: async (method: string, params: any) => {
|
||||
extNotifications.push({ method, params });
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates, extNotifications };
|
||||
}
|
||||
@@ -4041,7 +4313,7 @@ describe("result origin handling", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates };
|
||||
}
|
||||
@@ -4195,7 +4467,7 @@ describe("memory_recall handling", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates };
|
||||
}
|
||||
@@ -4352,7 +4624,7 @@ describe("post-error recovery", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
@@ -4443,6 +4715,8 @@ describe("post-error recovery", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
@@ -4950,7 +5224,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
@@ -5011,6 +5285,8 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
@@ -5171,14 +5447,9 @@ describe("streamEventToAcpNotifications", () => {
|
||||
},
|
||||
} as Parameters<typeof streamEventToAcpNotifications>[0];
|
||||
|
||||
const result = streamEventToAcpNotifications(
|
||||
message,
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
{ messageId },
|
||||
);
|
||||
const result = streamEventToAcpNotifications(message, "test", {}, {} as AcpClient, console, {
|
||||
messageId,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -5202,7 +5473,7 @@ describe("toAcpNotifications messageId", () => {
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
{} as AcpClient,
|
||||
console,
|
||||
{ messageId },
|
||||
);
|
||||
@@ -5225,7 +5496,7 @@ describe("toAcpNotifications messageId", () => {
|
||||
"user",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
{} as AcpClient,
|
||||
console,
|
||||
{ messageId },
|
||||
);
|
||||
@@ -5239,7 +5510,7 @@ describe("toAcpNotifications messageId", () => {
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
{} as AcpClient,
|
||||
console,
|
||||
{ messageId },
|
||||
);
|
||||
@@ -5250,14 +5521,7 @@ describe("toAcpNotifications messageId", () => {
|
||||
});
|
||||
|
||||
it("omits messageId when none is supplied", () => {
|
||||
const result = toAcpNotifications(
|
||||
"hello",
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
console,
|
||||
);
|
||||
const result = toAcpNotifications("hello", "assistant", "test", {}, {} as AcpClient, console);
|
||||
expect(result[0].update).not.toHaveProperty("messageId");
|
||||
});
|
||||
|
||||
@@ -5274,7 +5538,7 @@ describe("toAcpNotifications messageId", () => {
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AgentSideConnection,
|
||||
{} as AcpClient,
|
||||
console,
|
||||
{ messageId, registerHooks: false },
|
||||
);
|
||||
@@ -5283,6 +5547,55 @@ describe("toAcpNotifications messageId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications thinking chunks", () => {
|
||||
it("emits an agent_thought_chunk for non-empty thinking text", () => {
|
||||
const result = toAcpNotifications(
|
||||
[{ type: "thinking", thinking: "let me reason", signature: "" }],
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
console,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
sessionId: "test",
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: "let me reason" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips empty thinking blocks (display: 'omitted' signature-only blocks)", () => {
|
||||
const result = toAcpNotifications(
|
||||
[{ type: "thinking", thinking: "", signature: "abc" }],
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
console,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips empty thinking deltas", () => {
|
||||
const result = toAcpNotifications(
|
||||
[{ type: "thinking_delta", thinking: "", estimated_tokens: 0 }],
|
||||
"assistant",
|
||||
"test",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
console,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageIdForGrouping", () => {
|
||||
it("uses the Anthropic API message id for assistant messages", () => {
|
||||
const message = {
|
||||
@@ -5319,3 +5632,177 @@ describe("messageIdForGrouping", () => {
|
||||
expect(messageIdForGrouping({ type: "assistant", uuid: "", message: {} })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent selection config option", () => {
|
||||
const baseModes = { currentModeId: "default", availableModes: [] };
|
||||
const baseModels = { currentModelId: "default", availableModels: [] };
|
||||
|
||||
describe("discoverCustomAgents", () => {
|
||||
it("filters out Claude Code's built-in subagents", async () => {
|
||||
const q = {
|
||||
supportedAgents: async () => [
|
||||
{ name: "claude", description: "catch-all" },
|
||||
{ name: "Explore", description: "search" },
|
||||
{ name: "general-purpose", description: "gp" },
|
||||
{ name: "Plan", description: "architect" },
|
||||
{ name: "statusline-setup", description: "status" },
|
||||
{ name: "my-reviewer", description: "Reviews code" },
|
||||
{ name: "my-writer", description: "Writes docs" },
|
||||
],
|
||||
} as any;
|
||||
const agents = await discoverCustomAgents(q);
|
||||
expect(agents.map((a) => a.name)).toEqual(["my-reviewer", "my-writer"]);
|
||||
});
|
||||
|
||||
it("excludes a custom agent named 'default' (reserved sentinel)", async () => {
|
||||
const q = {
|
||||
supportedAgents: async () => [
|
||||
{ name: "default", description: "collides with the synthetic Default entry" },
|
||||
{ name: "my-reviewer", description: "Reviews code" },
|
||||
],
|
||||
} as any;
|
||||
const agents = await discoverCustomAgents(q);
|
||||
expect(agents.map((a) => a.name)).toEqual(["my-reviewer"]);
|
||||
});
|
||||
|
||||
it("returns an empty list when discovery throws", async () => {
|
||||
const q = {
|
||||
supportedAgents: async () => {
|
||||
throw new Error("control request failed");
|
||||
},
|
||||
} as any;
|
||||
expect(await discoverCustomAgents(q)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConfigOptions agent option", () => {
|
||||
it("omits the agent option when no custom agents are configured", () => {
|
||||
const options = buildConfigOptions(baseModes, baseModels, [], undefined, [], "default");
|
||||
expect(options.find((o) => o.id === "agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds an agent option with a synthetic Default entry when custom agents exist", () => {
|
||||
const agents = [
|
||||
{ name: "my-reviewer", description: "Reviews code" },
|
||||
// empty description should normalize to undefined, not ""
|
||||
{ name: "my-writer", description: "" },
|
||||
];
|
||||
const options = buildConfigOptions(
|
||||
baseModes,
|
||||
baseModels,
|
||||
[],
|
||||
undefined,
|
||||
agents,
|
||||
"my-reviewer",
|
||||
);
|
||||
const agentOption = options.find((o) => o.id === "agent");
|
||||
expect(agentOption).toBeDefined();
|
||||
expect(agentOption!.currentValue).toBe("my-reviewer");
|
||||
expect(agentOption!.type).toBe("select");
|
||||
const entries = (agentOption as any).options;
|
||||
expect(entries.map((o: any) => o.value)).toEqual(["default", "my-reviewer", "my-writer"]);
|
||||
expect(entries[2].description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("switching the agent", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = { sessionUpdate: async () => {} } as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
const agents = [{ name: "my-reviewer", description: "Reviews code" }];
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
|
||||
function* empty() {}
|
||||
const applyFlagSettings = vi.fn(async () => {});
|
||||
const gen = Object.assign(empty(), {
|
||||
interrupt: vi.fn(),
|
||||
close: vi.fn(),
|
||||
applyFlagSettings,
|
||||
});
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: buildConfigOptions(baseModes, baseModels, [], undefined, agents, "default"),
|
||||
agents,
|
||||
currentAgent: "default",
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
messageIdToUuid: new Map(),
|
||||
};
|
||||
return { session: agent.sessions[sessionId]!, applyFlagSettings };
|
||||
}
|
||||
|
||||
it("applies the agent flag live without restarting the subprocess", async () => {
|
||||
const agent = createMockAgent();
|
||||
const { session, applyFlagSettings } = injectSession(agent, "s1");
|
||||
|
||||
const result = await agent.setSessionConfigOption({
|
||||
sessionId: "s1",
|
||||
configId: "agent",
|
||||
value: "my-reviewer",
|
||||
});
|
||||
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ agent: "my-reviewer" });
|
||||
expect(session.currentAgent).toBe("my-reviewer");
|
||||
// The whole point of the SDK >= 0.3.161 approach: no process teardown.
|
||||
expect(session.query.interrupt).not.toHaveBeenCalled();
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
expect(agent.sessions["s1"]).toBe(session);
|
||||
const agentOption = result.configOptions.find((o) => o.id === "agent");
|
||||
expect(agentOption?.currentValue).toBe("my-reviewer");
|
||||
});
|
||||
|
||||
it("clears the flag (agent: null) when switching back to default", async () => {
|
||||
const agent = createMockAgent();
|
||||
const { session, applyFlagSettings } = injectSession(agent, "s2");
|
||||
session.currentAgent = "my-reviewer";
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: "s2",
|
||||
configId: "agent",
|
||||
value: "default",
|
||||
});
|
||||
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ agent: null });
|
||||
expect(session.currentAgent).toBe("default");
|
||||
});
|
||||
|
||||
it("leaves tracked state untouched when the live switch is rejected", async () => {
|
||||
const agent = createMockAgent();
|
||||
const { session, applyFlagSettings } = injectSession(agent, "s3");
|
||||
applyFlagSettings.mockRejectedValueOnce(new Error("control channel closed"));
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: "s3",
|
||||
configId: "agent",
|
||||
value: "my-reviewer",
|
||||
}),
|
||||
).rejects.toThrow("control channel closed");
|
||||
|
||||
// The flag never applied, so neither currentAgent nor the config option
|
||||
// moves — no desync with the agent the SDK is actually running.
|
||||
expect(session.currentAgent).toBe("default");
|
||||
const agentOption = session.configOptions.find((o) => o.id === "agent");
|
||||
expect(agentOption?.currentValue).toBe("default");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
@@ -52,7 +52,7 @@ describe("additionalRoots", () => {
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection);
|
||||
} as unknown as AcpClient);
|
||||
});
|
||||
|
||||
afterEach(
|
||||
|
||||
42
src/tests/agent-discovery.test.ts
Normal file
42
src/tests/agent-discovery.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../acp-agent.js";
|
||||
|
||||
// `discoverCustomAgents` distinguishes user/plugin-configured agents from
|
||||
// Claude Code's built-in subagents by name (BUILTIN_AGENT_NAMES), since the
|
||||
// SDK's `AgentInfo` carries no built-in/source flag. That hardcoded set drifts
|
||||
// silently if the SDK changes its default roster: a newly added built-in would
|
||||
// leak into the ACP "Agent" picker as if the user had configured it. This
|
||||
// integration test runs the real SDK so the set is flagged the moment it does.
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("agent discovery (SDK)", () => {
|
||||
it("BUILTIN_AGENT_NAMES exactly covers the SDK's default agent roster", async () => {
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId: randomUUID(),
|
||||
// No setting sources → no user/project/local custom agents are loaded,
|
||||
// so `supportedAgents()` reports exactly the intrinsic built-ins
|
||||
// regardless of what the developer has in ~/.claude/agents.
|
||||
settingSources: [],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const reported = (await q.supportedAgents()).map((a) => a.name).sort();
|
||||
expect(reported).toEqual([...BUILTIN_AGENT_NAMES].sort());
|
||||
|
||||
// With the built-ins being the only agents present, discovery surfaces
|
||||
// nothing — the ACP "Agent" picker is correctly omitted.
|
||||
expect(await discoverCustomAgents(q)).toEqual([]);
|
||||
} finally {
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
@@ -39,7 +38,7 @@ describe("authorization", () => {
|
||||
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
|
||||
const connectionMock = {
|
||||
sessionUpdate: async (_: any) => {},
|
||||
} as AgentSideConnection;
|
||||
} as AcpClient;
|
||||
|
||||
const agent = new ClaudeAcpAgent(connectionMock);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -47,13 +47,13 @@ describe("createSession options merging", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
function createMockClient(): AcpClient {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -550,14 +550,15 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
it("rejects a cwd that points at a file with invalidParams", async () => {
|
||||
const file = path.join(os.tmpdir(), "claude-acp-cwd-is-a-file.txt");
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-cwd-is-a-file-"));
|
||||
const file = path.join(dir, "not-a-directory.txt");
|
||||
fs.writeFileSync(file, "not a directory");
|
||||
try {
|
||||
await expect(agent.newSession({ cwd: file, mcpServers: [] })).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(file, { force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
const { registerHookCallbackSpy } = vi.hoisted(() => ({
|
||||
registerHookCallbackSpy: vi.fn(),
|
||||
@@ -84,7 +84,7 @@ describe("session config options", () => {
|
||||
let setModelSpy: ReturnType<typeof vi.fn>;
|
||||
let applyFlagSettingsSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
function createMockClient(): AcpClient {
|
||||
return {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
sessionUpdates.push(notification);
|
||||
@@ -92,7 +92,7 @@ describe("session config options", () => {
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
function populateSession() {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
function createMockClient(): AcpClient {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecycle", () => {
|
||||
@@ -165,7 +165,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
const commandName =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>";
|
||||
@@ -274,7 +274,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
// Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579.
|
||||
const concatenated =
|
||||
|
||||
@@ -7,13 +7,26 @@ import * as os from "node:os";
|
||||
describe("SettingsManager", () => {
|
||||
let tempDir: string;
|
||||
let settingsManager: SettingsManager;
|
||||
let originalConfigDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-"));
|
||||
// Isolate the user tier. resolveSettings reads the user settings.json from
|
||||
// CLAUDE_CONFIG_DIR at call time; without this it falls back to the real
|
||||
// ~/.claude and the developer's own settings (e.g. availableModels) leak
|
||||
// into the merge, making union-based assertions fail on their machine.
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, "user-config");
|
||||
await fs.promises.mkdir(process.env.CLAUDE_CONFIG_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
settingsManager?.dispose();
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
|
||||
}
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { AgentSideConnection, ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import { ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaMCPToolResultBlock,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import { AcpClient, toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import {
|
||||
toolUpdateFromToolResult,
|
||||
createPostToolUseHook,
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "../tools.js";
|
||||
|
||||
describe("rawOutput in tool call updates", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockClient = {} as AcpClient;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
it("should include rawOutput with string content for tool_result", () => {
|
||||
@@ -425,7 +425,7 @@ describe("rawOutput in tool call updates", () => {
|
||||
});
|
||||
|
||||
describe("Bash terminal output", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockClient = {} as AcpClient;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
const bashToolUse = {
|
||||
@@ -702,6 +702,77 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with image array tool_result (local Bash image output path)", () => {
|
||||
// The local Bash tool emits image content as
|
||||
// `[{ type: "image", source: { type: "base64", ... } }]` when a
|
||||
// command produces an image (e.g. piping a base64 data URI).
|
||||
const makeImageBashResult = (
|
||||
data: string,
|
||||
media_type: "image/png" | "image/jpeg" | "image/gif" | "image/webp" = "image/png",
|
||||
): ToolResultBlockParam => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type, data },
|
||||
} as ImageBlockParam,
|
||||
],
|
||||
});
|
||||
|
||||
it("should surface image content as ACP image content (terminal disabled)", () => {
|
||||
const toolResult = makeImageBashResult("iVBORw0KGgo=");
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
|
||||
},
|
||||
]);
|
||||
expect(update._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should bypass terminal _meta even when supportsTerminalOutput is true", () => {
|
||||
// Binary content cannot be streamed through the terminal-output
|
||||
// _meta channel, so the fix returns ACP content directly and skips
|
||||
// the terminal info/output/exit triple.
|
||||
const toolResult = makeImageBashResult("iVBORw0KGgo=", "image/jpeg");
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "image", data: "iVBORw0KGgo=", mimeType: "image/jpeg" },
|
||||
},
|
||||
]);
|
||||
expect(update._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should still surface multi-block content with text + image mixed", () => {
|
||||
const toolResult: ToolResultBlockParam = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: [
|
||||
{ type: "text", text: "generated:" },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "AAAA" },
|
||||
} as ImageBlockParam,
|
||||
],
|
||||
};
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
|
||||
expect(update.content).toEqual([
|
||||
{ type: "content", content: { type: "text", text: "generated:" } },
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "image", data: "AAAA", mimeType: "image/png" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications with clientCapabilities", () => {
|
||||
@@ -929,7 +1000,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
// Register hook callback by processing tool_use
|
||||
toAcpNotifications(
|
||||
@@ -1008,7 +1079,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
@@ -1093,7 +1164,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
@@ -1147,7 +1218,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
@@ -1223,7 +1294,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
@@ -1304,7 +1375,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
// Step 1: Process tool_use chunk — registers the PostToolUse hook callback
|
||||
const toolUseChunk = {
|
||||
@@ -1404,7 +1475,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
// Process tool_use (registers hook)
|
||||
toAcpNotifications(
|
||||
@@ -1569,7 +1640,7 @@ describe("planEntries - undefined input regression", () => {
|
||||
});
|
||||
|
||||
describe("toAcpNotifications - TodoWrite with undefined input regression", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockClient = {} as AcpClient;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
it("should not throw when TodoWrite tool_use has undefined input", () => {
|
||||
@@ -1704,7 +1775,7 @@ describe("applyTaskCreate / applyTaskUpdate", () => {
|
||||
});
|
||||
|
||||
describe("toAcpNotifications - Task* tools", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockClient = {} as AcpClient;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
it("suppresses tool_call for TaskCreate/TaskUpdate/TaskList/TaskGet on tool_use", () => {
|
||||
|
||||
25
src/tools.ts
25
src/tools.ts
@@ -530,7 +530,9 @@ export function toolUpdateFromToolResult(
|
||||
// Extract output and exit code from either format:
|
||||
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
|
||||
// 2. Plain string content from a regular tool_result
|
||||
// 3. Array content (e.g. [{ type: "text", text: "..." }])
|
||||
// 3. Array content (e.g. [{ type: "text", text: "..." }] for stdout,
|
||||
// or [{ type: "image", source: {...} }] when the local Bash tool
|
||||
// produces an image, e.g. piping a base64 data URI)
|
||||
let output = "";
|
||||
let exitCode = isError ? 1 : 0;
|
||||
|
||||
@@ -545,13 +547,20 @@ export function toolUpdateFromToolResult(
|
||||
exitCode = bashResult.return_code;
|
||||
} else if (typeof result === "string") {
|
||||
output = result;
|
||||
} else if (
|
||||
Array.isArray(result) &&
|
||||
result.length > 0 &&
|
||||
"text" in result[0] &&
|
||||
typeof result[0].text === "string"
|
||||
) {
|
||||
output = result.map((c: any) => c.text).join("\n");
|
||||
} else if (Array.isArray(result) && result.length > 0) {
|
||||
const textOnly = result.every(
|
||||
(c: any) => c && typeof c === "object" && typeof c.text === "string",
|
||||
);
|
||||
if (textOnly) {
|
||||
output = result.map((c: any) => c.text).join("\n");
|
||||
} else {
|
||||
// Image (or mixed non-text) content. Binary payloads can't be
|
||||
// streamed through the terminal-output _meta channel, so bypass
|
||||
// it and surface the blocks as ACP content. This handles the
|
||||
// local Bash tool's image output, which previously failed the
|
||||
// text-only guard and was silently dropped.
|
||||
return toAcpContentUpdate(result, isError);
|
||||
}
|
||||
}
|
||||
|
||||
if (supportsTerminalOutput) {
|
||||
|
||||
Reference in New Issue
Block a user