mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837989ef96 | ||
|
|
ff2a8a9635 | ||
|
|
6e0179288a | ||
|
|
307ab82712 | ||
|
|
57b00ccac6 | ||
|
|
15dfd83daa | ||
|
|
648e3f60bd | ||
|
|
8d5febf854 | ||
|
|
15ec9ab0dc | ||
|
|
98c284bb87 | ||
|
|
71d93b3ada | ||
|
|
ef42c46e5a | ||
|
|
fa949a20db | ||
|
|
b8df8e0e54 | ||
|
|
64aa130c66 | ||
|
|
47fee47709 | ||
|
|
c95fc884e8 | ||
|
|
5dd8746237 | ||
|
|
0a0468c720 | ||
|
|
e9163f8553 | ||
|
|
61272acb30 | ||
|
|
9616bdac47 | ||
|
|
1fe7ec09a3 | ||
|
|
228f02ecfb | ||
|
|
23626c9a43 | ||
|
|
8f6ebd1d91 | ||
|
|
07911601cc | ||
|
|
a7e6137f68 | ||
|
|
044c43e0c8 | ||
|
|
9013d1d468 | ||
|
|
d5e1b7fa72 | ||
|
|
960f62d765 | ||
|
|
508453c288 | ||
|
|
8dc8c86426 | ||
|
|
b4b7f561e2 | ||
|
|
41cde99fd7 | ||
|
|
5729c471f9 | ||
|
|
a759e64ef6 | ||
|
|
15fdf26fc7 | ||
|
|
9f38cb67df | ||
|
|
744b2d4112 | ||
|
|
2554c7bf98 | ||
|
|
1c80bf8e56 | ||
|
|
ad3b5fe745 | ||
|
|
794aa84684 | ||
|
|
12d34e64e5 | ||
|
|
59a098c2b5 | ||
|
|
c27e6aec90 | ||
|
|
4f273a20d8 | ||
|
|
b3640599ae | ||
|
|
58549ffe6a | ||
|
|
116f06eab1 | ||
|
|
db6eaaf714 | ||
|
|
d58004a348 | ||
|
|
0525e6dacb | ||
|
|
cc2885f6a9 | ||
|
|
1be5ca57ee | ||
|
|
7de5e4bcca | ||
|
|
85a45b2895 | ||
|
|
d8af943a1e | ||
|
|
7a701623df | ||
|
|
e692462249 | ||
|
|
bd0ae4dde1 | ||
|
|
7ff6b7fd6b | ||
|
|
12bd2762d9 | ||
|
|
0dbccf588f | ||
|
|
9854b0c679 | ||
|
|
d877ee7133 | ||
|
|
18516a3088 | ||
|
|
ec14211056 | ||
|
|
398f763f71 | ||
|
|
23cbe6f58f | ||
|
|
7ffb78c572 | ||
|
|
f4704c168f | ||
|
|
49d73b2ad3 | ||
|
|
cffea4be28 | ||
|
|
add7e31704 | ||
|
|
f1736a9371 | ||
|
|
895422c72d | ||
|
|
32175b83a0 | ||
|
|
9cda07ba9d | ||
|
|
51a370e4fe | ||
|
|
edde96ebaf | ||
|
|
3fb6db4e5e | ||
|
|
41e3771881 | ||
|
|
61ebda2c60 | ||
|
|
98b54a02c2 | ||
|
|
a172885c80 | ||
|
|
7ed1daf7b4 | ||
|
|
694221a412 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.37.0"
|
||||
".": "0.56.0"
|
||||
}
|
||||
|
||||
239
CHANGELOG.md
239
CHANGELOG.md
@@ -1,5 +1,244 @@
|
||||
# Changelog
|
||||
|
||||
## [0.56.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.55.0...v0.56.0) (2026-07-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.201 ([#837](https://github.com/agentclientprotocol/claude-agent-acp/issues/837)) ([ff2a8a9](https://github.com/agentclientprotocol/claude-agent-acp/commit/ff2a8a963536a4265700e4f2003a0ffff93c9087))
|
||||
|
||||
## [0.55.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.54.1...v0.55.0) (2026-07-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add refusal fallback consent dialog support ([#834](https://github.com/agentclientprotocol/claude-agent-acp/issues/834)) ([15dfd83](https://github.com/agentclientprotocol/claude-agent-acp/commit/15dfd83daa84ced98ecbaa7c83fd5a53124030c1))
|
||||
* **deps-dev:** bump the minor group with 3 updates ([#831](https://github.com/agentclientprotocol/claude-agent-acp/issues/831)) ([8d5febf](https://github.com/agentclientprotocol/claude-agent-acp/commit/8d5febf85485c1ad19b81501fb5c6d4275448a7c))
|
||||
* Update to claude-agent-sdk 0.3.198 ([#836](https://github.com/agentclientprotocol/claude-agent-acp/issues/836)) ([307ab82](https://github.com/agentclientprotocol/claude-agent-acp/commit/307ab82712213413446466d808e161073e94fc7f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Handle model refusal fallback updates ([#833](https://github.com/agentclientprotocol/claude-agent-acp/issues/833)) ([648e3f6](https://github.com/agentclientprotocol/claude-agent-acp/commit/648e3f60bd7e6eadb5f86b0dc475104ab12e7b02))
|
||||
* Handle SDK idle turns without results ([#835](https://github.com/agentclientprotocol/claude-agent-acp/issues/835)) ([57b00cc](https://github.com/agentclientprotocol/claude-agent-acp/commit/57b00ccac610e2eeaf0a753934a0c3b78d27ccb0))
|
||||
|
||||
## [0.54.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.54.0...v0.54.1) (2026-06-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply modelOverrides when resolving availableModels allowlist ([#827](https://github.com/agentclientprotocol/claude-agent-acp/issues/827)) ([98c284b](https://github.com/agentclientprotocol/claude-agent-acp/commit/98c284bb871a96710596dfdc2b9ea25d2400f6b2))
|
||||
|
||||
## [0.54.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.53.0...v0.54.0) (2026-06-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add Fast mode session config support ([#828](https://github.com/agentclientprotocol/claude-agent-acp/issues/828)) ([fa949a2](https://github.com/agentclientprotocol/claude-agent-acp/commit/fa949a20db0e33f2d63aebeb37e9fea212d8ee95))
|
||||
* **deps-dev:** bump prettier from 3.9.1 to 3.9.3 in the minor group ([#821](https://github.com/agentclientprotocol/claude-agent-acp/issues/821)) ([b8df8e0](https://github.com/agentclientprotocol/claude-agent-acp/commit/b8df8e0e5460fd782214f4dde488f7476c80c454))
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.197 for Sonnet 5 ([#826](https://github.com/agentclientprotocol/claude-agent-acp/issues/826)) ([ef42c46](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef42c46e5aea3bb53f433b9f9dd36a62a9f2df6a))
|
||||
* **deps:** bump fast-uri from 3.1.2 to 3.1.3 ([#822](https://github.com/agentclientprotocol/claude-agent-acp/issues/822)) ([64aa130](https://github.com/agentclientprotocol/claude-agent-acp/commit/64aa130c66c299f9835193796ba372b3ed40e7c1))
|
||||
|
||||
## [0.53.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.52.0...v0.53.0) (2026-06-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add ACP logout support ([#816](https://github.com/agentclientprotocol/claude-agent-acp/issues/816)) ([0a0468c](https://github.com/agentclientprotocol/claude-agent-acp/commit/0a0468c72092b7ceccc551b4353dd92f9c22cbb8))
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.195 ([#818](https://github.com/agentclientprotocol/claude-agent-acp/issues/818)) ([5dd8746](https://github.com/agentclientprotocol/claude-agent-acp/commit/5dd87462376597eb7b695200f4c716af46e764d6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Emit tool_call before permission request ([#820](https://github.com/agentclientprotocol/claude-agent-acp/issues/820)) ([c95fc88](https://github.com/agentclientprotocol/claude-agent-acp/commit/c95fc884e82e5a297d1b17b8965690260867835b))
|
||||
|
||||
## [0.52.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.51.0...v0.52.0) (2026-06-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add version flag handling ([#813](https://github.com/agentclientprotocol/claude-agent-acp/issues/813)) ([9616bda](https://github.com/agentclientprotocol/claude-agent-acp/commit/9616bdac47505e4a14c36d667fcffc9ae97e1f2a)), closes [#809](https://github.com/agentclientprotocol/claude-agent-acp/issues/809)
|
||||
* **deps-dev:** bump expect-type from 1.3.0 to 1.4.0 in the minor group ([#814](https://github.com/agentclientprotocol/claude-agent-acp/issues/814)) ([61272ac](https://github.com/agentclientprotocol/claude-agent-acp/commit/61272acb30dcafaa2455d334b11ce2ad97339707))
|
||||
* **deps:** Update @anthropic-ai/claude-agent-sdk to 0.3.191 ([#810](https://github.com/agentclientprotocol/claude-agent-acp/issues/810)) ([228f02e](https://github.com/agentclientprotocol/claude-agent-acp/commit/228f02ecfb23be16e59e121c6b42c0f2b2f40a4e))
|
||||
* Push session title updates at turn end ([#812](https://github.com/agentclientprotocol/claude-agent-acp/issues/812)) ([1fe7ec0](https://github.com/agentclientprotocol/claude-agent-acp/commit/1fe7ec09a3a7bcb7501231dae4b0ffe6ef9b70a4))
|
||||
|
||||
## [0.51.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.50.0...v0.51.0) (2026-06-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump the minor group with 11 updates ([#807](https://github.com/agentclientprotocol/claude-agent-acp/issues/807)) ([8f6ebd1](https://github.com/agentclientprotocol/claude-agent-acp/commit/8f6ebd1d9198edf723f4c8c1aa2b49b906c46646))
|
||||
|
||||
## [0.50.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.49.0...v0.50.0) (2026-06-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **acp:** Handle ACP request cancellation signals ([#801](https://github.com/agentclientprotocol/claude-agent-acp/issues/801)) ([9013d1d](https://github.com/agentclientprotocol/claude-agent-acp/commit/9013d1d46883a7f3774a63a9dca16c4a0f634a97))
|
||||
* **deps:** bump actions/checkout from 6.0.3 to 7.0.0 ([#803](https://github.com/agentclientprotocol/claude-agent-acp/issues/803)) ([044c43e](https://github.com/agentclientprotocol/claude-agent-acp/commit/044c43e0c894082b9e747c01e9dbde6e21036823))
|
||||
* **deps:** upgrade to @anthropic-ai/claude-agent-sdk@0.3.186 ([#806](https://github.com/agentclientprotocol/claude-agent-acp/issues/806)) ([a7e6137](https://github.com/agentclientprotocol/claude-agent-acp/commit/a7e6137f6877b72b8daa39e74971c3559db8d28f))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.3.178 ([#777](https://github.com/agentclientprotocol/claude-agent-acp/issues/777)) ([58549ff](https://github.com/agentclientprotocol/claude-agent-acp/commit/58549ffe6a8b02ce59894e567407bd4299c11428))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Better handle out of turn events ([#780](https://github.com/agentclientprotocol/claude-agent-acp/issues/780)) ([4f273a2](https://github.com/agentclientprotocol/claude-agent-acp/commit/4f273a20d870c9c69f71556b8e0519f1de30f285))
|
||||
* Forward option details in elicitation meta ([#779](https://github.com/agentclientprotocol/claude-agent-acp/issues/779)) ([b364059](https://github.com/agentclientprotocol/claude-agent-acp/commit/b3640599ae685beecacd93e012d5bbc9dac716f7)), closes [#764](https://github.com/agentclientprotocol/claude-agent-acp/issues/764)
|
||||
|
||||
## [0.45.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.45.0...v0.45.1) (2026-06-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix terminal error printing as text instead of terminal output ([#776](https://github.com/agentclientprotocol/claude-agent-acp/issues/776)) ([db6eaaf](https://github.com/agentclientprotocol/claude-agent-acp/commit/db6eaaf71484a321e47093ad65bcf8994943cb31))
|
||||
* Scope custom answers per question ([#774](https://github.com/agentclientprotocol/claude-agent-acp/issues/774)) ([d58004a](https://github.com/agentclientprotocol/claude-agent-acp/commit/d58004a34880e0a76833697319eb2a9efa6a43c7))
|
||||
|
||||
## [0.45.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.44.0...v0.45.0) (2026-06-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** bump the minor group with 3 updates ([#763](https://github.com/agentclientprotocol/claude-agent-acp/issues/763)) ([7de5e4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/7de5e4bcca9bfea70593092060f82bc8abe33e0e))
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.177 ([#771](https://github.com/agentclientprotocol/claude-agent-acp/issues/771)) ([1be5ca5](https://github.com/agentclientprotocol/claude-agent-acp/commit/1be5ca57ee772fe90e41126365dc4186a18ad257))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preserve ANTHROPIC_CUSTOM_MODEL_OPTION when availableModels is set ([#768](https://github.com/agentclientprotocol/claude-agent-acp/issues/768)) ([cc2885f](https://github.com/agentclientprotocol/claude-agent-acp/commit/cc2885f6a9993cf61e759c3c770015f94c218627))
|
||||
|
||||
## [0.44.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.43.0...v0.44.0) (2026-06-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump the minor group with 14 updates ([#758](https://github.com/agentclientprotocol/claude-agent-acp/issues/758)) ([7a70162](https://github.com/agentclientprotocol/claude-agent-acp/commit/7a701623df2e05aec8e552bdfd1cf573413c3471))
|
||||
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.3.170 ([#761](https://github.com/agentclientprotocol/claude-agent-acp/issues/761)) ([d8af943](https://github.com/agentclientprotocol/claude-agent-acp/commit/d8af943a1efef9be27d771cec089d2f1cb56c523))
|
||||
|
||||
## [0.43.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.42.0...v0.43.0) (2026-06-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add experimental elicitation support ([#756](https://github.com/agentclientprotocol/claude-agent-acp/issues/756)) ([12bd276](https://github.com/agentclientprotocol/claude-agent-acp/commit/12bd2762d9ba0ccb7497a3848a5928b8bb2ce820))
|
||||
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.3.169 ([#754](https://github.com/agentclientprotocol/claude-agent-acp/issues/754)) ([bd0ae4d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bd0ae4dde160f59559ab4ac1bf703fcf151eff17))
|
||||
* Update ACP SDK to 0.25.0 ([#753](https://github.com/agentclientprotocol/claude-agent-acp/issues/753)) ([0dbccf5](https://github.com/agentclientprotocol/claude-agent-acp/commit/0dbccf588ff702015494dd29969d47f0b7402feb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Forward unstreamed assistant text blocks ([#757](https://github.com/agentclientprotocol/claude-agent-acp/issues/757)) ([7ff6b7f](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ff6b7fd6b157aedbce0c636087f51ae7d1df3a7))
|
||||
* Validate cwd before creating sessions ([#751](https://github.com/agentclientprotocol/claude-agent-acp/issues/751)) ([9854b0c](https://github.com/agentclientprotocol/claude-agent-acp/commit/9854b0c6790ebafff514e3c4c8e03a131f065a4a)), closes [#749](https://github.com/agentclientprotocol/claude-agent-acp/issues/749)
|
||||
|
||||
## [0.42.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.41.0...v0.42.0) (2026-06-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** bump obug from 2.1.1 to 2.1.2 in the minor group ([#743](https://github.com/agentclientprotocol/claude-agent-acp/issues/743)) ([7ffb78c](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ffb78c57280fc32af77862896c0035ce0e690b7))
|
||||
* Send message ids for assistant and user messages ([#750](https://github.com/agentclientprotocol/claude-agent-acp/issues/750)) ([18516a3](https://github.com/agentclientprotocol/claude-agent-acp/commit/18516a3088f847a38f5eee0fe81ad5ca3e2a751c))
|
||||
* Update Claude Agent SDK to 0.3.165 ([#746](https://github.com/agentclientprotocol/claude-agent-acp/issues/746)) ([23cbe6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/23cbe6f58fa9e10ad736f664c4cf94dbd1c6ad65))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Prevent cross-family model matching in resolveModelPreference ([#731](https://github.com/agentclientprotocol/claude-agent-acp/issues/731)) ([f4704c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/f4704c168f917e6710f2b3cd49e1fef49613a469))
|
||||
* Prune tool cache per session after results ([#748](https://github.com/agentclientprotocol/claude-agent-acp/issues/748)) ([ec14211](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec142110561f680330c6976783805685c5c069ec))
|
||||
* Update to better utilize existing SDK data ([#747](https://github.com/agentclientprotocol/claude-agent-acp/issues/747)) ([398f763](https://github.com/agentclientprotocol/claude-agent-acp/commit/398f763f71bd2fe2ca9e8269de120d26a744b7a8))
|
||||
|
||||
## [0.41.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.40.0...v0.41.0) (2026-06-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** upgrade to @anthropic-ai/claude-agent-sdk@0.3.162 ([#740](https://github.com/agentclientprotocol/claude-agent-acp/issues/740)) ([add7e31](https://github.com/agentclientprotocol/claude-agent-acp/commit/add7e31704581aa70febb6341ca0e8cee5d93b43))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Force cancellation when SDK query hangs ([#742](https://github.com/agentclientprotocol/claude-agent-acp/issues/742)) ([cffea4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/cffea4be28e6def91027110433a99e80b1be8a0c)), closes [#680](https://github.com/agentclientprotocol/claude-agent-acp/issues/680)
|
||||
|
||||
## [0.40.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.39.0...v0.40.0) (2026-06-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** bump the minor group with 2 updates ([#726](https://github.com/agentclientprotocol/claude-agent-acp/issues/726)) ([51a370e](https://github.com/agentclientprotocol/claude-agent-acp/commit/51a370e4feb8a74279698b156e35b96631a74d74))
|
||||
* **deps:** bump actions/checkout from 6.0.2 to 6.0.3 ([#736](https://github.com/agentclientprotocol/claude-agent-acp/issues/736)) ([9cda07b](https://github.com/agentclientprotocol/claude-agent-acp/commit/9cda07ba9df27b48bd94332e365eed982e3f5613))
|
||||
* **deps:** bump the minor group with 50 updates ([#737](https://github.com/agentclientprotocol/claude-agent-acp/issues/737)) ([32175b8](https://github.com/agentclientprotocol/claude-agent-acp/commit/32175b83a0227d0baa1f7d7eedabe743b4b870e8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Optimize local command marker stripping ([#738](https://github.com/agentclientprotocol/claude-agent-acp/issues/738)) ([895422c](https://github.com/agentclientprotocol/claude-agent-acp/commit/895422c72d5f955903f227dc6887df823d2730e6)), closes [#727](https://github.com/agentclientprotocol/claude-agent-acp/issues/727)
|
||||
|
||||
## [0.39.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.38.0...v0.39.0) (2026-05-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude Agent SDK to 0.3.156 ([#722](https://github.com/agentclientprotocol/claude-agent-acp/issues/722)) ([3fb6db4](https://github.com/agentclientprotocol/claude-agent-acp/commit/3fb6db4e5ee5bd1f431cf5d2ce5331ce983f9597))
|
||||
|
||||
## [0.38.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.37.0...v0.38.0) (2026-05-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump to @anthropic-ai/claude-agent-sdk 0.3.154 (Opus 4.8) ([#716](https://github.com/agentclientprotocol/claude-agent-acp/issues/716)) ([a172885](https://github.com/agentclientprotocol/claude-agent-acp/commit/a172885c80fc32c108a8501268a543641088f543))
|
||||
* Support Opus 4.8 ([#718](https://github.com/agentclientprotocol/claude-agent-acp/issues/718)) ([98b54a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/98b54a02c267cdef619becceccf6ef519fbf8f6f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Remove hide Claude auth flag handling ([#707](https://github.com/agentclientprotocol/claude-agent-acp/issues/707)) ([7ed1daf](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ed1daf7b42088ad149e9bf742f764752b82d093))
|
||||
|
||||
## [0.37.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.36.1...v0.37.0) (2026-05-21)
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export default [
|
||||
"default-case": "error",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
eqeqeq: ["error", "smart"],
|
||||
curly: ["error", "all"],
|
||||
},
|
||||
},
|
||||
|
||||
760
package-lock.json
generated
760
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.37.0",
|
||||
"version": "0.56.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -59,23 +59,27 @@
|
||||
},
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.22.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.146",
|
||||
"@agentclientprotocol/sdk": "1.1.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.201",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.97.1",
|
||||
"@anthropic-ai/sdk": "0.110.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||
"@typescript-eslint/parser": "8.59.4",
|
||||
"eslint": "10.4.0",
|
||||
"@tsconfig/node22": "22.0.5",
|
||||
"@types/node": "26.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.62.1",
|
||||
"@typescript-eslint/parser": "8.62.1",
|
||||
"eslint": "10.6.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.6.0",
|
||||
"prettier": "3.8.3",
|
||||
"globals": "17.7.0",
|
||||
"prettier": "3.9.4",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.7"
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
2958
src/acp-agent.ts
2958
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
405
src/elicitation.ts
Normal file
405
src/elicitation.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ElicitationPropertySchema,
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
* (unstable) elicitation protocol.
|
||||
*
|
||||
* Two distinct SDK surfaces flow through here:
|
||||
*
|
||||
* 1. `onElicitation` — fired when an MCP server requests user input. These map
|
||||
* directly onto ACP `session/create_elicitation` (form or url mode).
|
||||
* 2. The built-in AskUserQuestion tool — when a `canUseTool` callback is
|
||||
* registered the SDK routes its permission check through `canUseTool`
|
||||
* (not the interactive `permission_ask_user_question` dialog). We render
|
||||
* its questions as an ACP form elicitation and feed the user's selections
|
||||
* back as the tool's `updatedInput`, which the tool's own `call()` reads.
|
||||
*/
|
||||
|
||||
/** Modes the connected client advertised support for. */
|
||||
export type ElicitationSupport = {
|
||||
form: boolean;
|
||||
url: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an MCP elicitation request (from the SDK's `onElicitation` callback)
|
||||
* into an ACP `CreateElicitationRequest`. Returns `null` when the request can't
|
||||
* be represented (e.g. a url-mode request with no url).
|
||||
*/
|
||||
export function mcpElicitationToCreateRequest(
|
||||
request: ElicitationRequest,
|
||||
sessionId: string,
|
||||
): CreateElicitationRequest | null {
|
||||
if (request.mode === "url") {
|
||||
if (!request.url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mode: "url",
|
||||
sessionId,
|
||||
message: request.message,
|
||||
url: request.url,
|
||||
// URL elicitations need a stable id so the client can correlate the
|
||||
// later `session/complete_elicitation` notification. MCP servers usually
|
||||
// provide one; fall back to a generated id if not.
|
||||
elicitationId: request.elicitationId ?? randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
// Form mode (the default). The MCP `requestedSchema` is already a JSON Schema
|
||||
// with primitive-typed properties, which is structurally what ACP expects.
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
message: request.message,
|
||||
requestedSchema: normalizeElicitationSchema(request.requestedSchema),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an ACP elicitation response back to the MCP `ElicitResult` the SDK expects
|
||||
* to hand back to the requesting server.
|
||||
*/
|
||||
export function createElicitationResponseToElicitResult(
|
||||
response: CreateElicitationResponse,
|
||||
): ElicitationResult {
|
||||
switch (response.action) {
|
||||
case "accept":
|
||||
return { action: "accept", content: response.content ?? {} };
|
||||
case "decline":
|
||||
return { action: "decline" };
|
||||
case "cancel":
|
||||
default:
|
||||
return { action: "cancel" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single question as supplied by the AskUserQuestion tool. Derived from the
|
||||
* SDK's input type so the shape stays in sync; the SDK validates the model's
|
||||
* tool call against this schema before it reaches us.
|
||||
*/
|
||||
export type AskUserQuestion = AskUserQuestionInput["questions"][number];
|
||||
|
||||
/**
|
||||
* Pull the well-formed questions out of an AskUserQuestion tool input. Returns
|
||||
* `null` when there are no usable questions — including the case where every
|
||||
* entry is malformed and filtering leaves an empty list — so callers can treat
|
||||
* "nothing to ask" uniformly.
|
||||
*/
|
||||
export function extractAskUserQuestions(input: Record<string, unknown>): AskUserQuestion[] | null {
|
||||
const questions = (input as { questions?: unknown }).questions;
|
||||
if (!Array.isArray(questions)) {
|
||||
return null;
|
||||
}
|
||||
const valid = questions.filter(
|
||||
(q): q is AskUserQuestion =>
|
||||
!!q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length > 0,
|
||||
);
|
||||
return valid.length > 0 ? valid : null;
|
||||
}
|
||||
|
||||
/** Stable form-field key for the question at the given index. */
|
||||
function questionFieldKey(index: number): string {
|
||||
return `question_${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form-field key for the per-question free-text "custom answer" field that sits
|
||||
* alongside `question_<n>`. Mirrors the first-party clients, where every
|
||||
* question carries its own "Other" box rather than one form-level field.
|
||||
*/
|
||||
function questionCustomFieldKey(index: number): string {
|
||||
return `question_${index}_custom`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_meta` key under which a bridged enum option carries its structured
|
||||
* `description`/`preview`, since ACP's `EnumOption` has no field for either.
|
||||
* Namespaced like the agent's other `_meta` extensions (`_claude/...`).
|
||||
*/
|
||||
const OPTION_META_KEY = "_claude/askUserQuestionOption";
|
||||
|
||||
/** A single option as supplied by the AskUserQuestion tool. */
|
||||
type AskUserQuestionOption = AskUserQuestion["options"][number];
|
||||
|
||||
/**
|
||||
* Build the `_meta` payload that carries an option's structured `description`
|
||||
* and optional `preview` for clients that can render them as distinct fields.
|
||||
* Returns `undefined` when neither is present, so we don't emit an empty `_meta`.
|
||||
*/
|
||||
function askUserQuestionOptionMeta(
|
||||
option: AskUserQuestionOption,
|
||||
): NonNullable<EnumOption["_meta"]> | undefined {
|
||||
const detail: { description?: string; preview?: string } = {};
|
||||
if (option.description) {
|
||||
detail.description = option.description;
|
||||
}
|
||||
if (option.preview) {
|
||||
detail.preview = option.preview;
|
||||
}
|
||||
return Object.keys(detail).length > 0 ? { [OPTION_META_KEY]: detail } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the AskUserQuestion tool's questions as an ACP form elicitation.
|
||||
*
|
||||
* Fields are keyed by a short stable id (`question_<n>`) rather than the full
|
||||
* question text, so the question text appears in exactly one place per field.
|
||||
* Single-select questions use a titled `oneOf` enum; multi-select questions use
|
||||
* an array with a titled `anyOf` item enum. The enum `const` is always the
|
||||
* option label, since that is what the tool records as the answer.
|
||||
*
|
||||
* Each question is followed by its own optional free-text "custom answer" field
|
||||
* (`question_<n>_custom`), mirroring the CLI's per-question "Other" box: the
|
||||
* user can type their own answer instead of picking an option, scoped to that
|
||||
* specific question. Nothing is marked required, so the user can also just skip
|
||||
* — matching the built-in tool, which always offers Skip + a free-text box.
|
||||
*/
|
||||
export function askUserQuestionsToCreateRequest(
|
||||
questions: AskUserQuestion[],
|
||||
sessionId: string,
|
||||
toolCallId: string | undefined,
|
||||
): CreateElicitationRequest {
|
||||
const single = questions.length === 1;
|
||||
const properties: Record<string, ElicitationPropertySchema> = {};
|
||||
|
||||
questions.forEach((question, index) => {
|
||||
const options: EnumOption[] = question.options.map((option) => {
|
||||
const enumOption: EnumOption = {
|
||||
const: option.label,
|
||||
// `title` stays a flattened "label — description" string so clients that
|
||||
// only read `const`/`title` still surface the description. Clients that
|
||||
// understand the `_meta` below should prefer its structured fields (and
|
||||
// treat `const` as the clean label) rather than parsing this title.
|
||||
title: option.description ? `${option.label} — ${option.description}` : option.label,
|
||||
};
|
||||
// The SDK option carries a `description` (secondary text) and an optional
|
||||
// `preview` (mockups, code snippets, comparisons shown on focus). Neither
|
||||
// has a structural slot in `EnumOption`, so forward them under ACP's
|
||||
// reserved `_meta` extension point for clients that can render them.
|
||||
const meta = askUserQuestionOptionMeta(option);
|
||||
if (meta) {
|
||||
enumOption._meta = meta;
|
||||
}
|
||||
return enumOption;
|
||||
});
|
||||
|
||||
// For a single question the prompt is carried by `message`, so we don't
|
||||
// repeat it in the field description. With multiple questions each field
|
||||
// needs its own question text.
|
||||
const description = single ? undefined : question.question;
|
||||
const title = question.header || undefined;
|
||||
|
||||
properties[questionFieldKey(index)] = question.multiSelect
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
|
||||
properties[questionCustomFieldKey(index)] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
description: "Type your own answer instead of choosing an option above (optional).",
|
||||
};
|
||||
});
|
||||
|
||||
const requestedSchema: ElicitationSchema = {
|
||||
type: "object",
|
||||
properties,
|
||||
};
|
||||
|
||||
const message = single ? questions[0].question : "Please answer the following questions.";
|
||||
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
message,
|
||||
requestedSchema,
|
||||
};
|
||||
}
|
||||
|
||||
/** Outcome of an AskUserQuestion elicitation, decoupled from any transport. */
|
||||
export type AskUserQuestionOutcome =
|
||||
{ action: "answered"; updatedInput: Record<string, unknown> } | { action: "cancel" };
|
||||
|
||||
/**
|
||||
* Fold an ACP elicitation response into the AskUserQuestion tool's input.
|
||||
*
|
||||
* Selected labels are read back from the indexed form fields and written into
|
||||
* `answers` as a `{ [questionText]: label }` map (comma-joining multi-selects)
|
||||
* — the key shape the tool's own `call()` reads. A non-empty per-question
|
||||
* custom-answer field (`question_<n>_custom`) takes precedence over that
|
||||
* question's selection, since the user typed their own answer instead of
|
||||
* picking one. Decline yields empty answers (the model is told the user skipped
|
||||
* rather than the turn aborting); cancel aborts the tool call.
|
||||
*/
|
||||
export function applyAskElicitationResponse(
|
||||
response: CreateElicitationResponse,
|
||||
toolInput: Record<string, unknown>,
|
||||
questions: AskUserQuestion[],
|
||||
): AskUserQuestionOutcome {
|
||||
if (response.action === "cancel") {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
if (response.action === "decline") {
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
|
||||
}
|
||||
|
||||
const content = response.content ?? {};
|
||||
// Typed against the tool's own output schema so the answer/response shapes
|
||||
// stay in sync with what the built-in tool's call() expects to read back.
|
||||
const answers: AskUserQuestionOutput["answers"] = {};
|
||||
questions.forEach((question, index) => {
|
||||
// A typed custom answer wins over the selection: the user chose to write
|
||||
// their own answer for this question instead of picking an option.
|
||||
const custom = content[questionCustomFieldKey(index)];
|
||||
if (typeof custom === "string" && custom.trim() !== "") {
|
||||
answers[question.question] = custom.trim();
|
||||
return;
|
||||
}
|
||||
|
||||
const value = content[questionFieldKey(index)];
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
const text = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
if (text === "") {
|
||||
return;
|
||||
}
|
||||
answers[question.question] = text;
|
||||
});
|
||||
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
|
||||
* The two are structurally compatible JSON Schemas; we just guarantee the
|
||||
* `type: "object"` discriminator is present.
|
||||
*/
|
||||
function normalizeElicitationSchema(
|
||||
schema: Record<string, unknown> | undefined,
|
||||
): ElicitationSchema {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
return { ...(schema as ElicitationSchema), type: "object" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `request_user_dialog` kind the CLI emits when a model refusal has a
|
||||
* fallback available but needs user consent before retrying (e.g. Claude Fable
|
||||
* declining a request with Opus available as the fallback). Declaring this
|
||||
* kind in `supportedDialogKinds` is the opt-in: the CLI fails closed and never
|
||||
* emits an undeclared kind — the flow degrades to the classic refusal error
|
||||
* ending the turn.
|
||||
*/
|
||||
export const REFUSAL_FALLBACK_DIALOG_KIND = "refusal_fallback_prompt";
|
||||
|
||||
/**
|
||||
* Payload of the `refusal_fallback_prompt` dialog. The dialog protocol
|
||||
* transports payloads opaquely, so this shape is recovered from the CLI's own
|
||||
* schema (v2.1.177): `originalModel`/`fallbackModel` are required strings;
|
||||
* `apiRefusalCategory` (nullable), `guidanceText`, and
|
||||
* `retractedMessageUuids` are optional. We ignore `retractedMessageUuids` —
|
||||
* ACP has no way to retract already-streamed chunks.
|
||||
*/
|
||||
export type RefusalFallbackPrompt = {
|
||||
originalModel: string;
|
||||
fallbackModel: string;
|
||||
apiRefusalCategory: string | null;
|
||||
guidanceText?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the opaque dialog payload into a {@link RefusalFallbackPrompt}.
|
||||
* Returns `null` when the required fields are missing or mistyped (a newer CLI
|
||||
* may reshape the payload), so the caller can cancel the dialog and let the
|
||||
* CLI apply its default behavior instead of rendering something misleading.
|
||||
*/
|
||||
export function extractRefusalFallbackPrompt(
|
||||
payload: Record<string, unknown>,
|
||||
): RefusalFallbackPrompt | null {
|
||||
const { originalModel, fallbackModel, apiRefusalCategory, guidanceText } = payload;
|
||||
if (typeof originalModel !== "string" || typeof fallbackModel !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
originalModel,
|
||||
fallbackModel,
|
||||
apiRefusalCategory: typeof apiRefusalCategory === "string" ? apiRefusalCategory : null,
|
||||
...(typeof guidanceText === "string" && guidanceText ? { guidanceText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Form-field key carrying the user's choice in the refusal-fallback form. */
|
||||
const REFUSAL_FALLBACK_CHOICE_KEY = "choice";
|
||||
|
||||
/** Wire values of the dialog's result enum (CLI schema). `edit_prompt` is
|
||||
* deliberately not offered: in the CLI it prefills the composer with the
|
||||
* refused prompt for edit-and-retry, and ACP has no composer-prefill surface
|
||||
* — the user can simply edit and resend on their own. */
|
||||
const RETRY_FALLBACK_RESULT = "retry_fallback";
|
||||
const KEEP_REFUSAL_RESULT = "cancelled";
|
||||
|
||||
/**
|
||||
* Render the refusal-fallback consent prompt as an ACP form elicitation: a
|
||||
* single-select between retrying on the fallback model and keeping the
|
||||
* refusal. The enum `const`s are the dialog's wire result values, so the
|
||||
* response maps back without a translation table.
|
||||
*/
|
||||
export function refusalFallbackToCreateRequest(
|
||||
prompt: RefusalFallbackPrompt,
|
||||
sessionId: string,
|
||||
): CreateElicitationRequest {
|
||||
const category = prompt.apiRefusalCategory ? ` (${prompt.apiRefusalCategory})` : "";
|
||||
const guidance = prompt.guidanceText ? `\n\n${prompt.guidanceText}` : "";
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
message:
|
||||
`${prompt.originalModel} declined this request${category}. ` +
|
||||
`Retry with ${prompt.fallbackModel}? The session would continue on ${prompt.fallbackModel}.` +
|
||||
guidance,
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
[REFUSAL_FALLBACK_CHOICE_KEY]: {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{ const: RETRY_FALLBACK_RESULT, title: `Retry with ${prompt.fallbackModel}` },
|
||||
{ const: KEEP_REFUSAL_RESULT, title: "Keep the refusal" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the elicitation response back to the dialog's result enum. Only an
|
||||
* explicit accept-with-retry resolves to `retry_fallback`; decline, cancel, a
|
||||
* skipped field, or an unrecognized value all keep the refusal — the dialog's
|
||||
* own default — so a dismissed or half-filled form can never trigger a model
|
||||
* switch the user didn't ask for.
|
||||
*/
|
||||
export function refusalFallbackResultFromResponse(response: CreateElicitationResponse): string {
|
||||
if (response.action !== "accept") {
|
||||
return KEEP_REFUSAL_RESULT;
|
||||
}
|
||||
const choice = response.content?.[REFUSAL_FALLBACK_CHOICE_KEY];
|
||||
return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
// `--cli` is checked first so that `--version`/`-v` (and any other flags) are
|
||||
// forwarded to the wrapped native CLI rather than swallowed by our own version
|
||||
// handler below. Our version flag only applies when not delegating.
|
||||
if (process.argv.includes("--cli")) {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const args = process.argv.slice(2).filter((arg) => arg !== "--cli");
|
||||
@@ -33,6 +37,9 @@ if (process.argv.includes("--cli")) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
||||
console.log(packageJson.version);
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Apply env vars from the managed-policy tier before any SDK call so the
|
||||
// SDK subprocess inherits them. Going through resolveSettings (vs. a raw
|
||||
|
||||
38
src/sdk-shims.d.ts
vendored
Normal file
38
src/sdk-shims.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Shims for types the `@anthropic-ai/claude-agent-sdk` 0.3.198 `sdk.d.ts`
|
||||
* references in its `SDKMessage` union but never declares
|
||||
* (`SDKControlRequestProgressMessage`, `SDKConversationResetMessage`). With
|
||||
* `skipLibCheck` the dangling references silently become error-`any`, which
|
||||
* poisons narrowing on the whole union — every exhaustive switch over
|
||||
* `SDKMessage` stops compiling.
|
||||
*
|
||||
* These must stay GLOBAL declarations (no top-level import/export in this
|
||||
* file): unqualified names inside the SDK's module declaration fall back to
|
||||
* the global scope, which is the only way a consumer can supply them. Shapes
|
||||
* are transcribed from the zod schemas embedded in the bundled CLI binary.
|
||||
* Delete this file once the SDK ships the declarations itself — a proper
|
||||
* module-scoped declaration will shadow these.
|
||||
*/
|
||||
|
||||
/** Progress for a long-running client-originated control request (currently
|
||||
* only `side_question`), correlated by `request_id`. */
|
||||
declare type SDKControlRequestProgressMessage = {
|
||||
type: "control_request_progress";
|
||||
request_id: string;
|
||||
status: "started" | "api_retry";
|
||||
attempt?: number;
|
||||
max_retries?: number;
|
||||
retry_delay_ms?: number;
|
||||
error_status?: number | null;
|
||||
uuid: import("crypto").UUID;
|
||||
session_id: string;
|
||||
};
|
||||
|
||||
/** Emitted by `/clear`, plan-mode exit, and fresh-session flows; the surface
|
||||
* should mount a fresh transcript under `new_conversation_id`. */
|
||||
declare type SDKConversationResetMessage = {
|
||||
type: "conversation_reset";
|
||||
new_conversation_id: import("crypto").UUID;
|
||||
uuid: import("crypto").UUID;
|
||||
session_id: string;
|
||||
};
|
||||
@@ -3,4 +3,4 @@ description: Say hello
|
||||
argument-hint: name
|
||||
---
|
||||
|
||||
Respond with "Hello $1" and nothing else.
|
||||
Respond with "Hello $ARGUMENTS" and nothing else.
|
||||
|
||||
@@ -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() {
|
||||
@@ -225,7 +224,9 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
// No setModel call is needed because no override was applied — the SDK is
|
||||
// already on its own default.
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
expect(response.models.currentModelId).toBe("claude-sonnet-4-6");
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
});
|
||||
|
||||
describe("auto mode availability per model", () => {
|
||||
@@ -578,10 +579,12 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5");
|
||||
expect(response.models.currentModelId).toBe("claude-haiku-4-5");
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-haiku-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not inherit display info across mismatched model family versions", async () => {
|
||||
it("does not inherit display info across mismatched model versions", async () => {
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
|
||||
// when the SDK's `opus` alias resolves to Opus 4.7, an allowlist entry
|
||||
// of `claude-opus-4-6` (or `claude-opus-4-6[1m]`) used to substring-match
|
||||
@@ -642,6 +645,221 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(byValue["claude-opus-4-7"].name).toBe("Opus 4.7");
|
||||
expect(byValue["claude-opus-4-7[1m]"].name).toMatch(/Opus 4\.7/);
|
||||
});
|
||||
|
||||
it("does not inherit display info across mismatched model families", async () => {
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
|
||||
// when the SDK's `opus` alias resolves to Opus 4.8, `claude-opus-4-6[1m]`
|
||||
// fails the version check against `opus` but the tokenized matcher
|
||||
// falls through to `sonnet[1m]` because the `1m` context hint alone
|
||||
// is enough to score a match.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-opus-4-6", "claude-opus-4-6[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
displayName: "Sonnet (1M context)",
|
||||
description: "Sonnet 4.6 for long sessions",
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
const byValue: Record<string, { name: string; description?: string }> = {};
|
||||
for (const opt of modelOption.options) {
|
||||
byValue[opt.value] = { name: opt.name, description: opt.description };
|
||||
}
|
||||
|
||||
expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]");
|
||||
expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("preserves ANTHROPIC_CUSTOM_MODEL_OPTION even when absent from the allowlist", async () => {
|
||||
// Per the model-config docs, ANTHROPIC_CUSTOM_MODEL_OPTION adds an entry
|
||||
// "without replacing the built-in aliases" and "appears at the bottom of
|
||||
// the /model picker", so it is exempt from the availableModels allowlist
|
||||
// (the same way the Default option is "not affected by availableModels").
|
||||
// ACP must match: a slim alias allowlist must not hide the custom model
|
||||
// row, and it appears last, after the allowlisted entries.
|
||||
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
|
||||
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["sonnet", "opus", "haiku"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
{ value: "opus", displayName: "Opus", description: "Claude Opus 4.6" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Claude Haiku 4.5" },
|
||||
{
|
||||
value: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"sonnet",
|
||||
"opus",
|
||||
"haiku",
|
||||
"claude-opus-4-8[1m]",
|
||||
]);
|
||||
const custom = modelOption.options.find((o: any) => o.value === "claude-opus-4-8[1m]");
|
||||
expect(custom.name).toBe("Opus 4.8");
|
||||
expect(custom.description).toBe("Claude Opus 4.8");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
} else {
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate the custom model option when also in the allowlist", async () => {
|
||||
// If the user lists the custom model's exact ID in availableModels AND it
|
||||
// is set as ANTHROPIC_CUSTOM_MODEL_OPTION, it must appear exactly once.
|
||||
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["sonnet", "claude-opus-4-8[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
{
|
||||
value: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"sonnet",
|
||||
"claude-opus-4-8[1m]",
|
||||
]);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
} else {
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("maps allowlist aliases through modelOverrides to provider IDs", async () => {
|
||||
// Bedrock/Vertex deployments enforce short aliases in availableModels
|
||||
// but require provider-specific IDs at the API. Without applying
|
||||
// modelOverrides during allowlist resolution, the alias is surfaced as
|
||||
// the model value and passed verbatim to setModel, which the provider
|
||||
// API rejects with a 400 "invalid model identifier".
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-opus-4-6", "claude-sonnet-4-6"],
|
||||
model: "claude-opus-4-6",
|
||||
modelOverrides: {
|
||||
"claude-opus-4-6": "global.anthropic.claude-opus-4-6-v1[1m]",
|
||||
"claude-sonnet-4-6": "global.anthropic.claude-sonnet-4-6[1m]",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { setModelSpy } = mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "opus", displayName: "Opus", description: "Claude Opus 4.6" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"global.anthropic.claude-opus-4-6-v1[1m]",
|
||||
"global.anthropic.claude-sonnet-4-6[1m]",
|
||||
]);
|
||||
// The allowlist synthesized the overridden ID (the SDK only surfaced the
|
||||
// `opus` alias), so the adapter syncs the provider ID via setModel — never
|
||||
// the raw alias that Bedrock would reject.
|
||||
expect(setModelSpy).toHaveBeenCalledWith("global.anthropic.claude-opus-4-6-v1[1m]");
|
||||
expect(modelOption.currentValue).toBe("global.anthropic.claude-opus-4-6-v1[1m]");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
@@ -687,7 +905,9 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-6-1m");
|
||||
expect(response.models.currentModelId).toBe("claude-opus-4-6-1m");
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-opus-4-6-1m",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the initial setModel when the resolved value matches the SDK's model list verbatim", async () => {
|
||||
@@ -727,7 +947,9 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
expect(response.models.currentModelId).toBe("claude-opus-4-6");
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-opus-4-6",
|
||||
);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_MODEL;
|
||||
@@ -777,6 +999,8 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5");
|
||||
expect(response.models.currentModelId).toBe("claude-haiku-4-5");
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-haiku-4-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -39,7 +39,7 @@ vi.mock("../tools.js", async () => ({
|
||||
describe("additionalRoots", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
const tempDirs: string[] = [];
|
||||
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
|
||||
const newSession = (meta: Record<string, unknown>, cwd = process.cwd()) =>
|
||||
agent.newSession({ cwd, mcpServers: [], _meta: meta });
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -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(
|
||||
@@ -79,7 +79,7 @@ describe("additionalRoots", () => {
|
||||
|
||||
it("prefers the official ACP additionalDirectories field over _meta.additionalRoots", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
@@ -89,7 +89,7 @@ describe("additionalRoots", () => {
|
||||
|
||||
it("merges official ACP additionalDirectories with claudeCode SDK additionalDirectories", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { claudeCode: { options: { additionalDirectories: ["/from/sdk"] } } },
|
||||
@@ -99,7 +99,7 @@ describe("additionalRoots", () => {
|
||||
|
||||
it("falls back to _meta.additionalRoots when the official field is omitted", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
});
|
||||
|
||||
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(() => ({
|
||||
@@ -15,7 +14,10 @@ const mockQuery = vi.hoisted(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
@@ -36,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);
|
||||
|
||||
@@ -100,7 +102,7 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -145,7 +147,7 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { 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";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
@@ -44,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 () => {
|
||||
@@ -65,7 +68,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -85,7 +88,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("works when user provides no disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -94,7 +97,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("works when user provides empty disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -110,7 +113,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("sets tools to empty array when disableBuiltInTools is true", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
@@ -133,7 +136,7 @@ describe("createSession options merging", () => {
|
||||
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -155,7 +158,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("inherits HOME and PATH from process.env when no env is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -165,7 +168,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("merges user-provided env vars on top of process.env", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -185,7 +188,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("allows user-provided env vars to override process.env entries", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -203,7 +206,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("defaults tools to claude_code preset when not provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -212,7 +215,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("passes through user-provided tools string array", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -228,7 +231,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
@@ -245,7 +248,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("passes through empty tools array to disable all built-in tools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -261,7 +264,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
describe("systemPrompt via _meta", () => {
|
||||
it("defaults to the claude_code preset when not provided", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
@@ -271,7 +274,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("replaces the preset when a string is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: "custom prompt" },
|
||||
});
|
||||
@@ -281,7 +284,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards append", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { append: "extra instructions" } },
|
||||
});
|
||||
@@ -295,7 +298,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards excludeDynamicSections", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { excludeDynamicSections: true } },
|
||||
});
|
||||
@@ -309,7 +312,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards append and excludeDynamicSections together", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
@@ -329,7 +332,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("ignores caller-provided type/preset overrides", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
@@ -369,7 +372,7 @@ describe("createSession options merging", () => {
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
@@ -381,7 +384,7 @@ describe("createSession options merging", () => {
|
||||
availableModels: ["opus", "sonnet"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
availableModels: ["opus", "sonnet"],
|
||||
@@ -394,7 +397,7 @@ describe("createSession options merging", () => {
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
@@ -403,7 +406,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
it("does not add settings when env var is not set", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toBeUndefined();
|
||||
});
|
||||
@@ -414,7 +417,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -438,13 +441,13 @@ describe("createSession options merging", () => {
|
||||
it("throws on invalid JSON", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = "not-json";
|
||||
|
||||
await expect(agent.newSession({ cwd: "/test", mcpServers: [] })).rejects.toThrow();
|
||||
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [
|
||||
{
|
||||
name: "acp-server",
|
||||
@@ -473,4 +476,264 @@ describe("createSession options merging", () => {
|
||||
// ACP-provided MCP server should also be present
|
||||
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
|
||||
});
|
||||
|
||||
describe("thinking config from MAX_THINKING_TOKENS", () => {
|
||||
let originalMaxThinking: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalMaxThinking = process.env.MAX_THINKING_TOKENS;
|
||||
delete process.env.MAX_THINKING_TOKENS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalMaxThinking !== undefined) {
|
||||
process.env.MAX_THINKING_TOKENS = originalMaxThinking;
|
||||
} else {
|
||||
delete process.env.MAX_THINKING_TOKENS;
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves thinking unset (SDK default) when env var is absent", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
// The deprecated option must not be set either.
|
||||
expect(capturedOptions!.maxThinkingTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps 0 to disabled thinking", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "0";
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "disabled" });
|
||||
});
|
||||
|
||||
it("maps a positive value to a fixed thinking budget", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "12000";
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "enabled", budgetTokens: 12000 });
|
||||
});
|
||||
|
||||
it("ignores a non-numeric value", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "lots";
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets a user-provided thinking option override the env default", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "12000";
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
thinking: { type: "adaptive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cwd validation", () => {
|
||||
it("rejects a relative cwd with invalidParams", async () => {
|
||||
await expect(
|
||||
agent.newSession({ cwd: "relative/path", mcpServers: [] }),
|
||||
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
|
||||
});
|
||||
|
||||
it("rejects a non-existent cwd with invalidParams", async () => {
|
||||
const missing = path.join(os.tmpdir(), "claude-acp-does-not-exist-xyz");
|
||||
await expect(agent.newSession({ cwd: missing, mcpServers: [] })).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a cwd that points at a file with invalidParams", async () => {
|
||||
const 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(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an existing absolute directory", async () => {
|
||||
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("elicitation", () => {
|
||||
it("keeps AskUserQuestion disabled and omits callbacks without elicitation capability", async () => {
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
expect(capturedOptions!.onElicitation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables AskUserQuestion and wires the elicitation callback when form is supported", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
||||
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
||||
});
|
||||
|
||||
it("wires callbacks for url-only elicitation but keeps AskUserQuestion disabled", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { url: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
||||
});
|
||||
|
||||
it("still merges user-provided disallowedTools when AskUserQuestion is enabled", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { disallowedTools: ["WebSearch"] } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
|
||||
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusal fallback dialog", () => {
|
||||
it("declares the dialog kind and callback when the client supports form elicitation", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.supportedDialogKinds).toEqual(["refusal_fallback_prompt"]);
|
||||
expect(typeof capturedOptions!.onUserDialog).toBe("function");
|
||||
});
|
||||
|
||||
it("omits the dialog wiring without form elicitation support (url-only included)", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { url: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
// The CLI fails closed on the undeclared kind, so the flow degrades to
|
||||
// the classic refusal error instead of parking a dialog we can't render.
|
||||
expect(capturedOptions!.supportedDialogKinds).toBeUndefined();
|
||||
expect(capturedOptions!.onUserDialog).toBeUndefined();
|
||||
});
|
||||
|
||||
/** Wire the session up with form support and return the captured dialog
|
||||
* callback plus a mock the test can point the elicitation response at. */
|
||||
async function setupDialog() {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const createElicitation = vi.fn();
|
||||
(
|
||||
agent as unknown as { client: { unstable_createElicitation: unknown } }
|
||||
).client.unstable_createElicitation = createElicitation;
|
||||
return { onUserDialog: capturedOptions!.onUserDialog!, createElicitation };
|
||||
}
|
||||
|
||||
const signal = () => ({ signal: new AbortController().signal });
|
||||
|
||||
it("renders the prompt as a form elicitation and maps the retry choice", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
createElicitation.mockResolvedValue({
|
||||
action: "accept",
|
||||
content: { choice: "retry_fallback" },
|
||||
});
|
||||
|
||||
const result = await onUserDialog(
|
||||
{
|
||||
dialogKind: "refusal_fallback_prompt",
|
||||
payload: {
|
||||
originalModel: "claude-fable-5",
|
||||
fallbackModel: "claude-opus-4-8",
|
||||
apiRefusalCategory: "cyber",
|
||||
},
|
||||
},
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "completed", result: "retry_fallback" });
|
||||
const request = createElicitation.mock.calls[0][0];
|
||||
expect(request.mode).toBe("form");
|
||||
expect(request.message).toContain("claude-fable-5");
|
||||
expect(request.message).toContain("claude-opus-4-8");
|
||||
});
|
||||
|
||||
it("completes with cancelled when the user keeps the refusal", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
createElicitation.mockResolvedValue({ action: "cancel" });
|
||||
|
||||
const result = await onUserDialog(
|
||||
{
|
||||
dialogKind: "refusal_fallback_prompt",
|
||||
payload: { originalModel: "a", fallbackModel: "b" },
|
||||
},
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "completed", result: "cancelled" });
|
||||
});
|
||||
|
||||
it("cancels unrecognized dialog kinds without presenting anything", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
|
||||
const result = await onUserDialog(
|
||||
{ dialogKind: "some_future_dialog", payload: {} },
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "cancelled" });
|
||||
expect(createElicitation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels on a malformed payload without presenting anything", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
|
||||
const result = await onUserDialog(
|
||||
{ dialogKind: "refusal_fallback_prompt", payload: { fallbackModel: 42 } },
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "cancelled" });
|
||||
expect(createElicitation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels when the elicitation request fails", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
createElicitation.mockRejectedValue(new Error("client exploded"));
|
||||
|
||||
const result = await onUserDialog(
|
||||
{
|
||||
dialogKind: "refusal_fallback_prompt",
|
||||
payload: { originalModel: "a", fallbackModel: "b" },
|
||||
},
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "cancelled" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
492
src/tests/elicitation.test.ts
Normal file
492
src/tests/elicitation.test.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ElicitationRequest } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
applyAskElicitationResponse,
|
||||
askUserQuestionsToCreateRequest,
|
||||
AskUserQuestion,
|
||||
createElicitationResponseToElicitResult,
|
||||
extractAskUserQuestions,
|
||||
extractRefusalFallbackPrompt,
|
||||
mcpElicitationToCreateRequest,
|
||||
refusalFallbackResultFromResponse,
|
||||
refusalFallbackToCreateRequest,
|
||||
} from "../elicitation.js";
|
||||
|
||||
const SESSION_ID = "session-1";
|
||||
|
||||
/**
|
||||
* Build a question matching the SDK's (strict) AskUserQuestion schema while
|
||||
* keeping test sites terse. Option descriptions default to "" (the tool always
|
||||
* provides one); pass a description to exercise the "label — description" title.
|
||||
*/
|
||||
function mkQuestion(
|
||||
question: string,
|
||||
options: Array<{ label: string; description?: string; preview?: string }>,
|
||||
opts: { header?: string; multiSelect?: boolean } = {},
|
||||
): AskUserQuestion {
|
||||
return {
|
||||
question,
|
||||
header: opts.header ?? "",
|
||||
multiSelect: opts.multiSelect ?? false,
|
||||
options: options.map((o) => ({
|
||||
label: o.label,
|
||||
description: o.description ?? "",
|
||||
...(o.preview ? { preview: o.preview } : {}),
|
||||
})),
|
||||
} as AskUserQuestion;
|
||||
}
|
||||
|
||||
describe("mcpElicitationToCreateRequest", () => {
|
||||
it("maps a form-mode request, defaulting the schema type to object", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Need your name",
|
||||
mode: "form",
|
||||
requestedSchema: { properties: { name: { type: "string" } } },
|
||||
};
|
||||
|
||||
const result = mcpElicitationToCreateRequest(request, SESSION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
mode: "form",
|
||||
sessionId: SESSION_ID,
|
||||
message: "Need your name",
|
||||
requestedSchema: { type: "object", properties: { name: { type: "string" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a url-mode request and preserves the elicitation id", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Authorize",
|
||||
mode: "url",
|
||||
url: "https://example.com/auth",
|
||||
elicitationId: "el-123",
|
||||
};
|
||||
|
||||
expect(mcpElicitationToCreateRequest(request, SESSION_ID)).toEqual({
|
||||
mode: "url",
|
||||
sessionId: SESSION_ID,
|
||||
message: "Authorize",
|
||||
url: "https://example.com/auth",
|
||||
elicitationId: "el-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("generates an elicitation id when a url request omits one", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Authorize",
|
||||
mode: "url",
|
||||
url: "https://example.com/auth",
|
||||
};
|
||||
|
||||
const result = mcpElicitationToCreateRequest(request, SESSION_ID);
|
||||
expect(result?.mode).toBe("url");
|
||||
expect((result as { elicitationId: string }).elicitationId).toMatch(/.+/);
|
||||
});
|
||||
|
||||
it("returns null for a url request with no url", () => {
|
||||
const request: ElicitationRequest = {
|
||||
serverName: "server",
|
||||
message: "Authorize",
|
||||
mode: "url",
|
||||
};
|
||||
|
||||
expect(mcpElicitationToCreateRequest(request, SESSION_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createElicitationResponseToElicitResult", () => {
|
||||
it("maps accept with content", () => {
|
||||
const response = { action: "accept", content: { name: "Ada" } } as CreateElicitationResponse;
|
||||
expect(createElicitationResponseToElicitResult(response)).toEqual({
|
||||
action: "accept",
|
||||
content: { name: "Ada" },
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults accept content to an empty object", () => {
|
||||
const response = { action: "accept" } as CreateElicitationResponse;
|
||||
expect(createElicitationResponseToElicitResult(response)).toEqual({
|
||||
action: "accept",
|
||||
content: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("maps decline and cancel", () => {
|
||||
expect(
|
||||
createElicitationResponseToElicitResult({ action: "decline" } as CreateElicitationResponse),
|
||||
).toEqual({ action: "decline" });
|
||||
expect(
|
||||
createElicitationResponseToElicitResult({ action: "cancel" } as CreateElicitationResponse),
|
||||
).toEqual({ action: "cancel" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAskUserQuestions", () => {
|
||||
const question = mkQuestion("Which?", [{ label: "A" }, { label: "B" }]);
|
||||
|
||||
it("reads questions off the tool input", () => {
|
||||
expect(extractAskUserQuestions({ questions: [question] })).toEqual([question]);
|
||||
});
|
||||
|
||||
it("returns null when there are no questions", () => {
|
||||
expect(extractAskUserQuestions({})).toBeNull();
|
||||
expect(extractAskUserQuestions({ questions: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("filters out malformed questions (missing options, empty options, bad question)", () => {
|
||||
const input = {
|
||||
questions: [
|
||||
question,
|
||||
{ question: "missing options" },
|
||||
{ options: [] },
|
||||
{ options: [], question: "empty" },
|
||||
],
|
||||
} as unknown as Record<string, unknown>;
|
||||
expect(extractAskUserQuestions(input)).toEqual([question]);
|
||||
});
|
||||
|
||||
it("returns null when every question is filtered out", () => {
|
||||
const input = {
|
||||
questions: [{ question: "no options" }, { options: [{ label: "A" }] }],
|
||||
} as unknown as Record<string, unknown>;
|
||||
expect(extractAskUserQuestions(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("askUserQuestionsToCreateRequest", () => {
|
||||
it("keys a single-select question by a stable id and carries the prompt in message only", () => {
|
||||
const questions = [
|
||||
mkQuestion(
|
||||
"Which library?",
|
||||
[{ label: "date-fns", description: "Lightweight" }, { label: "luxon" }],
|
||||
{
|
||||
header: "Library",
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const result = askUserQuestionsToCreateRequest(questions, SESSION_ID, "tool-1");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mode: "form",
|
||||
sessionId: SESSION_ID,
|
||||
toolCallId: "tool-1",
|
||||
message: "Which library?",
|
||||
});
|
||||
const schema = (result as Extract<CreateElicitationRequest, { mode: "form" }>).requestedSchema;
|
||||
// Nothing is required; the user can pick, type a custom answer, or skip.
|
||||
expect(schema.required).toBeUndefined();
|
||||
// Single question → no duplicated description (prompt is in `message`).
|
||||
expect(schema.properties?.["question_0"]).toEqual({
|
||||
type: "string",
|
||||
title: "Library",
|
||||
description: undefined,
|
||||
oneOf: [
|
||||
{
|
||||
const: "date-fns",
|
||||
title: "date-fns — Lightweight",
|
||||
// Structured description forwarded under `_meta` so clients can render
|
||||
// it as secondary text instead of parsing it out of the title.
|
||||
_meta: { "_claude/askUserQuestionOption": { description: "Lightweight" } },
|
||||
},
|
||||
// No description → no `_meta` emitted.
|
||||
{ const: "luxon", title: "luxon" },
|
||||
],
|
||||
});
|
||||
// The full question text is not used as a property key.
|
||||
expect(schema.properties?.["Which library?"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards option description and preview under _meta for rich clients", () => {
|
||||
const questions = [
|
||||
mkQuestion("Which layout?", [
|
||||
{
|
||||
label: "Grid",
|
||||
description: "Cards in a responsive grid",
|
||||
preview: "```\n[ ] [ ] [ ]\n[ ] [ ] [ ]\n```",
|
||||
},
|
||||
{ label: "List", description: "Stacked rows" },
|
||||
{ label: "Plain" },
|
||||
]),
|
||||
];
|
||||
|
||||
const schema = (
|
||||
askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined) as Extract<
|
||||
CreateElicitationRequest,
|
||||
{ mode: "form" }
|
||||
>
|
||||
).requestedSchema;
|
||||
const oneOf = (schema.properties?.["question_0"] as { oneOf: EnumOption[] }).oneOf;
|
||||
|
||||
// Both description and preview travel structurally; the title still flattens
|
||||
// the description for clients that only read const/title.
|
||||
expect(oneOf[0]).toEqual({
|
||||
const: "Grid",
|
||||
title: "Grid — Cards in a responsive grid",
|
||||
_meta: {
|
||||
"_claude/askUserQuestionOption": {
|
||||
description: "Cards in a responsive grid",
|
||||
preview: "```\n[ ] [ ] [ ]\n[ ] [ ] [ ]\n```",
|
||||
},
|
||||
},
|
||||
});
|
||||
// Description only → preview omitted from _meta.
|
||||
expect(oneOf[1]._meta).toEqual({
|
||||
"_claude/askUserQuestionOption": { description: "Stacked rows" },
|
||||
});
|
||||
// Neither → no _meta at all.
|
||||
expect(oneOf[2]._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes a per-question optional free-text custom-answer field", () => {
|
||||
const questions = [
|
||||
mkQuestion("Which?", [{ label: "A" }, { label: "B" }]),
|
||||
mkQuestion("What else?", [{ label: "C" }, { label: "D" }]),
|
||||
];
|
||||
const schema = (
|
||||
askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined) as Extract<
|
||||
CreateElicitationRequest,
|
||||
{ mode: "form" }
|
||||
>
|
||||
).requestedSchema;
|
||||
|
||||
// Each question gets its own "Other" box, not one shared form-level field.
|
||||
expect(schema.properties?.["question_0_custom"]).toMatchObject({
|
||||
type: "string",
|
||||
title: "Other",
|
||||
});
|
||||
expect(schema.properties?.["question_1_custom"]).toMatchObject({
|
||||
type: "string",
|
||||
title: "Other",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds an array property for multi-select questions and includes per-field question text", () => {
|
||||
const questions = [
|
||||
mkQuestion("Pick one?", [{ label: "A" }, { label: "B" }]),
|
||||
mkQuestion("Which features?", [{ label: "auth" }, { label: "logging" }], {
|
||||
multiSelect: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined);
|
||||
const schema = (result as Extract<CreateElicitationRequest, { mode: "form" }>).requestedSchema;
|
||||
|
||||
expect(result).not.toHaveProperty("toolCallId");
|
||||
// Multiple questions → generic message, each field carries its own question.
|
||||
expect(result.message).toBe("Please answer the following questions.");
|
||||
expect(schema.properties?.["question_1"]).toEqual({
|
||||
type: "array",
|
||||
title: undefined,
|
||||
description: "Which features?",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: "auth", title: "auth" },
|
||||
{ const: "logging", title: "logging" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAskElicitationResponse", () => {
|
||||
const questions = [
|
||||
mkQuestion("Single?", [{ label: "A" }, { label: "B" }]),
|
||||
mkQuestion("Multi?", [{ label: "X" }, { label: "Y" }], { multiSelect: true }),
|
||||
];
|
||||
const toolInput = { questions, metadata: { source: "test" } };
|
||||
|
||||
it("writes selected labels (keyed by indexed fields) into updatedInput on accept", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", question_1: ["X", "Y"] },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "A", "Multi?": "X, Y" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("folds a per-question custom answer into that question's answer", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", question_1_custom: "something else entirely" },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "A", "Multi?": "something else entirely" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a question's custom answer over its selection", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", question_0_custom: " my own take " },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "my own take" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores empty selections and blank custom answers", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_1: [], question_0_custom: " " },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: { questions, metadata: { source: "test" }, answers: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats decline as answered with no answers", () => {
|
||||
const response = { action: "decline" } as CreateElicitationResponse;
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: { questions, metadata: { source: "test" }, answers: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancel on cancel", () => {
|
||||
const response = { action: "cancel" } as CreateElicitationResponse;
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "cancel",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits answers for questions the user left unanswered", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "B" },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
const result = applyAskElicitationResponse(response, toolInput, questions);
|
||||
expect(result.action).toBe("answered");
|
||||
expect((result as Extract<typeof result, { action: "answered" }>).updatedInput.answers).toEqual(
|
||||
{ "Single?": "B" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractRefusalFallbackPrompt", () => {
|
||||
it("extracts required and optional fields", () => {
|
||||
expect(
|
||||
extractRefusalFallbackPrompt({
|
||||
originalModel: "claude-fable-5",
|
||||
fallbackModel: "claude-opus-4-8",
|
||||
apiRefusalCategory: "cyber",
|
||||
guidanceText: "You can retry with the fallback model.",
|
||||
retractedMessageUuids: ["u1"],
|
||||
}),
|
||||
).toEqual({
|
||||
originalModel: "claude-fable-5",
|
||||
fallbackModel: "claude-opus-4-8",
|
||||
apiRefusalCategory: "cyber",
|
||||
guidanceText: "You can retry with the fallback model.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when a required model field is missing or mistyped", () => {
|
||||
expect(extractRefusalFallbackPrompt({ fallbackModel: "claude-opus-4-8" })).toBeNull();
|
||||
expect(
|
||||
extractRefusalFallbackPrompt({ originalModel: 5, fallbackModel: "claude-opus-4-8" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes a non-string category to null and drops empty guidance", () => {
|
||||
expect(
|
||||
extractRefusalFallbackPrompt({
|
||||
originalModel: "a",
|
||||
fallbackModel: "b",
|
||||
apiRefusalCategory: null,
|
||||
guidanceText: "",
|
||||
}),
|
||||
).toEqual({ originalModel: "a", fallbackModel: "b", apiRefusalCategory: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusalFallbackToCreateRequest", () => {
|
||||
const prompt = {
|
||||
originalModel: "claude-fable-5",
|
||||
fallbackModel: "claude-opus-4-8",
|
||||
apiRefusalCategory: "cyber" as string | null,
|
||||
};
|
||||
|
||||
it("builds a single-choice form whose enum consts are the dialog's wire results", () => {
|
||||
const request = refusalFallbackToCreateRequest(prompt, SESSION_ID) as Extract<
|
||||
CreateElicitationRequest,
|
||||
{ mode: "form" }
|
||||
>;
|
||||
expect(request.mode).toBe("form");
|
||||
expect(request).toMatchObject({ sessionId: SESSION_ID });
|
||||
expect(request.message).toContain("claude-fable-5");
|
||||
expect(request.message).toContain("(cyber)");
|
||||
expect(request.message).toContain("claude-opus-4-8");
|
||||
|
||||
const choice = request.requestedSchema.properties?.choice as {
|
||||
type: string;
|
||||
oneOf: Array<{ const: string; title: string }>;
|
||||
};
|
||||
expect(choice.type).toBe("string");
|
||||
expect(choice.oneOf.map((o) => o.const)).toEqual(["retry_fallback", "cancelled"]);
|
||||
expect(choice.oneOf[0].title).toContain("claude-opus-4-8");
|
||||
});
|
||||
|
||||
it("omits the category marker and appends guidance when provided", () => {
|
||||
const request = refusalFallbackToCreateRequest(
|
||||
{ ...prompt, apiRefusalCategory: null, guidanceText: "Consider rephrasing." },
|
||||
SESSION_ID,
|
||||
);
|
||||
expect(request.message).not.toContain("(");
|
||||
expect(request.message).toContain("Consider rephrasing.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusalFallbackResultFromResponse", () => {
|
||||
it("maps an accepted retry choice to retry_fallback", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { choice: "retry_fallback" },
|
||||
} as CreateElicitationResponse;
|
||||
expect(refusalFallbackResultFromResponse(response)).toBe("retry_fallback");
|
||||
});
|
||||
|
||||
it("keeps the refusal for every other outcome", () => {
|
||||
const cases = [
|
||||
{ action: "accept", content: { choice: "cancelled" } },
|
||||
{ action: "accept", content: {} },
|
||||
{ action: "accept", content: { choice: "something-new" } },
|
||||
{ action: "accept" },
|
||||
{ action: "decline" },
|
||||
{ action: "cancel" },
|
||||
] as CreateElicitationResponse[];
|
||||
for (const response of cases) {
|
||||
expect(refusalFallbackResultFromResponse(response)).toBe("cancelled");
|
||||
}
|
||||
});
|
||||
});
|
||||
341
src/tests/fast-mode-config.test.ts
Normal file
341
src/tests/fast-mode-config.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
buildConfigOptions,
|
||||
clientSupportsBooleanConfigOptions,
|
||||
createFastModeConfigOption,
|
||||
fastModeStateEnabled,
|
||||
resolveFastModeEnabled,
|
||||
FAST_MODE_CONFIG_ID,
|
||||
FAST_MODE_ON,
|
||||
FAST_MODE_OFF,
|
||||
ClaudeAcpAgent,
|
||||
type AcpClient,
|
||||
} from "../acp-agent.js";
|
||||
|
||||
const MODES = {
|
||||
currentModeId: "default",
|
||||
availableModes: [{ id: "default", name: "Default", description: "Standard behavior" }],
|
||||
};
|
||||
|
||||
const MODELS = {
|
||||
currentModelId: "claude-opus-4-8",
|
||||
availableModels: [
|
||||
{ modelId: "claude-opus-4-8", name: "Claude Opus", description: "Most capable" },
|
||||
],
|
||||
};
|
||||
|
||||
const MODEL_INFOS: ModelInfo[] = [
|
||||
{ value: "claude-opus-4-8", displayName: "Claude Opus", description: "Most capable" },
|
||||
];
|
||||
|
||||
describe("createFastModeConfigOption", () => {
|
||||
it("produces a native boolean toggle when the client opted in", () => {
|
||||
expect(createFastModeConfigOption(true, true)).toEqual({
|
||||
id: FAST_MODE_CONFIG_ID,
|
||||
name: "Fast mode",
|
||||
description: expect.any(String),
|
||||
category: "model_config",
|
||||
type: "boolean",
|
||||
currentValue: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to an on/off select when the client did not opt in", () => {
|
||||
const option = createFastModeConfigOption(false, false);
|
||||
expect(option).toMatchObject({
|
||||
id: FAST_MODE_CONFIG_ID,
|
||||
type: "select",
|
||||
category: "model_config",
|
||||
currentValue: FAST_MODE_OFF,
|
||||
options: [
|
||||
{ value: FAST_MODE_ON, name: "On" },
|
||||
{ value: FAST_MODE_OFF, name: "Off" },
|
||||
],
|
||||
});
|
||||
expect(option).not.toHaveProperty("currentValue", true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientSupportsBooleanConfigOptions", () => {
|
||||
it("is true only when session.configOptions.boolean is present", () => {
|
||||
expect(
|
||||
clientSupportsBooleanConfigOptions({ session: { configOptions: { boolean: {} } } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when the capability is omitted or null at any level", () => {
|
||||
expect(clientSupportsBooleanConfigOptions(undefined)).toBe(false);
|
||||
expect(clientSupportsBooleanConfigOptions(null)).toBe(false);
|
||||
expect(clientSupportsBooleanConfigOptions({})).toBe(false);
|
||||
expect(clientSupportsBooleanConfigOptions({ session: { configOptions: {} } })).toBe(false);
|
||||
expect(
|
||||
clientSupportsBooleanConfigOptions({ session: { configOptions: { boolean: null } } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFastModeEnabled", () => {
|
||||
const base = { sessionId: "s", configId: FAST_MODE_CONFIG_ID };
|
||||
|
||||
it("accepts native boolean values", () => {
|
||||
expect(resolveFastModeEnabled({ ...base, type: "boolean", value: true })).toBe(true);
|
||||
expect(resolveFastModeEnabled({ ...base, type: "boolean", value: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the on/off select fallback", () => {
|
||||
expect(resolveFastModeEnabled({ ...base, value: FAST_MODE_ON })).toBe(true);
|
||||
expect(resolveFastModeEnabled({ ...base, value: FAST_MODE_OFF })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects any other value", () => {
|
||||
expect(() => resolveFastModeEnabled({ ...base, value: "maybe" })).toThrow(
|
||||
/Invalid value for config option fast/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fastModeStateEnabled", () => {
|
||||
it("treats cooldown as on (the user's intent persists through rate-limit cooldown)", () => {
|
||||
expect(fastModeStateEnabled("on")).toBe(true);
|
||||
expect(fastModeStateEnabled("cooldown")).toBe(true);
|
||||
expect(fastModeStateEnabled("off")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConfigOptions Fast mode", () => {
|
||||
it("omits the Fast mode option when the model does not support it", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
useBooleanOption: true,
|
||||
});
|
||||
expect(options.find((o) => o.id === FAST_MODE_CONFIG_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits the Fast mode option when no fast mode state is provided", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default");
|
||||
expect(options.find((o) => o.id === FAST_MODE_CONFIG_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces a boolean toggle when supported and the client opted in", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
supported: true,
|
||||
enabled: true,
|
||||
useBooleanOption: true,
|
||||
});
|
||||
expect(options).toContainEqual(createFastModeConfigOption(true, true));
|
||||
});
|
||||
|
||||
it("surfaces a select fallback when supported but the client did not opt in", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
supported: true,
|
||||
enabled: false,
|
||||
useBooleanOption: false,
|
||||
});
|
||||
expect(options).toContainEqual(createFastModeConfigOption(false, false));
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionConfigOption Fast mode toggle", () => {
|
||||
const SESSION_ID = "fast-session";
|
||||
|
||||
function setup(opts: { useBooleanOption: boolean }) {
|
||||
const sessionUpdates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (n: SessionNotification) => {
|
||||
sessionUpdates.push(n);
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
const clientCapabilities: ClientCapabilities = opts.useBooleanOption
|
||||
? { session: { configOptions: { boolean: {} } } }
|
||||
: {};
|
||||
(agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities =
|
||||
clientCapabilities;
|
||||
|
||||
const applyFlagSettings = vi.fn();
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
|
||||
query: { applyFlagSettings },
|
||||
fastModeEnabled: false,
|
||||
configOptions: [createFastModeConfigOption(false, opts.useBooleanOption)],
|
||||
};
|
||||
|
||||
return { agent, applyFlagSettings, sessionUpdates };
|
||||
}
|
||||
|
||||
it("toggles Fast mode on/off through a boolean value", async () => {
|
||||
const { agent, applyFlagSettings } = setup({ useBooleanOption: true });
|
||||
|
||||
const onResponse = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: FAST_MODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: true });
|
||||
expect(onResponse.configOptions).toContainEqual(createFastModeConfigOption(true, true));
|
||||
expect(
|
||||
(agent as unknown as { sessions: Record<string, { fastModeEnabled: boolean }> }).sessions[
|
||||
SESSION_ID
|
||||
].fastModeEnabled,
|
||||
).toBe(true);
|
||||
|
||||
const offResponse = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: FAST_MODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: false,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
|
||||
expect(offResponse.configOptions).toContainEqual(createFastModeConfigOption(false, true));
|
||||
});
|
||||
|
||||
it("toggles Fast mode through the on/off select fallback", async () => {
|
||||
const { agent, applyFlagSettings } = setup({ useBooleanOption: false });
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: FAST_MODE_CONFIG_ID,
|
||||
value: FAST_MODE_ON,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: true });
|
||||
expect(response.configOptions).toContainEqual(createFastModeConfigOption(true, false));
|
||||
});
|
||||
|
||||
it("does not change session state when the SDK rejects the flag", async () => {
|
||||
const { agent, applyFlagSettings } = setup({ useBooleanOption: true });
|
||||
applyFlagSettings.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: FAST_MODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
}),
|
||||
).rejects.toThrow("nope");
|
||||
|
||||
const session = (agent as unknown as { sessions: Record<string, { fastModeEnabled: boolean }> })
|
||||
.sessions[SESSION_ID];
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
const SESSION_ID = "fast-session";
|
||||
|
||||
function setup(opts: { fastModeEnabled: boolean; withOption: boolean }) {
|
||||
const sessionUpdates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (n: SessionNotification) => {
|
||||
sessionUpdates.push(n);
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
(agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities = {
|
||||
session: { configOptions: { boolean: {} } },
|
||||
};
|
||||
|
||||
const session = {
|
||||
query: {},
|
||||
fastModeEnabled: opts.fastModeEnabled,
|
||||
configOptions: opts.withOption
|
||||
? [createFastModeConfigOption(opts.fastModeEnabled, true)]
|
||||
: [],
|
||||
};
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = session;
|
||||
|
||||
const sync = (
|
||||
agent as unknown as {
|
||||
syncFastModeState: (
|
||||
sessionId: string,
|
||||
session: unknown,
|
||||
state: string | undefined,
|
||||
) => Promise<void>;
|
||||
}
|
||||
).syncFastModeState.bind(agent);
|
||||
|
||||
return { sync, session, sessionUpdates };
|
||||
}
|
||||
|
||||
it("emits a config_option_update when the SDK reports a new state", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "on");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(true);
|
||||
expect(session.configOptions).toContainEqual(createFastModeConfigOption(true, true));
|
||||
expect(sessionUpdates).toHaveLength(1);
|
||||
expect(sessionUpdates[0].update).toMatchObject({
|
||||
sessionUpdate: "config_option_update",
|
||||
});
|
||||
const updated = (
|
||||
sessionUpdates[0].update as { configOptions: ReturnType<typeof createFastModeConfigOption>[] }
|
||||
).configOptions;
|
||||
expect(updated).toContainEqual(createFastModeConfigOption(true, true));
|
||||
});
|
||||
|
||||
it("leaves the toggle on and quiet during a rate-limit cooldown", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
|
||||
|
||||
// cooldown is a transient suspension of an already-enabled fast mode.
|
||||
await sync(SESSION_ID, session, "cooldown");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(true);
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never lets a stray cooldown spuriously enable a toggle the user has off", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "cooldown");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clears the toggle when the SDK reports off", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "off");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
expect(session.configOptions).toContainEqual(createFastModeConfigOption(false, true));
|
||||
expect(sessionUpdates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is a no-op when the reported state is undefined or unchanged", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, undefined);
|
||||
await sync(SESSION_ID, session, "off");
|
||||
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves the retained setting (no clobber) when the model has no Fast mode option", async () => {
|
||||
// Model without fast support: the SDK reports a capability-driven state, not
|
||||
// the user's intent. We must leave session.fastModeEnabled untouched so it's
|
||||
// correct when a supporting model is reselected — reconciling here was the
|
||||
// original intent-clobber bug.
|
||||
const enabledCase = setup({ fastModeEnabled: true, withOption: false });
|
||||
await enabledCase.sync(SESSION_ID, enabledCase.session, "off");
|
||||
expect(enabledCase.session.fastModeEnabled).toBe(true);
|
||||
expect(enabledCase.sessionUpdates).toHaveLength(0);
|
||||
|
||||
const disabledCase = setup({ fastModeEnabled: false, withOption: false });
|
||||
await disabledCase.sync(SESSION_ID, disabledCase.session, "on");
|
||||
expect(disabledCase.session.fastModeEnabled).toBe(false);
|
||||
expect(disabledCase.sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
150
src/tests/model-resolution.test.ts
Normal file
150
src/tests/model-resolution.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { resolveModelPreference, applyAvailableModelsAllowlist } from "../acp-agent.js";
|
||||
|
||||
// Mirrors a real `supportedModels()` response: alias rows carry
|
||||
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
|
||||
// "claude-opus-4-6"-style ids.
|
||||
const LIVE_SHAPED_MODELS: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-opus-4-8[1m]",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Use the default model (currently Opus 4.8 (1M context))",
|
||||
},
|
||||
{
|
||||
value: "opus[1m]",
|
||||
resolvedModel: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.8 with 1M context · Best for everyday, complex tasks",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 · Efficient for routine tasks",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-4-5-20251001",
|
||||
displayName: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
];
|
||||
|
||||
describe("resolveModelPreference - resolvedModel matching", () => {
|
||||
it("matches a preference that equals an alias's resolvedModel exactly", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-sonnet-5")?.value).toBe("sonnet");
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8[1m]")?.value).toBe(
|
||||
"opus[1m]",
|
||||
);
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-haiku-4-5-20251001")?.value).toBe(
|
||||
"haiku",
|
||||
);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "CLAUDE-SONNET-5")?.value).toBe("sonnet");
|
||||
});
|
||||
|
||||
it("still resolves plain aliases via the direct-match tier", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "sonnet")?.value).toBe("sonnet");
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "opus[1m]")?.value).toBe("opus[1m]");
|
||||
});
|
||||
|
||||
it("does not let a retired dated id drift onto a same-family alias of a different generation", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-sonnet-4-6")).toBeNull();
|
||||
});
|
||||
|
||||
// "opus[1m]"'s `value` has no dotted version, just the context-hint digit
|
||||
// in "[1m]" — unstripped, that reads as version "1" and blocks the match
|
||||
// before the matcher reaches the description's real "4.8".
|
||||
it("does not mistake an alias's context-hint suffix for its version", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8")?.value).toBe("opus[1m]");
|
||||
});
|
||||
|
||||
it("returns null for a preference that matches nothing", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - resolvedModel matching", () => {
|
||||
it("resolves an allowlist entry pinned to a canonical resolvedModel id", () => {
|
||||
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);
|
||||
const entry = result.find((m) => m.value === "claude-sonnet-5");
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.displayName).toBe("Sonnet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - modelOverrides", () => {
|
||||
const SDK_MODELS: ModelInfo[] = [
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "Claude Opus 4.6",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "high"],
|
||||
},
|
||||
];
|
||||
|
||||
it("surfaces the override target as the value while keeping the alias's display metadata", () => {
|
||||
// The override target is an opaque Bedrock ARN with no "opus" substring,
|
||||
// so matching against it (instead of the "claude-opus-4-6" alias) would
|
||||
// find nothing and silently drop displayName/description/supportsEffort.
|
||||
const overrides = {
|
||||
"claude-opus-4-6": "arn:aws:bedrock:us-west-2:111122223333:inference-profile/custom-7f3a",
|
||||
};
|
||||
const result = applyAvailableModelsAllowlist(SDK_MODELS, ["claude-opus-4-6"], overrides);
|
||||
|
||||
const entry = result.find((m) => m.value === overrides["claude-opus-4-6"]);
|
||||
expect(entry).toEqual({
|
||||
value: overrides["claude-opus-4-6"],
|
||||
displayName: "Opus",
|
||||
description: "Claude Opus 4.6",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "high"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Runs against the real SDK (no mocks) so the fixture above is flagged the
|
||||
// moment the SDK's actual model list shape drifts from it. Requires a usable
|
||||
// ANTHROPIC_API_KEY, hence opt-in via RUN_INTEGRATION_TESTS.
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("model resolution (real SDK)", () => {
|
||||
it("every model's resolvedModel (when present) resolves back to that same row", async () => {
|
||||
const q = query({ prompt: "hi", options: { sessionId: randomUUID() } });
|
||||
try {
|
||||
const models = await q.supportedModels();
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
for (const model of models) {
|
||||
// "default" shares a resolvedModel with the CLI's recommended named
|
||||
// alias, and resolution intentionally prefers that alias on a tie.
|
||||
if (!model.resolvedModel || model.value === "default") continue;
|
||||
const resolved = resolveModelPreference(models, model.resolvedModel);
|
||||
expect(resolved?.value).toBe(model.value);
|
||||
}
|
||||
} finally {
|
||||
q.return(undefined);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("resolves the well-known 'sonnet' and 'opus' aliases to a model bearing their family name", async () => {
|
||||
const q = query({ prompt: "hi", options: { sessionId: randomUUID() } });
|
||||
try {
|
||||
const models = await q.supportedModels();
|
||||
|
||||
const sonnet = resolveModelPreference(models, "sonnet");
|
||||
expect(sonnet?.resolvedModel?.toLowerCase()).toContain("sonnet");
|
||||
|
||||
const opus = resolveModelPreference(models, "opus");
|
||||
expect(opus?.resolvedModel?.toLowerCase()).toContain("opus");
|
||||
} finally {
|
||||
q.return(undefined);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -29,6 +29,13 @@ describe("resolvePermissionMode", () => {
|
||||
expect(resolvePermissionMode("bypass")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("resolves 'manual' as an alias for 'default'", () => {
|
||||
const { logger, error } = mockLogger();
|
||||
expect(resolvePermissionMode("manual", logger)).toBe("default");
|
||||
expect(resolvePermissionMode("Manual", logger)).toBe("default");
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
@@ -113,17 +113,17 @@ describe("session config options", () => {
|
||||
settingsManager: {},
|
||||
modes: structuredClone(MOCK_MODES),
|
||||
models: structuredClone(MOCK_MODELS),
|
||||
modelInfos: MOCK_MODELS.availableModels.map(
|
||||
(m): ModelInfo => ({
|
||||
value: m.modelId,
|
||||
displayName: m.name,
|
||||
description: m.description,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
}),
|
||||
),
|
||||
modelInfos: MOCK_MODELS.availableModels.map((m): ModelInfo => ({
|
||||
value: m.modelId,
|
||||
displayName: m.name,
|
||||
description: m.description,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
})),
|
||||
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
|
||||
contextWindowSize: 200000,
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,13 +148,13 @@ describe("session config options", () => {
|
||||
|
||||
describe("newSession returns configOptions", () => {
|
||||
it("includes configOptions in the response", async () => {
|
||||
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(response.configOptions).toBeDefined();
|
||||
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
|
||||
});
|
||||
|
||||
it("includes mode and model config options", async () => {
|
||||
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const modeOption = response.configOptions?.find((o) => o.id === "mode");
|
||||
const modelOption = response.configOptions?.find((o) => o.id === "model");
|
||||
expect(modeOption).toBeDefined();
|
||||
@@ -173,7 +173,7 @@ describe("session config options", () => {
|
||||
(agent as unknown as { loadSession: typeof loadSessionSpy }).loadSession = loadSessionSpy;
|
||||
|
||||
const response = await agent.loadSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
sessionId: SESSION_ID,
|
||||
mcpServers: [],
|
||||
});
|
||||
@@ -216,6 +216,31 @@ describe("session config options", () => {
|
||||
).rejects.toThrow("Invalid value for config option mode: invalid-mode");
|
||||
});
|
||||
|
||||
it("rejects mode and config changes once the query stream has closed (husk session)", async () => {
|
||||
// After an unexpected stream death the session lingers as a husk
|
||||
// (queryClosed=true) so prompt() can answer with a clear error. The
|
||||
// config/mode handlers must do the same rather than calling setModel/
|
||||
// setPermissionMode on the closed query.
|
||||
const session = (agent as unknown as { sessions: Record<string, { queryClosed?: boolean }> })
|
||||
.sessions[SESSION_ID];
|
||||
session.queryClosed = true;
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
}),
|
||||
).rejects.toThrow(/start a new session/);
|
||||
await expect(agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" })).rejects.toThrow(
|
||||
/start a new session/,
|
||||
);
|
||||
|
||||
// Short-circuited before touching the (closed) query.
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
expect(setPermissionModeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("changes mode, sends current_mode_update but not config_option_update", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
@@ -377,33 +402,30 @@ describe("session config options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unstable_setSessionModel sends config_option_update", () => {
|
||||
describe("setSessionConfigOption(model) returns updated configOptions", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("sends config_option_update when model is changed via setSessionModel", async () => {
|
||||
await agent.unstable_setSessionModel({
|
||||
it("returns configOptions with the new model when changed", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
expect(configUpdate?.update).toMatchObject({
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: expect.arrayContaining([
|
||||
expect(response.configOptions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "model", currentValue: "claude-sonnet-4-6" }),
|
||||
]),
|
||||
});
|
||||
);
|
||||
});
|
||||
|
||||
it("updates stored configOptions currentValue when model changes", async () => {
|
||||
await agent.unstable_setSessionModel({
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const session = (
|
||||
@@ -415,7 +437,7 @@ describe("session config options", () => {
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("includes updated effort in config_option_update when model drops effort support", async () => {
|
||||
it("drops effort from returned configOptions when model drops effort support", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modelInfos = [
|
||||
{
|
||||
@@ -433,23 +455,18 @@ describe("session config options", () => {
|
||||
},
|
||||
];
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
const effortOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption).toBeUndefined();
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
});
|
||||
|
||||
it("clamps effort in config_option_update when new model has different supported levels", async () => {
|
||||
it("clamps effort in returned configOptions when new model has different supported levels", async () => {
|
||||
// Set current effort to "max" which the new model won't support
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
|
||||
@@ -472,39 +489,31 @@ describe("session config options", () => {
|
||||
},
|
||||
];
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
const effortOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption).toBeDefined();
|
||||
expect(effortOption.currentValue).toBe("default");
|
||||
expect(effortOption?.currentValue).toBe("default");
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
});
|
||||
|
||||
it("preserves effort in config_option_update when new model supports same level", async () => {
|
||||
it("preserves effort in returned configOptions when new model supports same level", async () => {
|
||||
// Set effort to "low" first
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
|
||||
if (effortOpt) effortOpt.currentValue = "low";
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
const effortOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption?.currentValue).toBe("low");
|
||||
// Effort didn't change, so applyFlagSettings should NOT be called
|
||||
expect(applyFlagSettingsSpy).not.toHaveBeenCalled();
|
||||
@@ -821,10 +830,11 @@ describe("session config options", () => {
|
||||
expect(session.configOptions.find((o) => o.id === "mode")?.currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("setSessionModel also syncs configOptions", async () => {
|
||||
await agent.unstable_setSessionModel({
|
||||
it("setSessionConfigOption(model) also syncs configOptions", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-6",
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const session = (
|
||||
@@ -947,58 +957,21 @@ describe("session config options", () => {
|
||||
it("drops `auto` from available modes when switching to Haiku", async () => {
|
||||
setupHaikuOpusSession("default");
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-haiku-4-5",
|
||||
configId: "model",
|
||||
value: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
const modeOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "mode",
|
||||
);
|
||||
const modeOption = response.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption).toBeDefined();
|
||||
const modeValues = modeOption.options.map((o: any) => o.value);
|
||||
const modeValues = (modeOption as any).options.map((o: any) => o.value);
|
||||
expect(modeValues).not.toContain("auto");
|
||||
expect(modeValues).toEqual(
|
||||
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps to `default` and emits current_mode_update when Opus(auto) → Haiku", async () => {
|
||||
setupHaikuOpusSession("auto");
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
// SDK was synced to "default".
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
|
||||
|
||||
// current_mode_update was emitted before config_option_update so a
|
||||
// client applying notifications in order observes the mode change
|
||||
// before re-rendering the config-option list.
|
||||
const modeUpdateIdx = sessionUpdates.findIndex(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
const configUpdateIdx = sessionUpdates.findIndex(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(modeUpdateIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(configUpdateIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(modeUpdateIdx).toBeLessThan(configUpdateIdx);
|
||||
expect((sessionUpdates[modeUpdateIdx].update as any).currentModeId).toBe("default");
|
||||
|
||||
// configOptions reflect the clamped mode.
|
||||
const modeOption = (sessionUpdates[configUpdateIdx].update as any).configOptions.find(
|
||||
(o: any) => o.id === "mode",
|
||||
);
|
||||
expect(modeOption.currentValue).toBe("default");
|
||||
});
|
||||
|
||||
it("re-adds `auto` when switching from Haiku back to Opus", async () => {
|
||||
const session = setupHaikuOpusSession("default");
|
||||
// Pretend Haiku is the current model with no `auto`.
|
||||
@@ -1013,19 +986,15 @@ describe("session config options", () => {
|
||||
description: m.description,
|
||||
}));
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-opus-4-5",
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
const modeOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "mode",
|
||||
);
|
||||
const modeValues = modeOption.options.map((o: any) => o.value);
|
||||
const modeOption = response.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption).toBeDefined();
|
||||
const modeValues = (modeOption as any).options.map((o: any) => o.value);
|
||||
expect(modeValues).toContain("auto");
|
||||
|
||||
// The current mode ("default") is still valid on Opus, so no
|
||||
@@ -1039,9 +1008,10 @@ describe("session config options", () => {
|
||||
it("preserves the current mode when it remains valid after a model switch", async () => {
|
||||
setupHaikuOpusSession("plan");
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-haiku-4-5",
|
||||
configId: "model",
|
||||
value: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
// `plan` is in availableModes for both Opus and Haiku, so no clamp.
|
||||
@@ -1052,20 +1022,15 @@ describe("session config options", () => {
|
||||
);
|
||||
expect(modeUpdates).toHaveLength(0);
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
const modeOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "mode",
|
||||
);
|
||||
expect(modeOption.currentValue).toBe("plan");
|
||||
const modeOption = response.configOptions.find((o) => o.id === "mode");
|
||||
expect((modeOption as any).currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("clamps mode and emits current_mode_update via setSessionConfigOption(model)", async () => {
|
||||
// Mirrors the unstable_setSessionModel(auto → Haiku) test, but goes
|
||||
// through the request/response API. The `current_mode_update` side
|
||||
// effect must still fire so clients learn about the clamp regardless of
|
||||
// which entry point triggered the model switch.
|
||||
// Switching Opus(auto) → Haiku clamps the mode to "default". The
|
||||
// `current_mode_update` side effect must fire so clients learn about the
|
||||
// clamp even though the request/response API returns the new
|
||||
// configOptions rather than emitting a config_option_update.
|
||||
setupHaikuOpusSession("auto");
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
@@ -1148,6 +1113,10 @@ describe("session config options", () => {
|
||||
],
|
||||
};
|
||||
|
||||
// The tool_call was already surfaced (by the streamed tool_use chunk), so
|
||||
// the permission request won't re-emit one — keep this focused on options.
|
||||
session.emittedToolCalls.add("toolu_1");
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const signal = new AbortController().signal;
|
||||
try {
|
||||
@@ -1179,6 +1148,10 @@ describe("session config options", () => {
|
||||
],
|
||||
};
|
||||
permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } };
|
||||
// The tool_call was already surfaced (by the streamed tool_use chunk), so
|
||||
// the permission request won't re-emit one — the deny path below should
|
||||
// produce no session updates at all.
|
||||
session.emittedToolCalls.add("toolu_2");
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const result = await canUseTool(
|
||||
@@ -1207,6 +1180,10 @@ describe("session config options", () => {
|
||||
],
|
||||
};
|
||||
|
||||
// The tool_call was already surfaced (by the streamed tool_use chunk), so
|
||||
// the permission request won't re-emit one — keep this focused on options.
|
||||
session.emittedToolCalls.add("toolu_3");
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const signal = new AbortController().signal;
|
||||
try {
|
||||
|
||||
@@ -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 } from "vitest";
|
||||
import { AgentSideConnection, ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
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 = {
|
||||
@@ -502,6 +502,23 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should route failed commands through the terminal when supportsTerminalOutput is true", () => {
|
||||
const toolResult: ToolResultBlockParam = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: "some error output",
|
||||
is_error: true,
|
||||
};
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
|
||||
expect(update._meta).toEqual({
|
||||
terminal_info: { terminal_id: "toolu_bash" },
|
||||
terminal_output: { terminal_id: "toolu_bash", data: "some error output" },
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 1, signal: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to stderr when stdout is empty", () => {
|
||||
const toolResult = makeBashResult("", "some error output", 1);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
@@ -610,10 +627,25 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should use error handler when is_error is true (early return before Bash case)", () => {
|
||||
it("should route is_error through the terminal when supportsTerminalOutput is true", () => {
|
||||
const toolResult = makeStringBashResult("command not found: bad_cmd", true);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
// Failed Bash commands skip the early error return and reach the Bash
|
||||
// case so the client receives terminal output with a non-zero exit code
|
||||
// instead of plain markdown details.
|
||||
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
|
||||
expect(update._meta).toEqual({
|
||||
terminal_info: { terminal_id: "toolu_bash" },
|
||||
terminal_output: { terminal_id: "toolu_bash", data: "command not found: bad_cmd" },
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 1, signal: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("should use error handler when is_error is true and terminal output is unsupported", () => {
|
||||
const toolResult = makeStringBashResult("command not found: bad_cmd", true);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, false);
|
||||
|
||||
// is_error with content hits the early error return at the top of
|
||||
// toolUpdateFromToolResult, before reaching the Bash switch case.
|
||||
// So there's no terminal _meta, just error-formatted content.
|
||||
@@ -670,17 +702,93 @@ 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", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
// Reset before each test: toAcpNotifications prunes the cache entry once it
|
||||
// maps the tool_result, so a shared object would be empty by the 2nd test.
|
||||
let toolUseCache: ToolUseCache;
|
||||
beforeEach(() => {
|
||||
toolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const bashResult: BetaBashCodeExecutionResultBlock = {
|
||||
type: "bash_code_execution_result",
|
||||
@@ -792,11 +900,21 @@ describe("Bash terminal output", () => {
|
||||
{ clientCapabilities: { _meta: { terminal_output: true } } },
|
||||
);
|
||||
|
||||
// Fresh cache: the withSupport call above already consumed the entry,
|
||||
// since toAcpNotifications prunes the tool_use once it maps the result.
|
||||
const toolUseCacheWithoutSupport: ToolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
const withoutSupport = toAcpNotifications(
|
||||
[toolResult],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
toolUseCacheWithoutSupport,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
);
|
||||
@@ -848,6 +966,31 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolUseCache pruning", () => {
|
||||
it("retains the tool_use entry until its result, then prunes it", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const toolUse = {
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_read",
|
||||
name: "Read",
|
||||
input: { file_path: "/tmp/x.txt" },
|
||||
};
|
||||
|
||||
// tool_use is cached and kept (the matching result hasn't arrived yet).
|
||||
toAcpNotifications([toolUse], "assistant", "s", toolUseCache, mockClient, mockLogger);
|
||||
expect(toolUseCache.toolu_read).toBeDefined();
|
||||
|
||||
// tool_result resolves it, so the entry is pruned to bound memory.
|
||||
const toolResult: ToolResultBlockParam = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_read",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
};
|
||||
toAcpNotifications([toolResult], "assistant", "s", toolUseCache, mockClient, mockLogger);
|
||||
expect(toolUseCache.toolu_read).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-tool-use hook sends diff content for Edit tool", () => {
|
||||
it("should include content and locations from structuredPatch in hook update", async () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
@@ -857,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(
|
||||
@@ -936,7 +1079,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
@@ -1021,7 +1164,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
@@ -1075,7 +1218,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
@@ -1151,7 +1294,7 @@ describe("Bash terminal output", () => {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
@@ -1232,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 = {
|
||||
@@ -1332,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(
|
||||
@@ -1471,6 +1614,68 @@ describe("toolInfoFromToolUse - undefined input regression", () => {
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Update TODOs");
|
||||
});
|
||||
|
||||
it("ReportFindings with undefined input should not throw", () => {
|
||||
const toolUse = { name: "ReportFindings", id: "toolu_findings_undef", input: undefined };
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
expect(info.title).toBe("Report findings: none found");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInfoFromToolUse - ReportFindings", () => {
|
||||
it("should summarize findings count and list each in content", () => {
|
||||
const toolUse = {
|
||||
name: "ReportFindings",
|
||||
id: "toolu_findings_1",
|
||||
input: {
|
||||
findings: [
|
||||
{
|
||||
file: "src/foo.ts",
|
||||
line: 42,
|
||||
summary: "Off-by-one in loop bound",
|
||||
failure_scenario: "Empty array → index out of bounds crash",
|
||||
},
|
||||
{
|
||||
file: "src/bar.ts",
|
||||
summary: "Unhandled promise rejection",
|
||||
failure_scenario: "Network failure → unhandled rejection",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
|
||||
expect(info.kind).toBe("think");
|
||||
expect(info.title).toBe("Report 2 findings");
|
||||
expect(info.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "**src/foo.ts:42** — Off-by-one in loop bound" },
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "**src/bar.ts** — Unhandled promise rejection" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should report a singular title and empty findings array", () => {
|
||||
const oneFinding = {
|
||||
name: "ReportFindings",
|
||||
id: "toolu_findings_2",
|
||||
input: { findings: [{ file: "src/baz.ts", summary: "Leak", failure_scenario: "..." }] },
|
||||
};
|
||||
expect(toolInfoFromToolUse(oneFinding, false).title).toBe("Report 1 finding");
|
||||
|
||||
const noFindings = {
|
||||
name: "ReportFindings",
|
||||
id: "toolu_findings_3",
|
||||
input: { findings: [] },
|
||||
};
|
||||
expect(toolInfoFromToolUse(noFindings, false).title).toBe("Report findings: none found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planEntries - undefined input regression", () => {
|
||||
@@ -1497,7 +1702,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", () => {
|
||||
@@ -1632,7 +1837,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", () => {
|
||||
|
||||
67
src/tools.ts
67
src/tools.ts
@@ -8,12 +8,14 @@ import {
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
AskUserQuestionInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
ReportFindingsInput,
|
||||
TaskCreateInput,
|
||||
TaskCreateOutput,
|
||||
TaskUpdateInput,
|
||||
@@ -370,6 +372,25 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "ReportFindings": {
|
||||
const input = toolUse.input as ReportFindingsInput | undefined;
|
||||
const findings = input?.findings ?? [];
|
||||
return {
|
||||
title:
|
||||
findings.length === 0
|
||||
? "Report findings: none found"
|
||||
: `Report ${findings.length} finding${findings.length === 1 ? "" : "s"}`,
|
||||
kind: "think",
|
||||
content: findings.map((finding) => ({
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `**${finding.file}${finding.line ? `:${finding.line}` : ""}** — ${finding.summary}`,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskCreate": {
|
||||
const input = toolUse.input as TaskCreateInput | undefined;
|
||||
return {
|
||||
@@ -415,6 +436,24 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "AskUserQuestion": {
|
||||
const input = toolUse.input as Partial<AskUserQuestionInput> | undefined;
|
||||
const questions = Array.isArray(input?.questions) ? input.questions : [];
|
||||
return {
|
||||
title:
|
||||
questions.length === 1 && questions[0]?.question
|
||||
? questions[0].question
|
||||
: "Asking for your input",
|
||||
kind: "other",
|
||||
content: questions
|
||||
.filter((q) => typeof q?.question === "string")
|
||||
.map((q) => ({
|
||||
type: "content" as const,
|
||||
content: { type: "text" as const, text: q.question },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
case "Other": {
|
||||
const input = toolUse.input;
|
||||
let output;
|
||||
@@ -466,7 +505,8 @@ export function toolUpdateFromToolResult(
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
toolResult.content.length > 0 &&
|
||||
!(toolUse?.name === "Bash" && supportsTerminalOutput)
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
@@ -510,7 +550,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;
|
||||
|
||||
@@ -525,13 +567,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) {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2021"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
|
||||
Reference in New Issue
Block a user