mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
36822c2b75 | ||
|
|
9e259d128a | ||
|
|
2711f506d5 | ||
|
|
2414a6f98b | ||
|
|
cfce130707 | ||
|
|
b275f6ff7f | ||
|
|
a0bfb98ba7 | ||
|
|
e1e1c69029 | ||
|
|
72875b248c | ||
|
|
f66bc31ae4 | ||
|
|
5b2e147b5d | ||
|
|
db852dcf7c | ||
|
|
505f9c13c0 | ||
|
|
f37e9a0d47 | ||
|
|
2162d5af62 | ||
|
|
798844726f | ||
|
|
f6e12d425e | ||
|
|
3b9b7d5a56 | ||
|
|
eb1259cf88 | ||
|
|
5ff7d50f0e | ||
|
|
47df1f2a0b | ||
|
|
5d7165d961 | ||
|
|
27ca2e5d40 | ||
|
|
0db6e3d6f8 | ||
|
|
8d76be356a | ||
|
|
9258652169 | ||
|
|
002c63a78b | ||
|
|
e0ea9d898a | ||
|
|
867a3a0de2 | ||
|
|
31a8931018 | ||
|
|
0c8ff277f2 | ||
|
|
8b43ee813f | ||
|
|
dba199839a | ||
|
|
253ddeb6d4 | ||
|
|
becc3b86e6 | ||
|
|
1f4d6c2900 | ||
|
|
8d7e22026e | ||
|
|
c78ac62d05 | ||
|
|
6ccd37ce94 | ||
|
|
5b9311938d | ||
|
|
9957b54882 | ||
|
|
ec47d3446f |
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@df4cb1c069e1874edd31b4311f1884172cec0e10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
|
||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
# Generating a GitHub token, so that PRs and tags created by
|
||||
# the release-please-action can trigger actions workflows.
|
||||
- name: Generate GitHub token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
id: generate-token
|
||||
with:
|
||||
# GitHub App ID secret name
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.31.3"
|
||||
".": "0.46.0"
|
||||
}
|
||||
|
||||
225
CHANGELOG.md
225
CHANGELOG.md
@@ -1,5 +1,230 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add 'Default' option to effort level config ([#701](https://github.com/agentclientprotocol/claude-agent-acp/issues/701)) ([9e259d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/9e259d128aa58dbb8107e53362781ca4e8ee071e))
|
||||
* **deps:** bump the claude-agent-sdk to 0.3.146 ([#700](https://github.com/agentclientprotocol/claude-agent-acp/issues/700)) ([72875b2](https://github.com/agentclientprotocol/claude-agent-acp/commit/72875b248c7b90a89638de2f1709eec9e2153e7e))
|
||||
* **deps:** bump the minor group with 13 updates ([#689](https://github.com/agentclientprotocol/claude-agent-acp/issues/689)) ([f66bc31](https://github.com/agentclientprotocol/claude-agent-acp/commit/f66bc31ae4bea73b48eeeb7f1db90c7d69cc0dac))
|
||||
* Emit tool calls for memory recall events ([#703](https://github.com/agentclientprotocol/claude-agent-acp/issues/703)) ([a0bfb98](https://github.com/agentclientprotocol/claude-agent-acp/commit/a0bfb98ba73eaf37e9c071e886c054f90a5d5629)), closes [#650](https://github.com/agentclientprotocol/claude-agent-acp/issues/650)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Avoid cross-version model alias matches ([#702](https://github.com/agentclientprotocol/claude-agent-acp/issues/702)) ([e1e1c69](https://github.com/agentclientprotocol/claude-agent-acp/commit/e1e1c69029ef08579c841e6fbdffb32a5b94df06))
|
||||
* Avoid redundant initial model sync ([#704](https://github.com/agentclientprotocol/claude-agent-acp/issues/704)) ([b275f6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/b275f6ff7f0f2e21adedbd8e63a6fc3d63cbbb8d)), closes [#646](https://github.com/agentclientprotocol/claude-agent-acp/issues/646)
|
||||
* Don't expose /clear in commands ([#705](https://github.com/agentclientprotocol/claude-agent-acp/issues/705)) ([cfce130](https://github.com/agentclientprotocol/claude-agent-acp/commit/cfce1307076e93f43a3ed8cc0134f9bb14a0f2d6))
|
||||
* emit "cancelled" instead of "end_turn" when the session was interrupted. ([#694](https://github.com/agentclientprotocol/claude-agent-acp/issues/694)) ([2414a6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/2414a6f98bec5bf50f3a13af528aa38d5a0fc974))
|
||||
* Recover prompt stream after a failed turn ([#706](https://github.com/agentclientprotocol/claude-agent-acp/issues/706)) ([2711f50](https://github.com/agentclientprotocol/claude-agent-acp/commit/2711f506d5799f0ae25160de311a4459ffb46c49)), closes [#654](https://github.com/agentclientprotocol/claude-agent-acp/issues/654)
|
||||
|
||||
## [0.36.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.36.0...v0.36.1) (2026-05-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* flaky authentication bypass for gateway ([#686](https://github.com/agentclientprotocol/claude-agent-acp/issues/686)) ([db852dc](https://github.com/agentclientprotocol/claude-agent-acp/commit/db852dcf7c8e3b461fabb1bdcf0c80a27d1da77d))
|
||||
|
||||
## [0.36.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.35.0...v0.36.0) (2026-05-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add experimental session delete support ([#682](https://github.com/agentclientprotocol/claude-agent-acp/issues/682)) ([2162d5a](https://github.com/agentclientprotocol/claude-agent-acp/commit/2162d5af62d493a82381bd88bc8fc67d376e358b))
|
||||
* Support experimental additionalDirectories field ([#684](https://github.com/agentclientprotocol/claude-agent-acp/issues/684)) ([f37e9a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/f37e9a0d47d8d201d98b2177083664a6781895cc))
|
||||
|
||||
## [0.35.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.34.1...v0.35.0) (2026-05-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump actions/create-github-app-token from 3.1.1 to 3.2.0 ([#669](https://github.com/agentclientprotocol/claude-agent-acp/issues/669)) ([47df1f2](https://github.com/agentclientprotocol/claude-agent-acp/commit/47df1f2a0be57ddb8873ae8e341f748471c7bc53))
|
||||
* **deps:** bump hono from 4.12.18 to 4.12.19 in the minor group ([#671](https://github.com/agentclientprotocol/claude-agent-acp/issues/671)) ([5d7165d](https://github.com/agentclientprotocol/claude-agent-acp/commit/5d7165d96158da2ddf3087676cdb0a19d1ff57aa))
|
||||
* **deps:** update to claude-agent-sdk 0.3.143 ([#664](https://github.com/agentclientprotocol/claude-agent-acp/issues/664)) ([27ca2e5](https://github.com/agentclientprotocol/claude-agent-acp/commit/27ca2e5d40917887671cc4c39a854fd8f92c01e6))
|
||||
* Use SDK settings resolution for defaults ([#677](https://github.com/agentclientprotocol/claude-agent-acp/issues/677)) ([eb1259c](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb1259cf88375ca3c20a219ab99f12f5488fb360))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add task hooks for plan state updates ([#676](https://github.com/agentclientprotocol/claude-agent-acp/issues/676)) ([5ff7d50](https://github.com/agentclientprotocol/claude-agent-acp/commit/5ff7d50f0ec7f34a7d9d901223aed73e2dbcce68))
|
||||
* render local-command-stdout messages instead of dropping them ([#649](https://github.com/agentclientprotocol/claude-agent-acp/issues/649)) ([3b9b7d5](https://github.com/agentclientprotocol/claude-agent-acp/commit/3b9b7d5a56defd925eb2038fa97b6484ad951587))
|
||||
|
||||
## [0.34.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.34.0...v0.34.1) (2026-05-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* load credential error for bedrock gateway ([#667](https://github.com/agentclientprotocol/claude-agent-acp/issues/667)) ([8d76be3](https://github.com/agentclientprotocol/claude-agent-acp/commit/8d76be356a3e74c58a7c93c3c201cca6a317d784))
|
||||
|
||||
## [0.34.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.33.1...v0.34.0) (2026-05-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add bedrock gateway authentication ([#665](https://github.com/agentclientprotocol/claude-agent-acp/issues/665)) ([002c63a](https://github.com/agentclientprotocol/claude-agent-acp/commit/002c63a78b844c168d3dfc744cf408362a6adf97))
|
||||
|
||||
## [0.33.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.33.0...v0.33.1) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Honor availableModels settings allowlist ([#637](https://github.com/agentclientprotocol/claude-agent-acp/issues/637)) ([867a3a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/867a3a0de2a050592d79aa76d7dd3dd5b478162d)), closes [#620](https://github.com/agentclientprotocol/claude-agent-acp/issues/620)
|
||||
|
||||
## [0.33.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.32.0...v0.33.0) (2026-05-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump the minor group with 14 updates ([#631](https://github.com/agentclientprotocol/claude-agent-acp/issues/631)) ([8b43ee8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8b43ee813f05f5087a213a7f42154e39d6bc4a5a))
|
||||
* **deps:** Update to claude-agent-sdk 0.2.132 ([#636](https://github.com/agentclientprotocol/claude-agent-acp/issues/636)) ([0c8ff27](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c8ff277f2af8d3085cc1b5cf33891630dede0b1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Handle result origins in ACP agent ([#627](https://github.com/agentclientprotocol/claude-agent-acp/issues/627)) ([dba1998](https://github.com/agentclientprotocol/claude-agent-acp/commit/dba199839a25ba405f04b9d4409e647150fe0290))
|
||||
|
||||
## [0.32.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.4...v0.32.0) (2026-05-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** Bump nanoid from 3.3.11 to 3.3.12 ([#622](https://github.com/agentclientprotocol/claude-agent-acp/issues/622)) ([c78ac62](https://github.com/agentclientprotocol/claude-agent-acp/commit/c78ac62d05283e5683b87b198923818f9f556a03))
|
||||
* **deps:** Bump the minor group with 2 updates ([#619](https://github.com/agentclientprotocol/claude-agent-acp/issues/619)) ([6ccd37c](https://github.com/agentclientprotocol/claude-agent-acp/commit/6ccd37ce947fbd55691f676344b15ef1266191ad))
|
||||
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.2.126 ([#621](https://github.com/agentclientprotocol/claude-agent-acp/issues/621)) ([becc3b8](https://github.com/agentclientprotocol/claude-agent-acp/commit/becc3b86e60eb33e7823a0ef27d5aa99758750d7))
|
||||
* **deps:** Update to @anthropic-ai/claude-agent-sdk@0.2.123 ([#614](https://github.com/agentclientprotocol/claude-agent-acp/issues/614)) ([5b93119](https://github.com/agentclientprotocol/claude-agent-acp/commit/5b9311938d97f47400debd66f2b4792e50639c6b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* emit a real diff when Write overwrites an existing file ([#618](https://github.com/agentclientprotocol/claude-agent-acp/issues/618)) ([8d7e220](https://github.com/agentclientprotocol/claude-agent-acp/commit/8d7e22026e15d54208414bd9ad128eb86d41b451))
|
||||
|
||||
## [0.31.4](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.3...v0.31.4) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* gate auto mode on model support ([#604](https://github.com/agentclientprotocol/claude-agent-acp/issues/604)) ([ec47d34](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec47d3446fa22e5895621f57c0a4497dbe044505))
|
||||
|
||||
## [0.31.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.2...v0.31.3) (2026-04-28)
|
||||
|
||||
|
||||
|
||||
917
package-lock.json
generated
917
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.31.3",
|
||||
"version": "0.46.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -60,22 +60,22 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.121",
|
||||
"@agentclientprotocol/sdk": "0.26.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.178",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.89.0",
|
||||
"@anthropic-ai/sdk": "0.104.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.6.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.1",
|
||||
"@typescript-eslint/parser": "8.59.1",
|
||||
"eslint": "10.2.1",
|
||||
"@types/node": "25.9.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"eslint": "10.5.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.5.0",
|
||||
"prettier": "3.8.3",
|
||||
"globals": "17.6.0",
|
||||
"prettier": "3.8.4",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
2172
src/acp-agent.ts
2172
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
301
src/elicitation.ts
Normal file
301
src/elicitation.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
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" };
|
||||
}
|
||||
13
src/index.ts
13
src/index.ts
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Load managed settings and apply environment variables
|
||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
|
||||
if (process.argv.includes("--cli")) {
|
||||
@@ -35,9 +34,13 @@ if (process.argv.includes("--cli")) {
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
// Apply env vars from the managed-policy tier before any SDK call so the
|
||||
// SDK subprocess inherits them. Going through resolveSettings (vs. a raw
|
||||
// read of managed-settings.json) also picks up MDM sources on macOS and
|
||||
// HKLM/HKCU on Windows.
|
||||
const policy = await resolveSettings({ settingSources: [] });
|
||||
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
|
||||
15
src/lib.ts
15
src/lib.ts
@@ -10,25 +10,14 @@ export {
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
applyEnvironmentSettings,
|
||||
nodeToWebReadable,
|
||||
nodeToWebWritable,
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
type SettingsManagerOptions,
|
||||
} from "./settings.js";
|
||||
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
|
||||
171
src/settings.ts
171
src/settings.ts
@@ -1,5 +1,12 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
// resolveSettings and filterEscalatingDefaultMode are marked @alpha in the
|
||||
// SDK; API may shift in a future release.
|
||||
import {
|
||||
filterEscalatingDefaultMode,
|
||||
resolveSettings,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
@@ -14,51 +21,10 @@ import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
||||
*/
|
||||
|
||||
export interface PermissionSettings {
|
||||
defaultMode?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeSettings {
|
||||
permissions?: PermissionSettings;
|
||||
env?: Record<string, string>;
|
||||
model?: string;
|
||||
effortLevel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid.
|
||||
* Silently ignores missing files (ENOENT) but logs warnings for other errors
|
||||
* (malformed JSON, permission errors, etc.) to aid debugging.
|
||||
*/
|
||||
async function loadSettingsFile(
|
||||
filePath: string | null,
|
||||
logger?: { error: (...args: any[]) => void },
|
||||
): Promise<ClaudeCodeSettings> {
|
||||
if (!filePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as ClaudeCodeSettings;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
logger?.error(`Failed to load settings from ${filePath}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the enterprise settings path based on the current platform
|
||||
*/
|
||||
export function getManagedSettingsPath(): string {
|
||||
function getManagedSettingsPath(): string {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
||||
case "linux":
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
case "win32":
|
||||
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
||||
default:
|
||||
@@ -72,23 +38,17 @@ export interface SettingsManagerOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Claude Code settings from multiple sources with proper precedence.
|
||||
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
|
||||
* so the values we see match what `query()` would observe.
|
||||
*
|
||||
* Settings are loaded from (in order of increasing precedence):
|
||||
* 1. User settings (~/.claude/settings.json)
|
||||
* 2. Project settings (<cwd>/.claude/settings.json)
|
||||
* 3. Local project settings (<cwd>/.claude/settings.local.json)
|
||||
* 4. Enterprise managed settings (platform-specific path)
|
||||
*
|
||||
* The manager watches all settings files for changes and automatically reloads.
|
||||
* Watches the user/project/local/managed settings files for changes and
|
||||
* re-resolves through the SDK on update. Escalating `permissions.defaultMode`
|
||||
* values from repo-committed sources are filtered out via
|
||||
* `filterEscalatingDefaultMode`, matching the CLI's trust policy.
|
||||
*/
|
||||
export class SettingsManager {
|
||||
private cwd: string;
|
||||
private userSettings: ClaudeCodeSettings = {};
|
||||
private projectSettings: ClaudeCodeSettings = {};
|
||||
private localSettings: ClaudeCodeSettings = {};
|
||||
private enterpriseSettings: ClaudeCodeSettings = {};
|
||||
private mergedSettings: ClaudeCodeSettings = {};
|
||||
private effective: Settings = {};
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
@@ -126,98 +86,37 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the user settings file
|
||||
* Paths the SDK reads when resolving settings for this cwd. Watching the
|
||||
* containing directories means we pick up file creation as well as edits.
|
||||
*/
|
||||
private getUserSettingsPath(): string {
|
||||
return path.join(CLAUDE_CONFIG_DIR, "settings.json");
|
||||
private getWatchedPaths(): string[] {
|
||||
return [
|
||||
path.join(CLAUDE_CONFIG_DIR, "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.local.json"),
|
||||
getManagedSettingsPath(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the project settings file
|
||||
*/
|
||||
private getProjectSettingsPath(): string {
|
||||
return path.join(this.cwd, ".claude", "settings.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the local project settings file
|
||||
*/
|
||||
private getLocalSettingsPath(): string {
|
||||
return path.join(this.cwd, ".claude", "settings.local.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings from all sources
|
||||
* Resolves the effective settings via the SDK and applies the CLI's trust
|
||||
* filter for escalating `permissions.defaultMode` values.
|
||||
*/
|
||||
private async loadAllSettings(): Promise<void> {
|
||||
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
|
||||
loadSettingsFile(this.getUserSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getProjectSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getLocalSettingsPath(), this.logger),
|
||||
loadSettingsFile(getManagedSettingsPath(), this.logger),
|
||||
]);
|
||||
|
||||
this.userSettings = userSettings;
|
||||
this.projectSettings = projectSettings;
|
||||
this.localSettings = localSettings;
|
||||
this.enterpriseSettings = enterpriseSettings;
|
||||
|
||||
this.mergeSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges all settings sources with proper precedence.
|
||||
* For permissions, rules from all sources are combined.
|
||||
* Deny rules always take precedence during permission checks.
|
||||
*/
|
||||
private mergeSettings(): void {
|
||||
const allSettings = [
|
||||
this.userSettings,
|
||||
this.projectSettings,
|
||||
this.localSettings,
|
||||
this.enterpriseSettings,
|
||||
];
|
||||
|
||||
const merged: ClaudeCodeSettings = {};
|
||||
|
||||
for (const settings of allSettings) {
|
||||
if (settings.env) {
|
||||
merged.env = { ...merged.env, ...settings.env };
|
||||
}
|
||||
|
||||
if (settings.model) {
|
||||
merged.model = settings.model;
|
||||
}
|
||||
|
||||
if (settings.effortLevel !== undefined) {
|
||||
merged.effortLevel = settings.effortLevel;
|
||||
}
|
||||
|
||||
if (settings.permissions?.defaultMode !== undefined) {
|
||||
merged.permissions = {
|
||||
...merged.permissions,
|
||||
defaultMode: settings.permissions.defaultMode,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: this.cwd });
|
||||
this.effective = filterEscalatingDefaultMode(resolved);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
}
|
||||
|
||||
this.mergedSettings = merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up file watchers for all settings files
|
||||
*/
|
||||
private setupWatchers(): void {
|
||||
const paths = [
|
||||
this.getUserSettingsPath(),
|
||||
this.getProjectSettingsPath(),
|
||||
this.getLocalSettingsPath(),
|
||||
getManagedSettingsPath(),
|
||||
];
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (!filePath) continue;
|
||||
|
||||
for (const filePath of this.getWatchedPaths()) {
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
@@ -268,8 +167,8 @@ export class SettingsManager {
|
||||
/**
|
||||
* Returns the current merged settings
|
||||
*/
|
||||
getSettings(): ClaudeCodeSettings {
|
||||
return this.mergedSettings;
|
||||
getSettings(): Settings {
|
||||
return this.effective;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,4 +3,4 @@ description: Say hello
|
||||
argument-hint: name
|
||||
---
|
||||
|
||||
Respond with "Hello $1" and nothing else.
|
||||
Respond with "Hello $ARGUMENTS" and nothing else.
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
},
|
||||
@@ -126,6 +126,31 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("drops escalating defaultMode when it comes from project-tier settings", async () => {
|
||||
// bypassPermissions in .claude/settings.json (a repo-committed tier) is
|
||||
// filtered by the SDK's trust policy and clamps to 'default'.
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(path.join(projectDir, ".claude"), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(projectDir, ".claude", "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "bypassPermissions" } }),
|
||||
);
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
@@ -197,8 +222,596 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
// Bad model is ignored at the usage site; falls back to the first SDK model.
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
expect(response.models.currentModelId).toBe("claude-sonnet-4-5");
|
||||
// No setModel call is needed because no override was applied — the SDK is
|
||||
// already on its own default.
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
});
|
||||
|
||||
describe("auto mode availability per model", () => {
|
||||
function mockQueryWithModels(models: any[]): {
|
||||
getCapturedOptions: () => any;
|
||||
setModelSpy: ReturnType<typeof vi.fn>;
|
||||
setPermissionModeSpy: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
let capturedOptions: any;
|
||||
const setModelSpy = vi.fn();
|
||||
const setPermissionModeSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
setPermissionMode: setPermissionModeSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
return {
|
||||
getCapturedOptions: () => capturedOptions,
|
||||
setModelSpy,
|
||||
setPermissionModeSpy,
|
||||
};
|
||||
}
|
||||
|
||||
it("omits `auto` from availableModes when the resolved model lacks supportsAutoMode", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{
|
||||
value: "claude-haiku-4-5",
|
||||
displayName: "Claude Haiku",
|
||||
description: "Fast",
|
||||
// supportsAutoMode intentionally omitted
|
||||
},
|
||||
]);
|
||||
|
||||
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 modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
|
||||
expect(modeIds).not.toContain("auto");
|
||||
expect(modeIds).toEqual(
|
||||
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes `auto` when the resolved model has supportsAutoMode: true", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
]);
|
||||
|
||||
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 modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
|
||||
expect(modeIds).toContain("auto");
|
||||
});
|
||||
|
||||
it("clamps permissions.defaultMode='auto' to 'default' on a model that lacks supportsAutoMode", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "auto" } }),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([
|
||||
{
|
||||
value: "claude-haiku-4-5",
|
||||
displayName: "Claude Haiku",
|
||||
description: "Fast",
|
||||
// supportsAutoMode intentionally omitted
|
||||
},
|
||||
]);
|
||||
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
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 },
|
||||
});
|
||||
|
||||
// Options.permissionMode is built before init resolves, so it still
|
||||
// carries the user-typed value; the SDK was synced via
|
||||
// setPermissionMode after we discovered the model can't honor it.
|
||||
expect(getCapturedOptions().permissionMode).toBe("auto");
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
expect(response.modes.availableModes.map((m: any) => m.id)).not.toContain("auto");
|
||||
|
||||
// A descriptive warning was logged so operators see the clamp.
|
||||
const messages = errorSpy.mock.calls.map((c) => c.join(" "));
|
||||
expect(messages.some((m) => m.includes("auto") && m.includes("claude-haiku-4-5"))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not clamp permissions.defaultMode='auto' on a model that supports auto", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "auto" } }),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
]);
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("auto");
|
||||
expect(setPermissionModeSpy).not.toHaveBeenCalled();
|
||||
expect(response.modes.currentModeId).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("availableModels allowlist from settings", () => {
|
||||
function mockQueryWithModels(models: any[]): {
|
||||
setModelSpy: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
return { setModelSpy };
|
||||
}
|
||||
|
||||
it("restricts configOptions to the user's allowlist using their exact IDs", async () => {
|
||||
// Reproduces the scenario from
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/620:
|
||||
// user lists `claude-haiku-4-5` (no date pin) in availableModels, but
|
||||
// the SDK still surfaces its `haiku` alias which resolves to a
|
||||
// date-pinned variant the user doesn't have access to.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: [
|
||||
"claude-sonnet-4-6[1m]",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7[1m]",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
displayName: "Sonnet (1M context)",
|
||||
description: "Sonnet 4.6 long context",
|
||||
},
|
||||
{
|
||||
value: "opus[1m]",
|
||||
displayName: "Opus (1M context)",
|
||||
description: "Opus 1M context",
|
||||
},
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
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",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7[1m]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("unions availableModels across user and project settings", async () => {
|
||||
// https://code.claude.com/docs/en/model-config#merge-behavior
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ availableModels: ["claude-haiku-4-5"] }),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(path.join(projectDir, ".claude"), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(projectDir, ".claude", "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
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");
|
||||
// User and project entries are unioned and deduplicated.
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7[1m]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only the default entry when availableModels is an empty array", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ availableModels: [] }),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("does not filter when availableModels is absent from settings", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
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", "haiku"]);
|
||||
});
|
||||
|
||||
it("passes the user's exact ID to setModel when it matches an SDK alias", async () => {
|
||||
// Without the allowlist, the SDK would resolve `haiku` to a
|
||||
// date-pinned variant. Forcing setModel to receive `claude-haiku-4-5`
|
||||
// is exactly what the issue's workaround
|
||||
// (`ANTHROPIC_DEFAULT_HAIKU_MODEL`) achieves manually.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-haiku-4-5"],
|
||||
model: "claude-haiku-4-5",
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { setModelSpy } = mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("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 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
|
||||
// `opus` and inherit the "Opus 4.7" display info. With version-aware
|
||||
// matching, these entries fall back to showing their literal ID rather
|
||||
// than a misleading newer name.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: [
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-7[1m]",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus 4.7",
|
||||
description: "Claude Opus 4.7 — complex tasks, higher cost",
|
||||
},
|
||||
{
|
||||
value: "opus[1m]",
|
||||
displayName: "Opus 4.7 (1M context)",
|
||||
description: "Opus 4.7 with 1M context",
|
||||
},
|
||||
]);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// 4-6 entries must NOT inherit the 4.7 SDK alias display info.
|
||||
expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6");
|
||||
expect(byValue["claude-opus-4-6"].description).toBe("");
|
||||
expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]");
|
||||
expect(byValue["claude-opus-4-6[1m]"].description).toBe("");
|
||||
|
||||
// 4-7 entries continue to inherit display info from a 4.7 SDK alias.
|
||||
expect(byValue["claude-opus-4-7"].name).toBe("Opus 4.7");
|
||||
expect(byValue["claude-opus-4-7[1m]"].name).toMatch(/Opus 4\.7/);
|
||||
});
|
||||
|
||||
it("does not inherit display info across mismatched model families", async () => {
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
|
||||
// when the SDK's `opus` alias resolves to Opus 4.8, `claude-opus-4-6[1m]`
|
||||
// fails the version check against `opus` but the tokenized matcher
|
||||
// falls through to `sonnet[1m]` because the `1m` context hint alone
|
||||
// is enough to score a match.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-opus-4-6", "claude-opus-4-6[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
displayName: "Sonnet (1M context)",
|
||||
description: "Sonnet 4.6 for long sessions",
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
const byValue: Record<string, { name: string; description?: string }> = {};
|
||||
for (const opt of modelOption.options) {
|
||||
byValue[opt.value] = { name: opt.name, description: opt.description };
|
||||
}
|
||||
|
||||
expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]");
|
||||
expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("preserves ANTHROPIC_CUSTOM_MODEL_OPTION even when absent from the allowlist", async () => {
|
||||
// Per the model-config docs, ANTHROPIC_CUSTOM_MODEL_OPTION adds an entry
|
||||
// "without replacing the built-in aliases" and "appears at the bottom of
|
||||
// the /model picker", so it is exempt from the availableModels allowlist
|
||||
// (the same way the Default option is "not affected by availableModels").
|
||||
// ACP must match: a slim alias allowlist must not hide the custom model
|
||||
// row, and it appears last, after the allowlisted entries.
|
||||
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
|
||||
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["sonnet", "opus", "haiku"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
{ value: "opus", displayName: "Opus", description: "Claude Opus 4.6" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Claude Haiku 4.5" },
|
||||
{
|
||||
value: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"sonnet",
|
||||
"opus",
|
||||
"haiku",
|
||||
"claude-opus-4-8[1m]",
|
||||
]);
|
||||
const custom = modelOption.options.find((o: any) => o.value === "claude-opus-4-8[1m]");
|
||||
expect(custom.name).toBe("Opus 4.8");
|
||||
expect(custom.description).toBe("Claude Opus 4.8");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
} else {
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate the custom model option when also in the allowlist", async () => {
|
||||
// If the user lists the custom model's exact ID in availableModels AND it
|
||||
// is set as ANTHROPIC_CUSTOM_MODEL_OPTION, it must appear exactly once.
|
||||
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["sonnet", "claude-opus-4-8[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
{
|
||||
value: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"sonnet",
|
||||
"claude-opus-4-8[1m]",
|
||||
]);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
} else {
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
@@ -244,6 +857,102 @@ 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 () => {
|
||||
// Covers the launcher case from PR #646: the launcher bakes the model into
|
||||
// ANTHROPIC_MODEL, the SDK already starts on that model, and a second
|
||||
// setModel call would be a redundant round-trip (and on some launcher
|
||||
// setups, more fragile than launch-time selection).
|
||||
const originalEnv = process.env.ANTHROPIC_MODEL;
|
||||
process.env.ANTHROPIC_MODEL = "claude-opus-4-6";
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
{ value: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", description: "" },
|
||||
{ value: "claude-opus-4-6", displayName: "Claude Opus 4.6", description: "" },
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
|
||||
try {
|
||||
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 },
|
||||
});
|
||||
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-opus-4-6",
|
||||
);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_MODEL;
|
||||
} else {
|
||||
process.env.ANTHROPIC_MODEL = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("still calls setModel when the allowlist synthesizes a value the SDK has not surfaced", async () => {
|
||||
// The allowlist may rewrite a model's `value` to the user's literal ID
|
||||
// (e.g., `claude-haiku-4-5`) even when the SDK only exposed an alias
|
||||
// (`haiku`). In that case the SDK has not independently arrived at the
|
||||
// user's preferred ID, so we must sync via setModel.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-haiku-4-5"],
|
||||
model: "claude-haiku-4-5",
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("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
@@ -15,9 +15,17 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" }],
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
@@ -31,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 () => {
|
||||
@@ -68,4 +76,33 @@ describe("additionalRoots", () => {
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]);
|
||||
});
|
||||
|
||||
it("prefers the official ACP additionalDirectories field over _meta.additionalRoots", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/from/official"]);
|
||||
});
|
||||
|
||||
it("merges official ACP additionalDirectories with claudeCode SDK additionalDirectories", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { claudeCode: { options: { additionalDirectories: ["/from/sdk"] } } },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/from/sdk", "/from/official"]);
|
||||
});
|
||||
|
||||
it("falls back to _meta.additionalRoots when the official field is omitted", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/from/meta"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,20 @@ import { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
initializationResult: vi.fn().mockResolvedValue({
|
||||
models: [{ value: "id", displayName: "name", description: "description" }],
|
||||
models: [
|
||||
{ value: "id", displayName: "name", description: "description", supportsAutoMode: true },
|
||||
],
|
||||
}),
|
||||
setModel: vi.fn(),
|
||||
setPermissionMode: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
);
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
@@ -50,6 +56,9 @@ describe("authorization", () => {
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "gateway-bedrock" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gateway auth offered when client advertises auth._meta.gateway capability", async () => {
|
||||
@@ -62,11 +71,20 @@ describe("authorization", () => {
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
expect.objectContaining({
|
||||
id: "gateway",
|
||||
_meta: { gateway: { protocol: "anthropic" } },
|
||||
}),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "gateway-bedrock",
|
||||
_meta: { gateway: { protocol: "bedrock" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses gateway env after gateway auth", async () => {
|
||||
it("uses gateway env after anthropic gateway auth", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
@@ -85,7 +103,7 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -102,7 +120,7 @@ describe("authorization", () => {
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_AUTH_TOKEN: " ",
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
|
||||
userEnv: "userEnv",
|
||||
@@ -112,6 +130,42 @@ describe("authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses gateway env after gateway auth", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { terminal: true, _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
|
||||
await agent.authenticate({
|
||||
methodId: "gateway-bedrock",
|
||||
_meta: {
|
||||
gateway: { baseUrl: "https://gateway.example", headers: { "custom-header": "test" } },
|
||||
},
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
AWS_BEARER_TOKEN_BEDROCK: " ",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude authentication without terminal-auth", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
@@ -15,10 +18,16 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" },
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
@@ -59,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: {
|
||||
@@ -79,7 +88,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("works when user provides no disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
@@ -88,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: {
|
||||
@@ -104,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,
|
||||
@@ -127,7 +136,7 @@ describe("createSession options merging", () => {
|
||||
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
@@ -149,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: [],
|
||||
});
|
||||
|
||||
@@ -159,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: {
|
||||
@@ -179,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: {
|
||||
@@ -197,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: [],
|
||||
});
|
||||
|
||||
@@ -206,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: {
|
||||
@@ -222,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,
|
||||
@@ -239,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: {
|
||||
@@ -255,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",
|
||||
@@ -265,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" },
|
||||
});
|
||||
@@ -275,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" } },
|
||||
});
|
||||
@@ -289,7 +298,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("forwards excludeDynamicSections", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { excludeDynamicSections: true } },
|
||||
});
|
||||
@@ -303,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: {
|
||||
@@ -323,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: {
|
||||
@@ -363,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" },
|
||||
@@ -375,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"],
|
||||
@@ -388,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" },
|
||||
@@ -397,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();
|
||||
});
|
||||
@@ -408,13 +417,13 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
settings: {
|
||||
model: "claude-sonnet-4-5",
|
||||
model: "claude-sonnet-4-6",
|
||||
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
||||
},
|
||||
},
|
||||
@@ -424,7 +433,7 @@ describe("createSession options merging", () => {
|
||||
|
||||
// _meta settings take precedence; env var is ignored entirely
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
model: "claude-sonnet-4-5",
|
||||
model: "claude-sonnet-4-6",
|
||||
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
||||
});
|
||||
});
|
||||
@@ -432,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",
|
||||
@@ -467,4 +476,140 @@ describe("createSession options merging", () => {
|
||||
// ACP-provided MCP server should also be present
|
||||
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
|
||||
});
|
||||
|
||||
describe("thinking config from MAX_THINKING_TOKENS", () => {
|
||||
let originalMaxThinking: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalMaxThinking = process.env.MAX_THINKING_TOKENS;
|
||||
delete process.env.MAX_THINKING_TOKENS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalMaxThinking !== undefined) {
|
||||
process.env.MAX_THINKING_TOKENS = originalMaxThinking;
|
||||
} else {
|
||||
delete process.env.MAX_THINKING_TOKENS;
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves thinking unset (SDK default) when env var is absent", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
// The deprecated option must not be set either.
|
||||
expect(capturedOptions!.maxThinkingTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps 0 to disabled thinking", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "0";
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "disabled" });
|
||||
});
|
||||
|
||||
it("maps a positive value to a fixed thinking budget", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "12000";
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "enabled", budgetTokens: 12000 });
|
||||
});
|
||||
|
||||
it("ignores a non-numeric value", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "lots";
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets a user-provided thinking option override the env default", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "12000";
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
thinking: { type: "adaptive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(capturedOptions!.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cwd validation", () => {
|
||||
it("rejects a relative cwd with invalidParams", async () => {
|
||||
await expect(
|
||||
agent.newSession({ cwd: "relative/path", mcpServers: [] }),
|
||||
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
|
||||
});
|
||||
|
||||
it("rejects a non-existent cwd with invalidParams", async () => {
|
||||
const missing = path.join(os.tmpdir(), "claude-acp-does-not-exist-xyz");
|
||||
await expect(agent.newSession({ cwd: missing, mcpServers: [] })).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a cwd that points at a file with invalidParams", async () => {
|
||||
const file = path.join(os.tmpdir(), "claude-acp-cwd-is-a-file.txt");
|
||||
fs.writeFileSync(file, "not a directory");
|
||||
try {
|
||||
await expect(agent.newSession({ cwd: file, mcpServers: [] })).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an existing absolute directory", async () => {
|
||||
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("elicitation", () => {
|
||||
it("keeps AskUserQuestion disabled and omits callbacks without elicitation capability", async () => {
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
expect(capturedOptions!.onElicitation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enables AskUserQuestion and wires the elicitation callback when form is supported", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
||||
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
||||
});
|
||||
|
||||
it("wires callbacks for url-only elicitation but keeps AskUserQuestion disabled", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { url: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
||||
});
|
||||
|
||||
it("still merges user-provided disallowedTools when AskUserQuestion is enabled", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { disallowedTools: ["WebSearch"] } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
|
||||
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
391
src/tests/elicitation.test.ts
Normal file
391
src/tests/elicitation.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
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,
|
||||
mcpElicitationToCreateRequest,
|
||||
} from "../elicitation.js";
|
||||
|
||||
const SESSION_ID = "session-1";
|
||||
|
||||
/**
|
||||
* Build a question matching the SDK's (strict) AskUserQuestion schema while
|
||||
* keeping test sites terse. Option descriptions default to "" (the tool always
|
||||
* provides one); pass a description to exercise the "label — description" title.
|
||||
*/
|
||||
function mkQuestion(
|
||||
question: string,
|
||||
options: Array<{ label: string; description?: string; 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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ const MOCK_MODELS = {
|
||||
currentModelId: "claude-opus-4-5",
|
||||
availableModels: [
|
||||
{ modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" },
|
||||
{ modelId: "claude-sonnet-4-5", name: "Claude Sonnet", description: "Balanced" },
|
||||
{ modelId: "claude-sonnet-4-6", name: "Claude Sonnet", description: "Balanced" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -65,8 +65,9 @@ const MOCK_CONFIG_OPTIONS = [
|
||||
description: "Available effort levels for this model",
|
||||
type: "select",
|
||||
category: "effort",
|
||||
currentValue: "high",
|
||||
currentValue: "default",
|
||||
options: [
|
||||
{ value: "default", name: "Default" },
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "medium", name: "Medium" },
|
||||
{ value: "high", name: "High" },
|
||||
@@ -147,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();
|
||||
@@ -172,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: [],
|
||||
});
|
||||
@@ -215,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,
|
||||
@@ -242,10 +268,10 @@ describe("session config options", () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
@@ -273,7 +299,7 @@ describe("session config options", () => {
|
||||
value: "sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("resolves display name to model ID", async () => {
|
||||
@@ -283,19 +309,19 @@ describe("session config options", () => {
|
||||
value: "Claude Sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("still works with exact model ID", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("throws for completely invalid model value", async () => {
|
||||
@@ -376,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-5",
|
||||
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.objectContaining({ id: "model", currentValue: "claude-sonnet-4-5" }),
|
||||
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-5",
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const session = (
|
||||
@@ -411,10 +434,10 @@ describe("session config options", () => {
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
const modelOption = session.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
|
||||
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 = [
|
||||
{
|
||||
@@ -425,30 +448,25 @@ describe("session config options", () => {
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
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: undefined });
|
||||
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");
|
||||
@@ -463,7 +481,7 @@ describe("session config options", () => {
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: true,
|
||||
@@ -471,39 +489,31 @@ describe("session config options", () => {
|
||||
},
|
||||
];
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
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("high");
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
|
||||
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-5",
|
||||
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();
|
||||
@@ -532,7 +542,7 @@ describe("session config options", () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const configUpdates = sessionUpdates.filter(
|
||||
@@ -557,6 +567,31 @@ describe("session config options", () => {
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "low" });
|
||||
});
|
||||
|
||||
it("calls applyFlagSettings with null effortLevel for 'default'", async () => {
|
||||
// Set effort to a non-default value 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 = "high";
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "default",
|
||||
});
|
||||
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
|
||||
// The SDK's applyFlagSettings travels over a JSON pipe and only clears a
|
||||
// flag-layer key when an explicit `null` is sent — `undefined` is
|
||||
// dropped during JSON.stringify, which would leave the previous effort
|
||||
// override in place. Round-trip the call args through JSON to make sure
|
||||
// the key actually reaches the SDK.
|
||||
const calls = applyFlagSettingsSpy.mock.calls;
|
||||
const lastCallArgs = calls[calls.length - 1]?.[0];
|
||||
const serialized = JSON.parse(JSON.stringify(lastCallArgs));
|
||||
expect(serialized).toHaveProperty("effortLevel", null);
|
||||
});
|
||||
|
||||
it("updates effort currentValue in returned configOptions", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
@@ -622,7 +657,7 @@ describe("session config options", () => {
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
@@ -632,7 +667,7 @@ describe("session config options", () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
@@ -650,7 +685,7 @@ describe("session config options", () => {
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
@@ -660,16 +695,16 @@ describe("session config options", () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined });
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
});
|
||||
|
||||
it("adds effort option when switching to a model that supports effort", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
// Start with sonnet (no effort) as current
|
||||
session.models = { ...session.models, currentModelId: "claude-sonnet-4-5" };
|
||||
session.models = { ...session.models, currentModelId: "claude-sonnet-4-6" };
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
@@ -679,7 +714,7 @@ describe("session config options", () => {
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
@@ -696,8 +731,8 @@ describe("session config options", () => {
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption).toBeDefined();
|
||||
// No previous effort, so defaults to "high" (the API default)
|
||||
expect(effortOption?.currentValue).toBe("high");
|
||||
// No previous effort, so defaults to "default" (no effort override)
|
||||
expect(effortOption?.currentValue).toBe("default");
|
||||
});
|
||||
|
||||
it("clamps effort to valid value when new model has different supported levels", async () => {
|
||||
@@ -715,7 +750,7 @@ describe("session config options", () => {
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: true,
|
||||
@@ -726,15 +761,15 @@ describe("session config options", () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption).toBeDefined();
|
||||
// "max" is not in sonnet's levels, so should fall back to "high" (the API default)
|
||||
expect(effortOption?.currentValue).toBe("high");
|
||||
// SDK should be told about the clamped value
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
|
||||
// "max" is not in sonnet's levels, so should fall back to "default" (no effort override)
|
||||
expect(effortOption?.currentValue).toBe("default");
|
||||
// SDK should be told to clear the effort override
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
});
|
||||
|
||||
it("preserves effort value when new model supports the same level", async () => {
|
||||
@@ -749,7 +784,7 @@ describe("session config options", () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
@@ -778,10 +813,10 @@ describe("session config options", () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("setSessionMode also syncs configOptions", async () => {
|
||||
@@ -795,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-5",
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const session = (
|
||||
@@ -807,8 +843,350 @@ describe("session config options", () => {
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto mode availability per model", () => {
|
||||
/**
|
||||
* Augment the session populated by `populateSession()` with a Haiku entry
|
||||
* (no `supportsAutoMode`), Opus + Sonnet entries with `supportsAutoMode:
|
||||
* true`, and seed `availableModes` so it currently includes `auto`. This
|
||||
* exercises the per-model recomputation done by `applyConfigOptionValue`
|
||||
* on a model switch.
|
||||
*/
|
||||
function setupHaikuOpusSession(currentModeId: string = "default") {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
{
|
||||
value: "claude-haiku-4-5",
|
||||
displayName: "Claude Haiku",
|
||||
description: "Fast",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
// supportsAutoMode intentionally omitted
|
||||
},
|
||||
];
|
||||
session.models = {
|
||||
currentModelId: "claude-opus-4-5",
|
||||
availableModels: [
|
||||
{ modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" },
|
||||
{ modelId: "claude-sonnet-4-6", name: "Claude Sonnet", description: "Balanced" },
|
||||
{ modelId: "claude-haiku-4-5", name: "Claude Haiku", description: "Fast" },
|
||||
],
|
||||
};
|
||||
session.modes = {
|
||||
currentModeId,
|
||||
availableModes: [
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Use a model classifier to approve/deny permission prompts",
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
description: "Standard behavior, prompts for dangerous operations",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Auto-accept file edit operations",
|
||||
},
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{
|
||||
id: "dontAsk",
|
||||
name: "Don't Ask",
|
||||
description: "Don't prompt for permissions, deny if not pre-approved",
|
||||
},
|
||||
],
|
||||
};
|
||||
// Reflect the seeded availableModes/availableModels in configOptions so
|
||||
// the pre-state matches what `createSession` would have produced for
|
||||
// Opus, and `setSessionConfigOption` validation can accept the seeded
|
||||
// model ids (notably the new Haiku entry).
|
||||
session.configOptions = session.configOptions.map((o: any) => {
|
||||
if (o.id === "mode") {
|
||||
return {
|
||||
...o,
|
||||
currentValue: currentModeId,
|
||||
options: session.modes.availableModes.map((m: any) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (o.id === "model") {
|
||||
return {
|
||||
...o,
|
||||
currentValue: session.models.currentModelId,
|
||||
options: session.models.availableModels.map((m: any) => ({
|
||||
value: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return o;
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("drops `auto` from available modes when switching to Haiku", async () => {
|
||||
setupHaikuOpusSession("default");
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
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).not.toContain("auto");
|
||||
expect(modeValues).toEqual(
|
||||
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
|
||||
);
|
||||
});
|
||||
|
||||
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`.
|
||||
session.models.currentModelId = "claude-haiku-4-5";
|
||||
session.modes.availableModes = session.modes.availableModes.filter(
|
||||
(m: any) => m.id !== "auto",
|
||||
);
|
||||
const modeOpt = session.configOptions.find((o: any) => o.id === "mode");
|
||||
modeOpt.options = session.modes.availableModes.map((m: any) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
}));
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
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
|
||||
// current_mode_update should have been emitted by the model switch.
|
||||
const modeUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
expect(modeUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves the current mode when it remains valid after a model switch", async () => {
|
||||
setupHaikuOpusSession("plan");
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
// `plan` is in availableModes for both Opus and Haiku, so no clamp.
|
||||
expect(setPermissionModeSpy).not.toHaveBeenCalledWith("default");
|
||||
|
||||
const modeUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
expect(modeUpdates).toHaveLength(0);
|
||||
|
||||
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 () => {
|
||||
// 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({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
|
||||
|
||||
const modeUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
expect(modeUpdates).toHaveLength(1);
|
||||
expect((modeUpdates[0].update as any).currentModeId).toBe("default");
|
||||
|
||||
// setSessionConfigOption is a request/response API: it returns the new
|
||||
// configOptions in the response rather than emitting a
|
||||
// config_option_update notification.
|
||||
const configUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdates).toHaveLength(0);
|
||||
|
||||
const modeOption = response.configOptions.find((o: any) => o.id === "mode");
|
||||
expect(modeOption).toBeDefined();
|
||||
expect((modeOption as any).currentValue).toBe("default");
|
||||
expect((modeOption as any).options.map((o: any) => o.value)).not.toContain("auto");
|
||||
});
|
||||
|
||||
it("rejects direct setSessionMode to `auto` when the active model does not offer it", async () => {
|
||||
const session = setupHaikuOpusSession("default");
|
||||
session.models.currentModelId = "claude-haiku-4-5";
|
||||
session.modes.availableModes = session.modes.availableModes.filter(
|
||||
(mode: any) => mode.id !== "auto",
|
||||
);
|
||||
|
||||
await expect(agent.setSessionMode({ sessionId: SESSION_ID, modeId: "auto" })).rejects.toThrow(
|
||||
"Mode auto is not available in this session",
|
||||
);
|
||||
|
||||
expect(setPermissionModeSpy).not.toHaveBeenCalledWith("auto");
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExitPlanMode permission options filtered by availableModes", () => {
|
||||
let capturedPermissionRequest: any;
|
||||
let permissionResponse: any;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPermissionRequest = null;
|
||||
permissionResponse = { outcome: { outcome: "cancelled" } };
|
||||
// Replace the default mock client with one that captures the
|
||||
// requestPermission call so we can assert on the offered options.
|
||||
(agent as any).client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
sessionUpdates.push(notification);
|
||||
},
|
||||
requestPermission: async (params: any) => {
|
||||
capturedPermissionRequest = params;
|
||||
return permissionResponse;
|
||||
},
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
};
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("omits the `auto` option on a model without supportsAutoMode", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
// Haiku-shaped session: availableModes does NOT include `auto`.
|
||||
session.modes = {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Default", description: "Standard" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
|
||||
],
|
||||
};
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const signal = new AbortController().signal;
|
||||
try {
|
||||
await canUseTool(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal, suggestions: undefined, toolUseID: "toolu_1" },
|
||||
);
|
||||
} catch {
|
||||
// The mock client returns `cancelled`, which makes canUseTool throw.
|
||||
// We only care about the captured requestPermission options.
|
||||
}
|
||||
|
||||
expect(capturedPermissionRequest).not.toBeNull();
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).not.toContain("auto");
|
||||
expect(optionIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"]));
|
||||
});
|
||||
|
||||
it("denies a selected `auto` option if the client did not receive that option", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modes = {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Default", description: "Standard" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
|
||||
],
|
||||
};
|
||||
permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } };
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const result = await canUseTool(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal: new AbortController().signal, suggestions: undefined, toolUseID: "toolu_2" },
|
||||
);
|
||||
|
||||
expect(capturedPermissionRequest).not.toBeNull();
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).not.toContain("auto");
|
||||
expect(result.behavior).toBe("deny");
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes the `auto` option on a model with supportsAutoMode", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modes = {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "auto", name: "Auto", description: "Use a model classifier" },
|
||||
{ id: "default", name: "Default", description: "Standard" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
|
||||
],
|
||||
};
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const signal = new AbortController().signal;
|
||||
try {
|
||||
await canUseTool(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal, suggestions: undefined, toolUseID: "toolu_3" },
|
||||
);
|
||||
} catch {
|
||||
// mock returns cancelled
|
||||
}
|
||||
|
||||
expect(capturedPermissionRequest).not.toBeNull();
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).toContain("auto");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,57 @@ describe("SettingsManager", () => {
|
||||
expect(settings.model).toBe("claude-3-5-haiku");
|
||||
});
|
||||
|
||||
it("should expose availableModels from settings", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const settings = settingsManager.getSettings();
|
||||
expect(settings.availableModels).toEqual(["claude-haiku-4-5", "claude-opus-4-7[1m]"]);
|
||||
});
|
||||
|
||||
it("should union and dedupe availableModels across sources", async () => {
|
||||
// Per Claude Code docs: "When `availableModels` is set at multiple
|
||||
// levels, such as user settings and project settings, arrays are
|
||||
// merged and deduplicated."
|
||||
// https://code.claude.com/docs/en/model-config#merge-behavior
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"],
|
||||
}),
|
||||
);
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
// claude-opus-4-7[1m] overlaps with project; should be deduped.
|
||||
availableModels: ["claude-opus-4-7[1m]", "claude-sonnet-4-6[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const settings = settingsManager.getSettings();
|
||||
expect(settings.availableModels).toEqual([
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7[1m]",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should merge permissions.defaultMode with later sources taking precedence", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
@@ -90,4 +141,66 @@ describe("SettingsManager", () => {
|
||||
expect(settings.permissions?.defaultMode).toBe("plan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escalating defaultMode trust filter", () => {
|
||||
// The SDK's filterEscalatingDefaultMode drops escalating values
|
||||
// (bypassPermissions / auto / acceptEdits) that came from a repo-committed
|
||||
// tier (.claude/settings.json), preventing a checked-in file from
|
||||
// silently escalating permissions. Local (.claude/settings.local.json)
|
||||
// and user-tier sources are not committed by convention, so escalating
|
||||
// values from those tiers are preserved.
|
||||
|
||||
it.each(["bypassPermissions", "auto", "acceptEdits"] as const)(
|
||||
"drops %s when set in project-tier settings",
|
||||
async (mode) => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: mode } }),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
expect(settingsManager.getSettings().permissions?.defaultMode).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["plan", "dontAsk"] as const)(
|
||||
"preserves non-escalating %s from project-tier settings",
|
||||
async (mode) => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: mode } }),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
expect(settingsManager.getSettings().permissions?.defaultMode).toBe(mode);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves escalating defaultMode when it comes from local-tier settings", async () => {
|
||||
// settings.local.json is git-ignored by convention, so the trust
|
||||
// filter does not apply.
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
expect(settingsManager.getSettings().permissions?.defaultMode).toBe("acceptEdits");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { AgentSideConnection, ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import { ImageBlockParam, ToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
@@ -14,8 +14,14 @@ import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import {
|
||||
toolUpdateFromToolResult,
|
||||
createPostToolUseHook,
|
||||
createTaskHook,
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
applyTaskCreate,
|
||||
applyTaskUpdate,
|
||||
parseTaskCreateOutput,
|
||||
taskStateToPlanEntries,
|
||||
TaskState,
|
||||
} from "../tools.js";
|
||||
|
||||
describe("rawOutput in tool call updates", () => {
|
||||
@@ -496,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);
|
||||
@@ -604,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.
|
||||
@@ -667,14 +705,19 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
|
||||
describe("toAcpNotifications with clientCapabilities", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
// Reset before each test: toAcpNotifications prunes the cache entry once it
|
||||
// maps the tool_result, so a shared object would be empty by the 2nd test.
|
||||
let toolUseCache: ToolUseCache;
|
||||
beforeEach(() => {
|
||||
toolUseCache = {
|
||||
toolu_bash: {
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la" },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const bashResult: BetaBashCodeExecutionResultBlock = {
|
||||
type: "bash_code_execution_result",
|
||||
@@ -786,11 +829,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,
|
||||
);
|
||||
@@ -842,6 +895,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 = {};
|
||||
@@ -1056,6 +1134,162 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-tool-use hook sends diff content for Write tool", () => {
|
||||
// Regression: previously the dispatch only invoked
|
||||
// toolUpdateFromDiffToolResponse for `Edit`, so a Write that
|
||||
// overwrote an existing file (FileWriteOutput.type === "update")
|
||||
// showed a "creation" diff (oldText: null, full new content) at
|
||||
// tool_use time and was never corrected after the tool ran.
|
||||
it("should emit a real diff for Write of an existing file (type: update)", async () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const hookUpdates: any[] = [];
|
||||
const mockClientWithUpdate = {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_write_update",
|
||||
name: "Write",
|
||||
input: {
|
||||
file_path: "/Users/test/project/file.ts",
|
||||
content: "line1\nNEW line2\nline3",
|
||||
},
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClientWithUpdate,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
const hook = createPostToolUseHook(mockLogger);
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: "Write",
|
||||
tool_input: {
|
||||
file_path: "/Users/test/project/file.ts",
|
||||
content: "line1\nNEW line2\nline3",
|
||||
},
|
||||
tool_response: {
|
||||
type: "update",
|
||||
filePath: "/Users/test/project/file.ts",
|
||||
content: "line1\nNEW line2\nline3",
|
||||
originalFile: "line1\nold line2\nline3",
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 3,
|
||||
newStart: 1,
|
||||
newLines: 3,
|
||||
lines: [" line1", "-old line2", "+NEW line2", " line3"],
|
||||
},
|
||||
],
|
||||
userModified: false,
|
||||
},
|
||||
tool_use_id: "toolu_write_update",
|
||||
session_id: "test-session",
|
||||
transcript_path: "/tmp/test",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
"toolu_write_update",
|
||||
{ signal: AbortSignal.abort() },
|
||||
);
|
||||
|
||||
expect(hookUpdates).toHaveLength(1);
|
||||
const hookUpdate = hookUpdates[0].update;
|
||||
expect(hookUpdate._meta.claudeCode.toolName).toBe("Write");
|
||||
expect(hookUpdate.content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: "/Users/test/project/file.ts",
|
||||
oldText: "line1\nold line2\nline3",
|
||||
newText: "line1\nNEW line2\nline3",
|
||||
},
|
||||
]);
|
||||
expect(hookUpdate.locations).toEqual([{ path: "/Users/test/project/file.ts", line: 1 }]);
|
||||
});
|
||||
|
||||
it("should still emit a sensible diff for Write of a brand-new file (type: create)", async () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const hookUpdates: any[] = [];
|
||||
const mockClientWithUpdate = {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
hookUpdates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: "toolu_write_create",
|
||||
name: "Write",
|
||||
input: {
|
||||
file_path: "/Users/test/project/new.ts",
|
||||
content: "first\nsecond",
|
||||
},
|
||||
},
|
||||
],
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClientWithUpdate,
|
||||
mockLogger,
|
||||
);
|
||||
|
||||
const hook = createPostToolUseHook(mockLogger);
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: "Write",
|
||||
tool_input: {
|
||||
file_path: "/Users/test/project/new.ts",
|
||||
content: "first\nsecond",
|
||||
},
|
||||
tool_response: {
|
||||
type: "create",
|
||||
filePath: "/Users/test/project/new.ts",
|
||||
content: "first\nsecond",
|
||||
originalFile: null,
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 0,
|
||||
oldLines: 0,
|
||||
newStart: 1,
|
||||
newLines: 2,
|
||||
lines: ["+first", "+second"],
|
||||
},
|
||||
],
|
||||
},
|
||||
tool_use_id: "toolu_write_create",
|
||||
session_id: "test-session",
|
||||
transcript_path: "/tmp/test",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
"toolu_write_create",
|
||||
{ signal: AbortSignal.abort() },
|
||||
);
|
||||
|
||||
expect(hookUpdates).toHaveLength(1);
|
||||
const hookUpdate = hookUpdates[0].update;
|
||||
expect(hookUpdate.content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: "/Users/test/project/new.ts",
|
||||
oldText: null,
|
||||
newText: "first\nsecond",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-tool-use hook preserves terminal _meta", () => {
|
||||
it("should send terminal_output and terminal_exit as separate notifications, and hook should only have claudeCode", async () => {
|
||||
const clientCapabilities: ClientCapabilities = {
|
||||
@@ -1385,3 +1619,404 @@ describe("toAcpNotifications - TodoWrite with undefined input regression", () =>
|
||||
expect(planUpdates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTaskCreateOutput", () => {
|
||||
it("parses JSON-string content", () => {
|
||||
const parsed = parseTaskCreateOutput(JSON.stringify({ task: { id: "1", subject: "X" } }));
|
||||
expect(parsed).toEqual({ task: { id: "1", subject: "X" } });
|
||||
});
|
||||
|
||||
it("parses array-of-text-block content", () => {
|
||||
const parsed = parseTaskCreateOutput([
|
||||
{ type: "text", text: JSON.stringify({ task: { id: "2", subject: "Y" } }) },
|
||||
]);
|
||||
expect(parsed).toEqual({ task: { id: "2", subject: "Y" } });
|
||||
});
|
||||
|
||||
it("returns undefined for non-JSON content", () => {
|
||||
expect(parseTaskCreateOutput("not json")).toBeUndefined();
|
||||
expect(parseTaskCreateOutput([{ type: "text", text: "not json" }])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when task.id is missing", () => {
|
||||
expect(parseTaskCreateOutput(JSON.stringify({ task: { subject: "X" } }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTaskCreate / applyTaskUpdate", () => {
|
||||
it("creates an entry on TaskCreate when both input and output are present", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskCreate(
|
||||
state,
|
||||
{ subject: "Write tests", description: "Cover Task* flow", activeForm: "Writing tests" },
|
||||
{ task: { id: "1", subject: "Write tests" } },
|
||||
);
|
||||
expect(state.get("1")).toEqual({
|
||||
subject: "Write tests",
|
||||
status: "pending",
|
||||
activeForm: "Writing tests",
|
||||
description: "Cover Task* flow",
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when the output has no task ID", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskCreate(state, { subject: "X", description: "Y" }, undefined);
|
||||
expect(state.size).toBe(0);
|
||||
});
|
||||
|
||||
it("updates fields by task ID and keeps insertion order in plan entries", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskCreate(state, { subject: "A", description: "" }, { task: { id: "1", subject: "A" } });
|
||||
applyTaskCreate(state, { subject: "B", description: "" }, { task: { id: "2", subject: "B" } });
|
||||
applyTaskUpdate(state, { taskId: "1", status: "in_progress" });
|
||||
expect(taskStateToPlanEntries(state)).toEqual([
|
||||
{ content: "A", status: "in_progress", priority: "medium" },
|
||||
{ content: "B", status: "pending", priority: "medium" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes entries when status is 'deleted'", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskCreate(state, { subject: "A", description: "" }, { task: { id: "1", subject: "A" } });
|
||||
applyTaskUpdate(state, { taskId: "1", status: "deleted" });
|
||||
expect(state.size).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a placeholder entry when TaskUpdate carries a subject for an unseen task", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskUpdate(state, { taskId: "5", subject: "Late arrival", status: "in_progress" });
|
||||
expect(state.get("5")).toEqual({
|
||||
subject: "Late arrival",
|
||||
status: "in_progress",
|
||||
activeForm: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips TaskUpdate for an unseen task when no subject is available", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskUpdate(state, { taskId: "5", status: "in_progress" });
|
||||
// Without a subject we'd render an empty-content plan entry, so the
|
||||
// update is dropped instead of synthesizing a blank placeholder.
|
||||
expect(state.has("5")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications - Task* tools", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
it("suppresses tool_call for TaskCreate/TaskUpdate/TaskList/TaskGet on tool_use", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const taskState: TaskState = new Map();
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{ type: "tool_use", id: "1", name: "TaskCreate", input: { subject: "A", description: "" } },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "2",
|
||||
name: "TaskUpdate",
|
||||
input: { taskId: "1", status: "in_progress" },
|
||||
},
|
||||
{ type: "tool_use", id: "3", name: "TaskList", input: {} },
|
||||
{ type: "tool_use", id: "4", name: "TaskGet", input: { taskId: "1" } },
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
expect(taskState.size).toBe(0);
|
||||
});
|
||||
|
||||
it("emits a plan snapshot after a TaskCreate tool_result and accumulates state", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const taskState: TaskState = new Map();
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "1",
|
||||
name: "TaskCreate",
|
||||
input: { subject: "First", description: "" },
|
||||
},
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
const created = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "1",
|
||||
content: JSON.stringify({ task: { id: "1", subject: "First" } }),
|
||||
is_error: false,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].update).toMatchObject({
|
||||
sessionUpdate: "plan",
|
||||
entries: [{ content: "First", status: "pending", priority: "medium" }],
|
||||
});
|
||||
|
||||
// A second TaskCreate accumulates rather than replacing.
|
||||
toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "2",
|
||||
name: "TaskCreate",
|
||||
input: { subject: "Second", description: "" },
|
||||
},
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
const second = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "2",
|
||||
content: JSON.stringify({ task: { id: "2", subject: "Second" } }),
|
||||
is_error: false,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(second[0].update).toMatchObject({
|
||||
sessionUpdate: "plan",
|
||||
entries: [
|
||||
{ content: "First", status: "pending", priority: "medium" },
|
||||
{ content: "Second", status: "pending", priority: "medium" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a plan snapshot reflecting status changes after a TaskUpdate tool_result", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const taskState: TaskState = new Map([["1", { subject: "First", status: "pending" as const }]]);
|
||||
toolUseCache["update-1"] = {
|
||||
type: "tool_use",
|
||||
id: "update-1",
|
||||
name: "TaskUpdate",
|
||||
input: { taskId: "1", status: "completed" },
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "update-1",
|
||||
content: JSON.stringify({
|
||||
success: true,
|
||||
taskId: "1",
|
||||
updatedFields: ["status"],
|
||||
}),
|
||||
is_error: false,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "plan",
|
||||
entries: [{ content: "First", status: "completed", priority: "medium" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses TaskList and TaskGet tool_result without touching task state", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
"list-1": { type: "tool_use", id: "list-1", name: "TaskList", input: {} },
|
||||
"get-1": { type: "tool_use", id: "get-1", name: "TaskGet", input: { taskId: "1" } },
|
||||
};
|
||||
const taskState: TaskState = new Map([
|
||||
["1", { subject: "Existing", status: "in_progress" as const }],
|
||||
]);
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{ type: "tool_result", tool_use_id: "list-1", content: "...", is_error: false },
|
||||
{ type: "tool_result", tool_use_id: "get-1", content: "...", is_error: false },
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
expect(taskState.get("1")).toEqual({ subject: "Existing", status: "in_progress" });
|
||||
});
|
||||
|
||||
it("does not apply TaskCreate/TaskUpdate when the tool_result reports an error", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
"create-1": {
|
||||
type: "tool_use",
|
||||
id: "create-1",
|
||||
name: "TaskCreate",
|
||||
input: { subject: "A", description: "" },
|
||||
},
|
||||
};
|
||||
const taskState: TaskState = new Map();
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "create-1",
|
||||
content: "task creation failed",
|
||||
is_error: true,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
expect(taskState.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTaskHook", () => {
|
||||
it("registers a task on TaskCreated and fires onChange", async () => {
|
||||
const taskState: TaskState = new Map();
|
||||
let changes = 0;
|
||||
const hook = createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
changes++;
|
||||
},
|
||||
});
|
||||
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "TaskCreated",
|
||||
task_id: "t-1",
|
||||
task_subject: "Investigate flaky test",
|
||||
task_description: "Repro and fix",
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
expect(taskState.get("t-1")).toEqual({
|
||||
subject: "Investigate flaky test",
|
||||
status: "pending",
|
||||
description: "Repro and fix",
|
||||
});
|
||||
expect(changes).toBe(1);
|
||||
});
|
||||
|
||||
it("does not clobber an existing entry on TaskCreated", async () => {
|
||||
const taskState: TaskState = new Map([
|
||||
["t-1", { subject: "Investigate flaky test", status: "in_progress" as const }],
|
||||
]);
|
||||
let changes = 0;
|
||||
const hook = createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
changes++;
|
||||
},
|
||||
});
|
||||
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "TaskCreated",
|
||||
task_id: "t-1",
|
||||
task_subject: "Investigate flaky test",
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
expect(taskState.get("t-1")?.status).toBe("in_progress");
|
||||
expect(changes).toBe(0);
|
||||
});
|
||||
|
||||
it("marks a task completed on TaskCompleted", async () => {
|
||||
const taskState: TaskState = new Map([
|
||||
["t-1", { subject: "Investigate flaky test", status: "in_progress" as const }],
|
||||
]);
|
||||
let changes = 0;
|
||||
const hook = createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
changes++;
|
||||
},
|
||||
});
|
||||
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "TaskCompleted",
|
||||
task_id: "t-1",
|
||||
task_subject: "Investigate flaky test",
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
expect(taskState.get("t-1")?.status).toBe("completed");
|
||||
expect(changes).toBe(1);
|
||||
});
|
||||
|
||||
it("is a no-op for unrelated hook events", async () => {
|
||||
const taskState: TaskState = new Map();
|
||||
let changes = 0;
|
||||
const hook = createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
changes++;
|
||||
},
|
||||
});
|
||||
|
||||
await hook({ hook_event_name: "PostToolUse", tool_name: "Read" } as any, undefined, {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(taskState.size).toBe(0);
|
||||
expect(changes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
210
src/tools.ts
210
src/tools.ts
@@ -8,12 +8,16 @@ import {
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
AskUserQuestionInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TaskCreateInput,
|
||||
TaskCreateOutput,
|
||||
TaskUpdateInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
@@ -367,6 +371,40 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskCreate": {
|
||||
const input = toolUse.input as TaskCreateInput | undefined;
|
||||
return {
|
||||
title: input?.subject ? `Create task: ${input.subject}` : "Create task",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskUpdate": {
|
||||
const input = toolUse.input as TaskUpdateInput | undefined;
|
||||
return {
|
||||
title: input?.subject ? `Update task: ${input.subject}` : "Update task",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskList": {
|
||||
return {
|
||||
title: "List tasks",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskGet": {
|
||||
return {
|
||||
title: "Get task",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string } | undefined;
|
||||
return {
|
||||
@@ -378,6 +416,24 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "AskUserQuestion": {
|
||||
const input = toolUse.input as Partial<AskUserQuestionInput> | undefined;
|
||||
const questions = Array.isArray(input?.questions) ? input.questions : [];
|
||||
return {
|
||||
title:
|
||||
questions.length === 1 && questions[0]?.question
|
||||
? questions[0].question
|
||||
: "Asking for your input",
|
||||
kind: "other",
|
||||
content: questions
|
||||
.filter((q) => typeof q?.question === "string")
|
||||
.map((q) => ({
|
||||
type: "content" as const,
|
||||
content: { type: "text" as const, text: q.question },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
case "Other": {
|
||||
const input = toolUse.input;
|
||||
let output;
|
||||
@@ -429,7 +485,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);
|
||||
@@ -669,6 +726,103 @@ export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): Pl
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session task list accumulated from Task* tool calls (TaskCreate /
|
||||
* TaskUpdate). The headless/SDK session emits these as incremental tool
|
||||
* calls keyed by task ID, replacing the snapshot-style TodoWrite tool.
|
||||
* Iteration order is insertion order (Map semantics), matching the order
|
||||
* tasks are created.
|
||||
*/
|
||||
export type TaskEntry = {
|
||||
subject: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm?: string;
|
||||
description?: string;
|
||||
};
|
||||
export type TaskState = Map<string, TaskEntry>;
|
||||
|
||||
/**
|
||||
* Best-effort parse of a TaskCreate tool_result content into the structured
|
||||
* TaskCreateOutput. The SDK delivers tool outputs either as a string or as
|
||||
* an array of TextBlockParam-like blocks containing JSON text; try both.
|
||||
*/
|
||||
export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | undefined {
|
||||
const tryParse = (text: string): TaskCreateOutput | undefined => {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.task &&
|
||||
typeof parsed.task.id === "string"
|
||||
) {
|
||||
return parsed as TaskCreateOutput;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (typeof content === "string") {
|
||||
return tryParse(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && "type" in block && block.type === "text") {
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
const parsed = tryParse(text);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyTaskCreate(
|
||||
state: TaskState,
|
||||
input: TaskCreateInput | undefined,
|
||||
output: TaskCreateOutput | undefined,
|
||||
): void {
|
||||
const taskId = output?.task?.id;
|
||||
if (!taskId || !input) return;
|
||||
state.set(taskId, {
|
||||
subject: input.subject,
|
||||
status: "pending",
|
||||
activeForm: input.activeForm,
|
||||
description: input.description,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyTaskUpdate(state: TaskState, input: TaskUpdateInput | undefined): void {
|
||||
if (!input?.taskId) return;
|
||||
if (input.status === "deleted") {
|
||||
state.delete(input.taskId);
|
||||
return;
|
||||
}
|
||||
const existing = state.get(input.taskId);
|
||||
// Without a subject from either the existing entry or the update payload,
|
||||
// we'd produce a plan entry with empty `content` — drop the update.
|
||||
const subject = input.subject ?? existing?.subject;
|
||||
if (!subject) return;
|
||||
state.set(input.taskId, {
|
||||
subject,
|
||||
status: input.status ?? existing?.status ?? "pending",
|
||||
activeForm: input.activeForm ?? existing?.activeForm,
|
||||
description: input.description ?? existing?.description,
|
||||
});
|
||||
}
|
||||
|
||||
export function taskStateToPlanEntries(state: TaskState): PlanEntry[] {
|
||||
return Array.from(state.values()).map((task) => ({
|
||||
content: task.subject,
|
||||
status: task.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
}
|
||||
|
||||
export function markdownEscape(text: string): string {
|
||||
let escape = "```";
|
||||
for (const [m] of text.matchAll(/^```+/gm)) {
|
||||
@@ -679,7 +833,7 @@ export function markdownEscape(text: string): string {
|
||||
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
|
||||
}
|
||||
|
||||
interface EditToolResponseHunk {
|
||||
interface DiffToolResponseHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
@@ -687,23 +841,24 @@ interface EditToolResponseHunk {
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface EditToolResponse {
|
||||
interface DiffToolResponse {
|
||||
filePath?: string;
|
||||
structuredPatch?: EditToolResponseHunk[];
|
||||
structuredPatch?: DiffToolResponseHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds diff ToolUpdate content from the structured Edit toolResponse provided
|
||||
* by the PostToolUse hook. Unlike parsing the plain unified diff string, this uses
|
||||
* the pre-parsed structuredPatch which supports multiple replacement sites (replaceAll)
|
||||
* and always includes context lines for better readability.
|
||||
* Builds diff ToolUpdate content from the structured toolResponse provided by
|
||||
* the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing
|
||||
* the plain unified diff string, this uses the pre-parsed structuredPatch
|
||||
* which supports multiple replacement sites (replaceAll) and always includes
|
||||
* context lines for better readability.
|
||||
*/
|
||||
export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
} {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as EditToolResponse;
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
|
||||
|
||||
const content: ToolCallContent[] = [];
|
||||
@@ -796,3 +951,38 @@ export const createPostToolUseHook =
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook callback for `TaskCreated` / `TaskCompleted` events. The SDK fires
|
||||
* these for both user-facing TaskCreate tool calls and subagent task
|
||||
* creation, giving us `task_id` + `task_subject` without having to parse
|
||||
* tool_result payloads.
|
||||
*
|
||||
* Populating `taskState` from the hook means a later `TaskUpdate` (which
|
||||
* typically only carries `taskId` + `status`) finds an existing entry with
|
||||
* a real subject, instead of synthesizing a placeholder with empty content.
|
||||
*/
|
||||
export const createTaskHook =
|
||||
(options: { taskState: TaskState; onChange?: () => Promise<void> }): HookCallback =>
|
||||
async (input): Promise<{ continue: boolean }> => {
|
||||
const taskId =
|
||||
"task_id" in input && typeof input.task_id === "string" ? input.task_id : undefined;
|
||||
if (!taskId) return { continue: true };
|
||||
|
||||
if (input.hook_event_name === "TaskCreated") {
|
||||
if (!input.task_subject) return { continue: true };
|
||||
if (options.taskState.has(taskId)) return { continue: true };
|
||||
options.taskState.set(taskId, {
|
||||
subject: input.task_subject,
|
||||
status: "pending",
|
||||
description: input.task_description,
|
||||
});
|
||||
if (options.onChange) await options.onChange();
|
||||
} else if (input.hook_event_name === "TaskCompleted") {
|
||||
const existing = options.taskState.get(taskId);
|
||||
if (!existing || existing.status === "completed") return { continue: true };
|
||||
options.taskState.set(taskId, { ...existing, status: "completed" });
|
||||
if (options.onChange) await options.onChange();
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
18
src/utils.ts
18
src/utils.ts
@@ -2,9 +2,7 @@
|
||||
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { WritableStream, ReadableStream } from "node:stream/web";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import { ClaudeCodeSettings, getManagedSettingsPath } from "./settings.js";
|
||||
|
||||
// Useful for bridging push-based and async-iterator-based code.
|
||||
export class Pushable<T> implements AsyncIterable<T> {
|
||||
@@ -89,19 +87,3 @@ export function unreachable(value: never, logger: Logger = console) {
|
||||
export function sleep(time: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
export function loadManagedSettings(): ClaudeCodeSettings | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
|
||||
if (settings.env) {
|
||||
for (const [key, value] of Object.entries(settings.env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user