mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
255 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32abd34bc0 | ||
|
|
67af018d02 | ||
|
|
a55f31a6b0 | ||
|
|
f0801d50b2 | ||
|
|
1a9d3cebcd | ||
|
|
84e05eecc5 | ||
|
|
5faefab8ab | ||
|
|
b3ddfc3b86 | ||
|
|
e09f1486f1 | ||
|
|
1e15f3d8b0 | ||
|
|
92adcbd1ac | ||
|
|
e474a0c127 | ||
|
|
ef1dbd1cb4 | ||
|
|
e30f0e5ba0 | ||
|
|
9e1185bfd5 | ||
|
|
3d2fd36de2 | ||
|
|
e9dd45207d | ||
|
|
b2fa8fb275 | ||
|
|
f7dc300a16 | ||
|
|
ca1fe79e6f | ||
|
|
079614ab05 | ||
|
|
2ecfa83b26 | ||
|
|
dd67450fd9 | ||
|
|
d6a069f9fe | ||
|
|
85cd70c9f3 | ||
|
|
860c0441c7 | ||
|
|
3c98d76197 | ||
|
|
07a0fbc2f6 | ||
|
|
a0da4277e3 | ||
|
|
e3b7d1b9b3 | ||
|
|
32b16c1ad9 | ||
|
|
9fb631f5d7 | ||
|
|
5c81e99fe5 | ||
|
|
de37577eaf | ||
|
|
3c570c7673 | ||
|
|
96abf29334 | ||
|
|
93f58c0d2f | ||
|
|
6811943f57 | ||
|
|
d627b8c5e9 | ||
|
|
403a668078 | ||
|
|
fb9aced315 | ||
|
|
0a501434c0 | ||
|
|
c073131488 | ||
|
|
fcf5aaf06d | ||
|
|
cc73e37b41 | ||
|
|
41f04ec31c | ||
|
|
8acd0bd27f | ||
|
|
4b82236c1f | ||
|
|
0111786af2 | ||
|
|
aa82193330 | ||
|
|
26f3e8a521 | ||
|
|
769255c9a9 | ||
|
|
bea1a40e9b | ||
|
|
f9b15ba1c5 | ||
|
|
0990a8e520 | ||
|
|
15e91fb5c3 | ||
|
|
f422c7c954 | ||
|
|
063cd35380 | ||
|
|
a16145396f | ||
|
|
65a2230385 | ||
|
|
1d36b58cfb | ||
|
|
ee324b54dd | ||
|
|
d07799d7b3 | ||
|
|
7506223cff | ||
|
|
ae6c388314 | ||
|
|
320f4d70a1 | ||
|
|
e00a43901f | ||
|
|
93d87c2e71 | ||
|
|
fe94156852 | ||
|
|
3c6cb3208b | ||
|
|
cf16fabcc4 | ||
|
|
23b3073025 | ||
|
|
2bf75c1a9f | ||
|
|
27395a196d | ||
|
|
a7b3e3c97d | ||
|
|
886b9b3a75 | ||
|
|
f919d615eb | ||
|
|
f51f3a7937 | ||
|
|
b409782992 | ||
|
|
ce4a67ae33 | ||
|
|
5268661b7f | ||
|
|
9583f97536 | ||
|
|
95721e4e39 | ||
|
|
7f1c6c2078 | ||
|
|
3500b6c16e | ||
|
|
07db59ef0d | ||
|
|
34c847a9e3 | ||
|
|
d2428a49ef | ||
|
|
91d629d6c6 | ||
|
|
5fd7873c7c | ||
|
|
83496544b2 | ||
|
|
c5d5c11f40 | ||
|
|
521d1f766d | ||
|
|
f11435df13 | ||
|
|
e8545266fe | ||
|
|
ff3b97aece | ||
|
|
c40d7bc30f | ||
|
|
152fe91152 | ||
|
|
45de3d2847 | ||
|
|
c7ae07da6b | ||
|
|
0af467c400 | ||
|
|
f4ce899223 | ||
|
|
31539b4ee9 | ||
|
|
61a39ea801 | ||
|
|
c0b7752b45 | ||
|
|
3002539e4a | ||
|
|
87a0886a34 | ||
|
|
2193a13a3b | ||
|
|
c13edf3b02 | ||
|
|
a217891e6c | ||
|
|
867de85f0b | ||
|
|
af11cdbba5 | ||
|
|
f7af07758c | ||
|
|
3524606555 | ||
|
|
9ecb222807 | ||
|
|
dd9fe3a98e | ||
|
|
1c641cd6a7 | ||
|
|
a02ef184d3 | ||
|
|
3beddee766 | ||
|
|
52d5b74aa7 | ||
|
|
edf6486c44 | ||
|
|
232a8cd8e2 | ||
|
|
37e3f389f6 | ||
|
|
f513a14a46 | ||
|
|
c0e2cee265 | ||
|
|
b4f56b17fc | ||
|
|
c2e45e638d | ||
|
|
5c1863d56e | ||
|
|
6253b1611e | ||
|
|
6ff856c94f | ||
|
|
1b057923e8 | ||
|
|
3b3ce6f287 | ||
|
|
b1d09f2366 | ||
|
|
d24066bd9d | ||
|
|
5ca63abc47 | ||
|
|
001614b459 | ||
|
|
218661f97b | ||
|
|
5ede2b7c4c | ||
|
|
988a741d59 | ||
|
|
b57a429867 | ||
|
|
101f09dbf8 | ||
|
|
d36204f6da | ||
|
|
4de2f98a8e | ||
|
|
f5a5c73607 | ||
|
|
5bd0af27ec | ||
|
|
be618f5dcb | ||
|
|
28b85aed41 | ||
|
|
44bb150b5d | ||
|
|
18bd9f2490 | ||
|
|
b8e655d7ac | ||
|
|
0e77751692 | ||
|
|
e5b836820b | ||
|
|
c3ffcfa98f | ||
|
|
4d232dafad | ||
|
|
4f0ffa2735 | ||
|
|
f069df5857 | ||
|
|
e2a1ac9b9d | ||
|
|
673b0507d7 | ||
|
|
ea00a4014a | ||
|
|
ebdd115b76 | ||
|
|
7c87fe7a8c | ||
|
|
8420b50523 | ||
|
|
24339faf2b | ||
|
|
8360a21358 | ||
|
|
a9985d49e9 | ||
|
|
9dfd0a0c4f | ||
|
|
fd4fd247bf | ||
|
|
67926a9ac2 | ||
|
|
4d0cb9cbdd | ||
|
|
e0cac12239 | ||
|
|
6b68ddf00e | ||
|
|
34a617e20f | ||
|
|
9016cba978 | ||
|
|
325c513f1b | ||
|
|
87fa0a23c0 | ||
|
|
c5a49e7481 | ||
|
|
c597cfae85 | ||
|
|
a31f4534dd | ||
|
|
2e9bb8e82b | ||
|
|
cfdb6463d6 | ||
|
|
adb495d5d3 | ||
|
|
11ca660dec | ||
|
|
32898580a5 | ||
|
|
a7794b5ffb | ||
|
|
8802888e39 | ||
|
|
e4cebeca96 | ||
|
|
45a539257b | ||
|
|
670fb18728 | ||
|
|
36ea238d76 | ||
|
|
8366864daf | ||
|
|
5917529f0d | ||
|
|
2597775ef0 | ||
|
|
6572fcf978 | ||
|
|
5586ae0d2c | ||
|
|
39ab0c248f | ||
|
|
72a9cde888 | ||
|
|
c9d5d3bb9c | ||
|
|
cee383c67c | ||
|
|
041aa44d00 | ||
|
|
3db76b2d20 | ||
|
|
db965a31aa | ||
|
|
5280246773 | ||
|
|
9f3dd402ea | ||
|
|
d19e399018 | ||
|
|
24d1050f7a | ||
|
|
a94598dd89 | ||
|
|
8228c471c8 | ||
|
|
c2200eec80 | ||
|
|
3e5f0b4830 | ||
|
|
172565f19d | ||
|
|
09106474ea | ||
|
|
2b60bad254 | ||
|
|
e81ecbd96e | ||
|
|
e1f08905f7 | ||
|
|
634f1ef6a4 | ||
|
|
86d333b8cc | ||
|
|
9f6fef55dd | ||
|
|
c3e1418d89 | ||
|
|
3b4cd4b2d5 | ||
|
|
9320e05891 | ||
|
|
3f44543019 | ||
|
|
4db3d855bf | ||
|
|
e57893f6d0 | ||
|
|
6a93d7e786 | ||
|
|
c3c0f4da36 | ||
|
|
73d308e4ad | ||
|
|
49b28a25eb | ||
|
|
8516df7188 | ||
|
|
e31a16228a | ||
|
|
c00782c103 | ||
|
|
4211f8cf05 | ||
|
|
ac2119a23a | ||
|
|
427117dce6 | ||
|
|
b5a7e0f28d | ||
|
|
8ef0b4e4c6 | ||
|
|
959cd6c8e2 | ||
|
|
ef209d9d12 | ||
|
|
91b00b71e3 | ||
|
|
101a527732 | ||
|
|
59c5a0a8b8 | ||
|
|
5133e2dec7 | ||
|
|
ed035da9d8 | ||
|
|
8df7f10058 | ||
|
|
6a0d0b5522 | ||
|
|
7e0d2c9506 | ||
|
|
2835f030cf | ||
|
|
2710e146f4 | ||
|
|
bf396fea67 | ||
|
|
84b5744a2f | ||
|
|
b213152a94 | ||
|
|
cd5aabdd74 | ||
|
|
9e18a4e6fd | ||
|
|
48df285bd0 | ||
|
|
b138c84cdb | ||
|
|
0735aca015 |
18
.github/dependabot.yml
vendored
Normal file
18
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every week
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
minor:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -21,12 +21,12 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: "lts/*"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
57
.github/workflows/publish.yml
vendored
57
.github/workflows/publish.yml
vendored
@@ -1,19 +1,52 @@
|
||||
name: Publish Package to npmjs
|
||||
name: Publish and Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
# 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
|
||||
id: generate-token
|
||||
with:
|
||||
# GitHub App ID secret name
|
||||
app-id: ${{ secrets.RELEASE_PLZ_APP_ID }}
|
||||
# GitHub App private key secret name
|
||||
private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}
|
||||
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7
|
||||
id: release
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
release-type: node
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -22,15 +55,3 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [publish-npm]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
with:
|
||||
draft: true
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.claude/*.local.*
|
||||
CLAUDE.local.md
|
||||
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.agent-shell
|
||||
|
||||
.release-please-manifest.json
|
||||
release-please-config.json
|
||||
CHANGELOG.md
|
||||
3
.release-please-manifest.json
Normal file
3
.release-please-manifest.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.31.2"
|
||||
}
|
||||
398
CHANGELOG.md
398
CHANGELOG.md
@@ -1,5 +1,403 @@
|
||||
# Changelog
|
||||
|
||||
## [0.31.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.1...v0.31.2) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* tolerate invalid values in settings.json ([#601](https://github.com/agentclientprotocol/claude-agent-acp/issues/601)) ([67af018](https://github.com/agentclientprotocol/claude-agent-acp/commit/67af018d0216238d8f3cd5bad04a55200518239b))
|
||||
|
||||
## [0.31.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.0...v0.31.1) (2026-04-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* detect glibc and use correct binary ([#599](https://github.com/agentclientprotocol/claude-agent-acp/issues/599)) ([f0801d5](https://github.com/agentclientprotocol/claude-agent-acp/commit/f0801d50b2390a3c48150b9b8507d30a70c18b84))
|
||||
|
||||
## [0.31.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.30.0...v0.31.0) (2026-04-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow configuring models through env vars ([#586](https://github.com/agentclientprotocol/claude-agent-acp/issues/586)) ([1e15f3d](https://github.com/agentclientprotocol/claude-agent-acp/commit/1e15f3d8b0a06076ee62764ab576ac29fe056a38))
|
||||
* Support effort levels ([#464](https://github.com/agentclientprotocol/claude-agent-acp/issues/464)) ([9e1185b](https://github.com/agentclientprotocol/claude-agent-acp/commit/9e1185bfd55cbf83a653a0ff8c194f986cbd1574))
|
||||
* Update to acp sdk v0.20 ([#589](https://github.com/agentclientprotocol/claude-agent-acp/issues/589)) ([92adcbd](https://github.com/agentclientprotocol/claude-agent-acp/commit/92adcbd1ace2f2103742ffcf5c589eb1ca41b9b8))
|
||||
* Update to claude-agent-sdk 0.2.119 ([#587](https://github.com/agentclientprotocol/claude-agent-acp/issues/587)) ([ef1dbd1](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef1dbd1cb457f0524d221ea07235abf49dc941a0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Forward full systemPrompt preset options from _meta ([#591](https://github.com/agentclientprotocol/claude-agent-acp/issues/591)) ([b3ddfc3](https://github.com/agentclientprotocol/claude-agent-acp/commit/b3ddfc3b86703c23fe99d93611885c12b1e4812c))
|
||||
* Session Replay and Permission rendering ([#593](https://github.com/agentclientprotocol/claude-agent-acp/issues/593)) ([5faefab](https://github.com/agentclientprotocol/claude-agent-acp/commit/5faefab8ab9b725f7a3374808bf5579e411884bc)), closes [#579](https://github.com/agentclientprotocol/claude-agent-acp/issues/579)
|
||||
|
||||
## [0.30.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.29.2...v0.30.0) (2026-04-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.2.114 ([#572](https://github.com/agentclientprotocol/claude-agent-acp/issues/572)) ([e9dd452](https://github.com/agentclientprotocol/claude-agent-acp/commit/e9dd45207d396302f20de82561135bf9558c0e76))
|
||||
|
||||
## [0.29.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.29.1...v0.29.2) (2026-04-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Guard against null usage tokens ([#565](https://github.com/agentclientprotocol/claude-agent-acp/issues/565)) ([f7dc300](https://github.com/agentclientprotocol/claude-agent-acp/commit/f7dc300a165e70f6426f53920f12e24c04033cdc)), closes [#564](https://github.com/agentclientprotocol/claude-agent-acp/issues/564)
|
||||
|
||||
## [0.29.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.29.0...v0.29.1) (2026-04-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* emit usage updates from Claude stream events ([#506](https://github.com/agentclientprotocol/claude-agent-acp/issues/506)) ([dd67450](https://github.com/agentclientprotocol/claude-agent-acp/commit/dd67450fd9bddc82db3e71273b535e07b2672804))
|
||||
* Remove dot from auto mode description ([#561](https://github.com/agentclientprotocol/claude-agent-acp/issues/561)) ([2ecfa83](https://github.com/agentclientprotocol/claude-agent-acp/commit/2ecfa83b26db58deaded210463fa6ff21d0dff70))
|
||||
* Update to claude-agent-sdk 0.2.112 to fix Auto bug with Opus 4.7 ([#562](https://github.com/agentclientprotocol/claude-agent-acp/issues/562)) ([079614a](https://github.com/agentclientprotocol/claude-agent-acp/commit/079614ab05afba17e2cce0d3d238df7b90e17389))
|
||||
|
||||
## [0.29.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.28.0...v0.29.0) (2026-04-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.2.111 (Opus 4.7) ([#557](https://github.com/agentclientprotocol/claude-agent-acp/issues/557)) ([85cd70c](https://github.com/agentclientprotocol/claude-agent-acp/commit/85cd70c9f3be47c9c404958547c4046d866db1c9))
|
||||
|
||||
## [0.28.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.27.0...v0.28.0) (2026-04-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.2.109 ([#549](https://github.com/agentclientprotocol/claude-agent-acp/issues/549)) ([07a0fbc](https://github.com/agentclientprotocol/claude-agent-acp/commit/07a0fbc2f6bc388541d064a436412bdd850772cb))
|
||||
|
||||
## [0.27.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.26.0...v0.27.0) (2026-04-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow clients to opt into receiving raw SDK messages ([#527](https://github.com/agentclientprotocol/claude-agent-acp/issues/527)) ([403a668](https://github.com/agentclientprotocol/claude-agent-acp/commit/403a668078c067868062ed26cd4d3e36665b66b6))
|
||||
* Update to claude-agent-sdk 0.2.104 ([#537](https://github.com/agentclientprotocol/claude-agent-acp/issues/537)) ([6811943](https://github.com/agentclientprotocol/claude-agent-acp/commit/6811943f57ef08616be633a8197223d4072663cf))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Allow auto mode after plan mode and send description for auto mode ([#528](https://github.com/agentclientprotocol/claude-agent-acp/issues/528)) ([fb9aced](https://github.com/agentclientprotocol/claude-agent-acp/commit/fb9aced3151c40694f1f01fd95665c4f5d90eb67))
|
||||
* Better remote check for auth methods ([#538](https://github.com/agentclientprotocol/claude-agent-acp/issues/538)) ([93f58c0](https://github.com/agentclientprotocol/claude-agent-acp/commit/93f58c0d2fcf7365c7ca5a6e52f56663e2065ddb))
|
||||
* better shutdown logic ([#543](https://github.com/agentclientprotocol/claude-agent-acp/issues/543)) ([9fb631f](https://github.com/agentclientprotocol/claude-agent-acp/commit/9fb631f5d76a5c5f6f0a1b61bdef05fe368c754c))
|
||||
* exit process when ACP connection closes ([#530](https://github.com/agentclientprotocol/claude-agent-acp/issues/530)) ([5c81e99](https://github.com/agentclientprotocol/claude-agent-acp/commit/5c81e99fe5ccdf774819b7ff3a2bc78a6519d730))
|
||||
* guard tool info rendering when tool_use input is undefined ([#536](https://github.com/agentclientprotocol/claude-agent-acp/issues/536)) ([d627b8c](https://github.com/agentclientprotocol/claude-agent-acp/commit/d627b8c5e95be31ac03923ef67d91748ec8564c5))
|
||||
* Remove backup auth check from new session ([#544](https://github.com/agentclientprotocol/claude-agent-acp/issues/544)) ([32b16c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/32b16c1ad99394238b0e5cd6c2f761c12debe142))
|
||||
|
||||
## [0.26.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.3...v0.26.0) (2026-04-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update claude-agent-sdk to 0.2.96 ([#526](https://github.com/agentclientprotocol/claude-agent-acp/issues/526)) ([c073131](https://github.com/agentclientprotocol/claude-agent-acp/commit/c07313148808a55f27f385c10babbaf7511a7f12))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Remove bun builds from release ([#525](https://github.com/agentclientprotocol/claude-agent-acp/issues/525)) ([fcf5aaf](https://github.com/agentclientprotocol/claude-agent-acp/commit/fcf5aaf06dfe9f7d1b285b976eeb2d1e20ea8dec))
|
||||
* Use TUI login for remote environments ([#523](https://github.com/agentclientprotocol/claude-agent-acp/issues/523)) ([cc73e37](https://github.com/agentclientprotocol/claude-agent-acp/commit/cc73e37b41678aa67813c4fbef66ba33ca538743))
|
||||
|
||||
## [0.25.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.2...v0.25.3) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Drop claude-agent-sdk back to 0.2.91 to fix broken import ([#513](https://github.com/agentclientprotocol/claude-agent-acp/issues/513)) ([26f3e8a](https://github.com/agentclientprotocol/claude-agent-acp/commit/26f3e8a5216295985fadb80fb3b977045c0c1b2c))
|
||||
* Recreate resumed sessions when params change ([#515](https://github.com/agentclientprotocol/claude-agent-acp/issues/515)) ([aa82193](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa82193330026bae132fed8391c47dde777dcf5a))
|
||||
|
||||
## [0.25.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.1...v0.25.2) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prioritize ANTHROPIC_MODEL env var over settings.model in model … ([#505](https://github.com/agentclientprotocol/claude-agent-acp/issues/505)) ([bea1a40](https://github.com/agentclientprotocol/claude-agent-acp/commit/bea1a40e9bfe1e06672b118a727f9339def3be23))
|
||||
|
||||
## [0.25.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.0...v0.25.1) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add `auto` to valid modes in applySessionMode to fix mode cycling ([#507](https://github.com/agentclientprotocol/claude-agent-acp/issues/507)) ([15e91fb](https://github.com/agentclientprotocol/claude-agent-acp/commit/15e91fb5c3449de2600b583cb7a8d36b5b510443))
|
||||
|
||||
## [0.25.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.2...v0.25.0) (2026-04-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add auto permission mode support ([#501](https://github.com/agentclientprotocol/claude-agent-acp/issues/501)) ([a161453](https://github.com/agentclientprotocol/claude-agent-acp/commit/a16145396fe2e6ead8734478961b2de65707e335))
|
||||
* Add separate Claude and Console terminal logins ([#502](https://github.com/agentclientprotocol/claude-agent-acp/issues/502)) ([063cd35](https://github.com/agentclientprotocol/claude-agent-acp/commit/063cd353809df8f3dda9e57d8ea848c0a6d44257))
|
||||
* Update to claude-agent-sdk 0.2.91 ([#500](https://github.com/agentclientprotocol/claude-agent-acp/issues/500)) ([65a2230](https://github.com/agentclientprotocol/claude-agent-acp/commit/65a223038576d72b74e1483fed10e982a1f842bd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* log warnings for malformed settings files instead of silent fallback ([#486](https://github.com/agentclientprotocol/claude-agent-acp/issues/486)) ([ae6c388](https://github.com/agentclientprotocol/claude-agent-acp/commit/ae6c38831415f9fc1de2d3dd1d4a247becbbd32f))
|
||||
* prevent race conditions in SettingsManager setCwd and debounce ([#485](https://github.com/agentclientprotocol/claude-agent-acp/issues/485)) ([7506223](https://github.com/agentclientprotocol/claude-agent-acp/commit/7506223cffb1aba4b4560feda11f69a1395a8c9d))
|
||||
* use current model's context window for usage_update size ([#412](https://github.com/agentclientprotocol/claude-agent-acp/issues/412)) ([d07799d](https://github.com/agentclientprotocol/claude-agent-acp/commit/d07799d7b3b4e438c8b158266c79723a0b592c07))
|
||||
|
||||
## [0.24.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.1...v0.24.2) (2026-03-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add explicit type checks for MCP servers (http/sse) ([#487](https://github.com/agentclientprotocol/claude-agent-acp/issues/487)) ([e00a439](https://github.com/agentclientprotocol/claude-agent-acp/commit/e00a43901fa2b4fd7d582e1de57277be88233007))
|
||||
|
||||
## [0.24.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.0...v0.24.1) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cleanup based on new idle state [#463](https://github.com/agentclientprotocol/claude-agent-acp/issues/463) ([#480](https://github.com/agentclientprotocol/claude-agent-acp/issues/480)) ([23b3073](https://github.com/agentclientprotocol/claude-agent-acp/commit/23b30730253752f0bc4e30b619a6236f16fafdb9))
|
||||
|
||||
## 0.24.0
|
||||
|
||||
Rename from `@zed-industries/claude-agent-acp` to `@agentclientprotocol/claude-agent-acp`.
|
||||
|
||||
We are moving this to the main ACP org to better allow multiple teams to contribute and maintain this adapter.
|
||||
|
||||
## 0.23.1
|
||||
|
||||
- Add back error_during_execution break point (#469)
|
||||
|
||||
## 0.23.0
|
||||
|
||||
- Use idle session state as end of turn (#463)
|
||||
- Update claude-agent-sdk to 0.2.83 (#462)
|
||||
- Fix handling of local-only slash commands (#432)
|
||||
- fix: correct null check for gatewayAuthMeta in subscription validation (#455)
|
||||
- fix: include both stdout and stderr in Bash tool output (#456)
|
||||
- fix: prevent prompt loop hang when cancel races with first result (#458)
|
||||
- fix: dispose SettingsManager on session close to prevent resource leaks (#454)
|
||||
- fix: restore plan content in ExitPlanMode tool call (#451)
|
||||
|
||||
## 0.22.2
|
||||
|
||||
- Add experimental meta param for testing additional directories
|
||||
|
||||
## 0.22.1
|
||||
|
||||
- Fix: invalid auth required state in gateway mode
|
||||
|
||||
## 0.22.0
|
||||
|
||||
- Use stable list sessions method (#429)
|
||||
- Use correct Claude CLI path for static binaries (#428)
|
||||
- Update claude-agent-sdk to 0.2.76 (#427)
|
||||
- fix: resolve model aliases in setSessionConfigOption (#401) (#403)
|
||||
- Reuse existing sessions for load/resume if possible (#426)
|
||||
- Remove interrupt flag from deny responses (#425)
|
||||
- Allow Bypass permissions mode after Exiting plan (#410)
|
||||
- Don't get out of sync when background task creates new init/result (try 2) (#400)
|
||||
- Add session/close support (#409)
|
||||
|
||||
## 0.21.0
|
||||
|
||||
- Update to claude-agent-sdk 0.2.71
|
||||
- show project-relative paths in tool call titles
|
||||
- Lib: pass through tools array to control built-in tool availability
|
||||
- fix: skip user replay
|
||||
- fix: handle renamed Agent tool in toolInfoFromToolUse
|
||||
|
||||
## 0.20.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.68
|
||||
|
||||
## 0.20.1
|
||||
|
||||
- fix: inherit process.env when spawning agent subprocess
|
||||
|
||||
## 0.20.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.63
|
||||
- Respect user settings for permission mode and model selection
|
||||
- Better handling of concurrent prompts
|
||||
- Support --cli for node as well
|
||||
- Propagate max_tokens stop reason instead of throwing internal error
|
||||
- fix: throw resourceNotFound when loadSession fails to resume
|
||||
- fix: add missing zod dependency
|
||||
- Surface better error message when Claude Code process exits unexpectedly
|
||||
|
||||
## 0.19.2
|
||||
|
||||
- Fix for broken notifications when reloading session messages
|
||||
|
||||
## 0.19.1
|
||||
|
||||
- Support windows arm builds and clean up artifact files
|
||||
|
||||
## 0.19.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.62
|
||||
- Use SDK functions for listing and loading session history
|
||||
- Build single-file executables using bun.
|
||||
- Fix for overwritten disallowed tools.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
- Switch over to built-in Claude tools. We no longer replicate specific ACP tools and just rely on sending updates based on Claude's internal tools. This means it won't use client capabilities for files or terminals, but also means there will be less difference and hopefully issues arising from the differences in behavior.
|
||||
- Support ACP session config options: https://agentclientprotocol.com/protocol/session-config-options
|
||||
- Fix for image output from tool calls.
|
||||
|
||||
## 0.17.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.45 to add access to Sonnet 4.6
|
||||
|
||||
## 0.17.0
|
||||
|
||||
Rename from `@zed-industries/claude-code-acp` to `@zed-industries/claude-agent-acp` to align with the current [branding guidelines](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines)
|
||||
|
||||
## 0.16.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.44
|
||||
- fix: Replace all non-alphanumeric characters for session loading in encodeProjectPath (#307)
|
||||
- don't include /login slash command in login command (#315)
|
||||
|
||||
## 0.16.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.38
|
||||
- Fix incorrect paths for session/list
|
||||
- Fix available commands after loading a session
|
||||
- Make loading session more permissive for finding events
|
||||
- Fix overriding user-provided disallowedTools
|
||||
|
||||
## 0.16.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.34
|
||||
- Experimental support for session loading
|
||||
|
||||
## 0.15.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.32 (adds support for Opus 4.6 and 1M context Opus)
|
||||
|
||||
## 0.14.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.29
|
||||
- Update to using the recommended `CLAUDE_CONFIG_DIR` env variable for setting where config files are kept
|
||||
- Support /context command
|
||||
- Fix incorrect context type mapping for tool calls
|
||||
- Fix glob metching for file permissions on Windows
|
||||
- Support the `IS_SANDBOX` env var for supporting bypass permissions in root mode
|
||||
- Fix missing notification for entering plan mode
|
||||
- Experimental unstable support for listing sessions
|
||||
|
||||
## 0.13.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.22
|
||||
- Fix: return content from ACP write tool to help with issues with alternate providers.
|
||||
|
||||
## 0.13.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.7
|
||||
- Add TypeScript declaration files for library users
|
||||
- Fixed error handling in custom ACP focused MCP tools
|
||||
|
||||
## 0.13.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.6
|
||||
- Update to @agentclientprotocol/sdk@0.13.0
|
||||
|
||||
## 0.12.6
|
||||
|
||||
- Fix model selection
|
||||
|
||||
## 0.12.5
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.70
|
||||
- Unstable implementation of resuming sessions
|
||||
|
||||
## 0.12.4
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.67
|
||||
- Better respect permissions specified in settings files
|
||||
- Unstable implementation of forking
|
||||
|
||||
## 0.12.3
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.65
|
||||
- Update to @agentclientprotocol/sdk@0.9.0
|
||||
- Allow agent to write plans and todos to its config directory
|
||||
- Fix experimental resume ids
|
||||
|
||||
## 0.12.2
|
||||
|
||||
- Fix duplicate tool use IDs error
|
||||
|
||||
## 0.12.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.61
|
||||
- Update to @agentclientprotocol/sdk@0.8.0
|
||||
|
||||
## 0.12.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.59
|
||||
- Brings Opus to Claude Pro plans
|
||||
- Support "Don't Ask" profile
|
||||
- Unify ACP + Claude Code session ids
|
||||
|
||||
## 0.11.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.57
|
||||
- Removed dependency on @anthropic-ai/claude-code since this is no longer needed
|
||||
|
||||
## 0.10.10
|
||||
|
||||
- Update to @agentclientprotocol/sdk@0.7.0
|
||||
|
||||
## 0.10.9
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.55
|
||||
- Allow defining a custom logger when used as a library
|
||||
- Allow specifying custom options when used as a library
|
||||
- Add `CLAUDECODE=1` to terminal invocations to match default Claude Code behavior
|
||||
|
||||
## 0.10.8
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.51 (adds support for Opus 4.5)
|
||||
|
||||
## 0.10.7
|
||||
|
||||
- Fix read/edit tool error handling so upstream errors surface
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.50
|
||||
|
||||
## 0.10.6
|
||||
|
||||
- Disable experimental terminal auth support for now, as it was causing issues on Windows. Will revisit with a fix later.
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.46
|
||||
|
||||
## 0.10.5
|
||||
|
||||
- Better error messages at end of turn if there were any
|
||||
- Add experimental support for disabling built-in tools via \_meta flag
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.44
|
||||
|
||||
## 0.10.4
|
||||
|
||||
- Fix tool call titles not appearing during approval in some cases
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.42
|
||||
|
||||
## 0.10.3
|
||||
|
||||
- Fix for experimental terminal auth support
|
||||
|
||||
## 0.10.2
|
||||
|
||||
- Fix incorrect stop reason for tool call refusals
|
||||
|
||||
## 0.10.1
|
||||
|
||||
- Add additional structured metadata to tool calls
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.37
|
||||
|
||||
## 0.10.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@v0.1.30
|
||||
- Use `canUseTool` callback instead of launching an HTTP MCP server for permission checks.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
- Support slash commands coming from MCP servers (Prompts)
|
||||
|
||||
## 0.8.0
|
||||
|
||||
- Revert changes to filename for cli entrypoint
|
||||
|
||||
61
LICENSE
61
LICENSE
@@ -1,42 +1,18 @@
|
||||
Copyright 2022 - 2025 Zed Industries, Inc.
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
|
||||
|
||||
Apache License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
|
||||
1. Definitions.
|
||||
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
@@ -45,28 +21,23 @@ Apache License
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
@@ -75,7 +46,6 @@ Apache License
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
@@ -90,12 +60,10 @@ Apache License
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
@@ -103,7 +71,6 @@ Apache License
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
@@ -120,28 +87,23 @@ Apache License
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
@@ -159,7 +121,6 @@ Apache License
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
@@ -167,7 +128,6 @@ Apache License
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
@@ -176,13 +136,11 @@ Apache License
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
@@ -193,7 +151,6 @@ Apache License
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
@@ -206,7 +163,6 @@ Apache License
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
@@ -218,5 +174,18 @@ Apache License
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025 Zed Industries, Inc. and contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
48
README.md
48
README.md
@@ -1,10 +1,10 @@
|
||||
# ACP adapter for Claude Code
|
||||
# ACP adapter for the Claude Agent SDK
|
||||
|
||||
[](https://www.npmjs.com/package/@zed-industries/claude-code-acp)
|
||||
[](https://www.npmjs.com/package/@agentclientprotocol/claude-agent-acp)
|
||||
|
||||
Use [Claude Code](https://www.anthropic.com/claude-code) from [ACP-compatible](https://agentclientprotocol.com) clients such as [Zed](https://zed.dev)!
|
||||
Use [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines) from [ACP-compatible](https://agentclientprotocol.com) clients!
|
||||
|
||||
This tool implements an ACP agent by using the official [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-overview), supporting:
|
||||
This tool implements an ACP agent by using the official [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting:
|
||||
|
||||
- Context @-mentions
|
||||
- Images
|
||||
@@ -18,42 +18,8 @@ This tool implements an ACP agent by using the official [Claude Code SDK](https:
|
||||
|
||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
## How to use
|
||||
## Contribution Policy
|
||||
|
||||
### Zed
|
||||
This project does not require a Contributor License Agreement (CLA). Instead, contributions are accepted under the following terms:
|
||||
|
||||
The latest version of Zed can already use this adapter out of the box.
|
||||
|
||||
To use Claude Code, open the Agent Panel and click "New Claude Code Thread" from the `+` button menu in the top-right:
|
||||
|
||||
https://github.com/user-attachments/assets/ddce66c7-79ac-47a3-ad59-4a6a3ca74903
|
||||
|
||||
Read the docs on [External Agent](https://zed.dev/docs/ai/external-agents) support.
|
||||
|
||||
### Other clients
|
||||
|
||||
- Emacs via [agent-shell.el](https://github.com/xenodium/agent-shell)
|
||||
- [marimo notebook](https://github.com/marimo-team/marimo)
|
||||
- Neovim
|
||||
- via [CodeCompanion.nvim](https://codecompanion.olimorris.dev/configuration/adapters#setup-claude-code-via-acp)
|
||||
- via [yetone/avante.nvim](https://github.com/yetone/avante.nvim)
|
||||
|
||||
[Submit a PR](https://github.com/zed-industries/claude-code-acp/pulls) to add yours!
|
||||
|
||||
#### Installation
|
||||
|
||||
Install the adapter from `npm`:
|
||||
|
||||
```bash
|
||||
npm install @zed-industries/claude-code-acp
|
||||
```
|
||||
|
||||
You can then use `claude-code-acp` as a regular ACP agent:
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY=sk-... claude-code-acp
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
> By contributing to this project, you agree that your contributions will be licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). You affirm that you have the legal right to submit your work, that you are not including code you do not have rights to, and that you understand contributions are made without requiring a Contributor License Agreement (CLA).
|
||||
|
||||
24
docs/RELEASES.md
Normal file
24
docs/RELEASES.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## How to publish a new release
|
||||
|
||||
1. Create a new branch prep-vX.X.X
|
||||
|
||||
```sh
|
||||
git checkout -b prep-vX.X.X
|
||||
```
|
||||
|
||||
2. Bump the version in package.json
|
||||
|
||||
```sh
|
||||
npm version vX.X.X
|
||||
```
|
||||
|
||||
3. Add a new entry in CHANGELOG.md
|
||||
4. Create a PR for `prev-vX.X.X` & merge it
|
||||
5. Create tag and push it
|
||||
|
||||
```sh
|
||||
git tag vX.X.X
|
||||
git push origin vX.X.X
|
||||
```
|
||||
|
||||
6. Paste CHANGELOG into release notes and publish the version on GitHub
|
||||
56
docs/model-configuration.md
Normal file
56
docs/model-configuration.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Model Configuration
|
||||
|
||||
When using claude-agent-acp with alternative providers (e.g. AWS Bedrock), model IDs differ from the direct Anthropic API. The `CLAUDE_MODEL_CONFIG` environment variable lets you configure model overrides and availability at the deployment level.
|
||||
|
||||
## `CLAUDE_MODEL_CONFIG`
|
||||
|
||||
A JSON string with two optional fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `modelOverrides` | `Record<string, string>` | Maps Anthropic model IDs to provider-specific model IDs (e.g. Bedrock model IDs or ARNs) |
|
||||
| `availableModels` | `string[]` | Restricts which models are offered to users. Accepts aliases (`"opus"`), prefixes (`"opus-4-5"`), or full IDs |
|
||||
|
||||
### Examples
|
||||
|
||||
**Bedrock model overrides:**
|
||||
|
||||
```bash
|
||||
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"}}'
|
||||
```
|
||||
|
||||
**Restrict available models:**
|
||||
|
||||
```bash
|
||||
CLAUDE_MODEL_CONFIG='{"availableModels":["opus","sonnet"]}'
|
||||
```
|
||||
|
||||
**Both together:**
|
||||
|
||||
```bash
|
||||
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"},"availableModels":["opus","sonnet"]}'
|
||||
```
|
||||
|
||||
**Full Bedrock example:**
|
||||
|
||||
```bash
|
||||
CLAUDE_CODE_USE_BEDROCK=1 \
|
||||
AWS_REGION=us-west-2 \
|
||||
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1"}}' \
|
||||
node dist/index.js
|
||||
```
|
||||
|
||||
## Precedence
|
||||
|
||||
When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself.
|
||||
|
||||
| Source | Priority |
|
||||
| -------------------------------------------- | ----------------------------------------------------- |
|
||||
| `_meta.claudeCode.options.settings` (caller) | Highest — used if present |
|
||||
| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings |
|
||||
|
||||
## Format details
|
||||
|
||||
- The value must be valid JSON. Invalid JSON will cause session creation to fail with a parse error.
|
||||
- Only `modelOverrides` and `availableModels` keys are read; other keys in the JSON are ignored.
|
||||
- Both fields map directly to the Claude Agent SDK's `Settings` type.
|
||||
3521
package-lock.json
generated
3521
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@@ -1,17 +1,26 @@
|
||||
{
|
||||
"name": "@zed-industries/claude-code-acp",
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.8.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"version": "0.31.2",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
"bin": {
|
||||
"claude-code-acp": "./dist/index.js"
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/lib.d.ts",
|
||||
"import": "./dist/lib.js"
|
||||
},
|
||||
"./*": "./*"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"!dist/tests/",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"package.json"
|
||||
@@ -40,41 +49,33 @@
|
||||
"sdk",
|
||||
"code"
|
||||
],
|
||||
"homepage": "https://github.com/zed-industries/claude-code-acp#readme",
|
||||
"homepage": "https://github.com/agentclientprotocol/claude-agent-acp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/zed-industries/claude-code-acp/issues"
|
||||
"url": "https://github.com/agentclientprotocol/claude-agent-acp/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zed-industries/claude-code-acp.git"
|
||||
"url": "git+https://github.com/agentclientprotocol/claude-agent-acp.git"
|
||||
},
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.5.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.27",
|
||||
"@anthropic-ai/claude-code": "2.0.27",
|
||||
"@modelcontextprotocol/sdk": "1.20.2",
|
||||
"diff": "8.0.2",
|
||||
"express": "5.1.0",
|
||||
"minimist": "1.2.8",
|
||||
"uuid": "13.0.0"
|
||||
"@agentclientprotocol/sdk": "0.20.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.119",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.67.0",
|
||||
"@types/express": "5.0.4",
|
||||
"@types/minimist": "1.2.5",
|
||||
"@types/node": "24.9.1",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.2",
|
||||
"@typescript-eslint/parser": "8.46.2",
|
||||
"@vitest/ui": "4.0.3",
|
||||
"eslint": "9.38.0",
|
||||
"@anthropic-ai/sdk": "0.89.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.6.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.0",
|
||||
"@typescript-eslint/parser": "8.59.0",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "16.4.0",
|
||||
"prettier": "3.6.2",
|
||||
"globals": "17.5.0",
|
||||
"prettier": "3.8.3",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.3"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
13
release-please-config.json
Normal file
13
release-please-config.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true,
|
||||
"packages": {
|
||||
".": {
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"release-type": "node",
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true
|
||||
}
|
||||
},
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
||||
}
|
||||
2705
src/acp-agent.ts
2705
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
87
src/index.ts
87
src/index.ts
@@ -2,25 +2,72 @@
|
||||
|
||||
// Load managed settings and apply environment variables
|
||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
if (process.argv.includes("--cli")) {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const args = process.argv.slice(2).filter((arg) => arg !== "--cli");
|
||||
const child = spawn(await claudeCliPath(), args, { stdio: "inherit" });
|
||||
|
||||
const signals =
|
||||
process.platform === "win32"
|
||||
? (["SIGINT", "SIGTERM"] as const)
|
||||
: (["SIGINT", "SIGTERM", "SIGHUP"] as const);
|
||||
for (const sig of signals) {
|
||||
process.on(sig, () => {
|
||||
if (!child.killed) child.kill(sig);
|
||||
});
|
||||
}
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal && process.platform !== "win32") {
|
||||
// Remove our listener so re-raising actually terminates instead of
|
||||
// re-entering the no-op handler, which would let us exit with code 0
|
||||
// instead of the signal's conventional 128+N.
|
||||
process.removeAllListeners(signal);
|
||||
process.kill(process.pid, signal);
|
||||
} else {
|
||||
process.exit(code ?? 1);
|
||||
}
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.warn = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
const { connection, agent } = runAcp();
|
||||
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
console.error("Error during cleanup:", err);
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
|
||||
// error). Without this, `process.stdin.resume()` keeps the event loop
|
||||
// alive indefinitely, causing orphan process accumulation in oneshot mode.
|
||||
connection.closed.then(shutdown);
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
// Keep process alive while connection is open
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.warn = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
import { runAcp } from "./acp-agent.js";
|
||||
runAcp();
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
|
||||
21
src/lib.ts
21
src/lib.ts
@@ -1,9 +1,14 @@
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
isLocalCommandMetadata,
|
||||
stripLocalCommandMetadata,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
@@ -14,12 +19,16 @@ export {
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export {
|
||||
createMcpServer,
|
||||
createPermissionMcpServer,
|
||||
PERMISSION_TOOL_NAME,
|
||||
toolNames,
|
||||
} from "./mcp-server.js";
|
||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
type SettingsManagerOptions,
|
||||
} from "./settings.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
|
||||
@@ -1,928 +0,0 @@
|
||||
import express from "express";
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
import { Server } from "node:http";
|
||||
import { ClaudeAcpAgent } from "./acp-agent.js";
|
||||
import { ClientCapabilities, TerminalOutputResponse } from "@agentclientprotocol/sdk";
|
||||
import * as diff from "diff";
|
||||
|
||||
import { sleep, unreachable, extractLinesWithByteLimit } from "./utils.js";
|
||||
import { PermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const SYSTEM_REMINDER = `
|
||||
|
||||
<system-reminder>
|
||||
Whenever you read a file, you should consider whether it looks malicious. If it does, you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer high-level questions about the code behavior.
|
||||
</system-reminder>`;
|
||||
|
||||
const defaults = { maxFileSize: 50000, linesToRead: 2000 };
|
||||
|
||||
const unqualifiedToolNames = {
|
||||
read: "Read",
|
||||
edit: "Edit",
|
||||
write: "Write",
|
||||
bash: "Bash",
|
||||
killShell: "KillShell",
|
||||
bashOutput: "BashOutput",
|
||||
};
|
||||
|
||||
const SERVER_PREFIX = "mcp__acp__";
|
||||
export const toolNames = {
|
||||
read: SERVER_PREFIX + unqualifiedToolNames.read,
|
||||
edit: SERVER_PREFIX + unqualifiedToolNames.edit,
|
||||
write: SERVER_PREFIX + unqualifiedToolNames.write,
|
||||
bash: SERVER_PREFIX + unqualifiedToolNames.bash,
|
||||
killShell: SERVER_PREFIX + unqualifiedToolNames.killShell,
|
||||
bashOutput: SERVER_PREFIX + unqualifiedToolNames.bashOutput,
|
||||
};
|
||||
|
||||
const editToolNames = [toolNames.edit, toolNames.write];
|
||||
|
||||
export function createMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
clientCapabilities: ClientCapabilities | undefined,
|
||||
): McpServer {
|
||||
// Create MCP server
|
||||
const server = new McpServer({ name: "acp", version: "1.0.0" }, { capabilities: { tools: {} } });
|
||||
|
||||
if (clientCapabilities?.fs?.readTextFile) {
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.read,
|
||||
{
|
||||
title: unqualifiedToolNames.read,
|
||||
description: `Reads the content of the given file in the project.
|
||||
|
||||
In sessions with ${toolNames.read} always use it instead of Read as it contains the most up-to-date contents.
|
||||
|
||||
Reads a file from the local filesystem. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to ${defaults.linesToRead} lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any files larger than ${defaults.maxFileSize} bytes will be truncated
|
||||
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.
|
||||
- This tool can only read files, not directories. To read a directory, use an ls command via the ${toolNames.bash} tool.
|
||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.`,
|
||||
inputSchema: {
|
||||
file_path: z.string().describe("The absolute path to the file to read"),
|
||||
offset: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(1)
|
||||
.describe(
|
||||
"The line number to start reading from. Only provide if the file is too large to read at once",
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(defaults.linesToRead)
|
||||
.describe(
|
||||
`The number of lines to read. Only provide if the file is too large to read at once.`,
|
||||
),
|
||||
},
|
||||
annotations: {
|
||||
title: "Read file",
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const content = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
line: input.offset,
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
// Extract lines with byte limit enforcement
|
||||
const result = extractLinesWithByteLimit(content.content, defaults.maxFileSize);
|
||||
|
||||
// Construct informative message about what was read
|
||||
let readInfo = "";
|
||||
if (input.offset > 1 || result.wasLimited) {
|
||||
readInfo = "\n\n<file-read-info>";
|
||||
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Read ${result.linesRead} lines (hit 50KB limit). `;
|
||||
} else {
|
||||
readInfo += `Read lines ${input.offset}-${result.linesRead}. `;
|
||||
}
|
||||
|
||||
if (result.wasLimited) {
|
||||
readInfo += `Continue with offset=${result.linesRead}.`;
|
||||
}
|
||||
|
||||
readInfo += "</file-read-info>";
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: result.content + readInfo + SYSTEM_REMINDER,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Reading file failed: " + error.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (clientCapabilities?.fs?.writeTextFile) {
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.write,
|
||||
{
|
||||
title: unqualifiedToolNames.write,
|
||||
description: `Writes a file to the local filesystem..
|
||||
|
||||
In sessions with ${toolNames.write} always use it instead of Write as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
Usage:
|
||||
- This tool will overwrite the existing file if there is one at the provided path.
|
||||
- If this is an existing file, you MUST use the ${toolNames.read} tool first to read the file's contents. This tool will fail if you did not read the file first.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
|
||||
inputSchema: {
|
||||
file_path: z
|
||||
.string()
|
||||
.describe("The absolute path to the file to write (must be absolute, not relative)"),
|
||||
content: z.string().describe("The content to write to the file"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Write file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
try {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
content: input.content,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [],
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Writing file failed: " + error.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.edit,
|
||||
{
|
||||
title: unqualifiedToolNames.edit,
|
||||
description: `Performs exact string replacements in files.
|
||||
|
||||
In sessions with ${toolNames.edit} always use it instead of Edit as it will
|
||||
allow the user to conveniently review changes.
|
||||
|
||||
Usage:
|
||||
- You must use your \`${toolNames.read}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
|
||||
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.`,
|
||||
inputSchema: {
|
||||
file_path: z.string().describe("The absolute path to the file to modify"),
|
||||
old_string: z.string().describe("The text to replace"),
|
||||
new_string: z
|
||||
.string()
|
||||
.describe("The text to replace it with (must be different from old_string)"),
|
||||
replace_all: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.optional()
|
||||
.describe("Replace all occurences of old_string (default false)"),
|
||||
},
|
||||
annotations: {
|
||||
title: "Edit file",
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
idempotentHint: false,
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { content } = await agent.readTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
});
|
||||
|
||||
const { newContent } = replaceAndCalculateLocation(content, [
|
||||
{
|
||||
oldText: input.old_string,
|
||||
newText: input.new_string,
|
||||
replaceAll: input.replace_all,
|
||||
},
|
||||
]);
|
||||
|
||||
const patch = diff.createPatch(input.file_path, content, newContent);
|
||||
|
||||
await agent.writeTextFile({
|
||||
sessionId,
|
||||
path: input.file_path,
|
||||
content: newContent,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: patch,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (agent.clientCapabilities?.terminal) {
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.bash,
|
||||
{
|
||||
title: unqualifiedToolNames.bash,
|
||||
description: `Executes a bash command
|
||||
|
||||
In sessions with ${toolNames.bash} always use it instead of Bash`,
|
||||
inputSchema: {
|
||||
command: z.string().describe("The command to execute"),
|
||||
timeout: z
|
||||
.number()
|
||||
.default(2 * 60 * 1000)
|
||||
.describe(`Optional timeout in milliseconds (max ${2 * 60 * 1000})`),
|
||||
description: z.string().optional()
|
||||
.describe(`Clear, concise description of what this command does in 5-10 words, in active voice. Examples:
|
||||
Input: ls
|
||||
Output: List files in current directory
|
||||
|
||||
Input: git status
|
||||
Output: Show working tree status
|
||||
|
||||
Input: npm install
|
||||
Output: Install package dependencies
|
||||
|
||||
Input: mkdir foo
|
||||
Output: Create directory 'foo'`),
|
||||
run_in_background: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
`Set to true to run this command in the background. The tool returns an \`id\` that can be used with the \`${toolNames.bashOutput}\` tool to retrieve the current output, or the \`${toolNames.killShell}\` tool to stop it early.`,
|
||||
),
|
||||
},
|
||||
},
|
||||
async (input, extra) => {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "The user has left the building",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const toolCallId = extra._meta?.["claudecode/toolUseId"];
|
||||
|
||||
if (typeof toolCallId !== "string") {
|
||||
throw new Error("No tool call ID found");
|
||||
}
|
||||
|
||||
if (!agent.clientCapabilities?.terminal || !agent.client.createTerminal) {
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
const handle = await agent.client.createTerminal({
|
||||
command: input.command,
|
||||
sessionId,
|
||||
outputByteLimit: 32_000,
|
||||
});
|
||||
|
||||
await agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "in_progress",
|
||||
title: input.description,
|
||||
content: [{ type: "terminal", terminalId: handle.id }],
|
||||
},
|
||||
});
|
||||
|
||||
const abortPromise = new Promise((resolve) => {
|
||||
if (extra.signal.aborted) {
|
||||
resolve(null);
|
||||
} else {
|
||||
extra.signal.addEventListener("abort", () => {
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const statusPromise = Promise.race([
|
||||
handle.waitForExit().then((exitStatus) => ({ status: "exited" as const, exitStatus })),
|
||||
abortPromise.then(() => ({ status: "aborted" as const, exitStatus: null })),
|
||||
sleep(input.timeout).then(async () => {
|
||||
if (agent.backgroundTerminals[handle.id]?.status === "started") {
|
||||
await handle.kill();
|
||||
}
|
||||
return { status: "timedOut" as const, exitStatus: null };
|
||||
}),
|
||||
]);
|
||||
|
||||
if (input.run_in_background) {
|
||||
agent.backgroundTerminals[handle.id] = {
|
||||
handle,
|
||||
lastOutput: null,
|
||||
status: "started",
|
||||
};
|
||||
|
||||
statusPromise.then(async ({ status, exitStatus }) => {
|
||||
const bgTerm = agent.backgroundTerminals[handle.id];
|
||||
|
||||
if (bgTerm.status !== "started") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentOutput = await handle.currentOutput();
|
||||
|
||||
agent.backgroundTerminals[handle.id] = {
|
||||
status,
|
||||
pendingOutput: {
|
||||
...currentOutput,
|
||||
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
|
||||
exitStatus: exitStatus ?? currentOutput.exitStatus,
|
||||
},
|
||||
};
|
||||
|
||||
return handle.release();
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Command started in background with id: ${handle.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
await using terminal = handle;
|
||||
|
||||
const { status } = await statusPromise;
|
||||
|
||||
if (status === "aborted") {
|
||||
return {
|
||||
content: [{ type: "text", text: "Tool cancelled by user" }],
|
||||
};
|
||||
}
|
||||
|
||||
const output = await terminal.currentOutput();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: toolCommandOutput(status, output) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.bashOutput,
|
||||
{
|
||||
title: unqualifiedToolNames.bashOutput,
|
||||
description: `- Retrieves output from a running or completed background bash shell
|
||||
- Takes a shell_id parameter identifying the shell
|
||||
- Always returns only new output since the last check
|
||||
- Returns stdout and stderr output along with shell status
|
||||
- Use this tool when you need to monitor or check the output of a long-running shell
|
||||
|
||||
In sessions with ${toolNames.bashOutput} always use it instead of BashOutput.`,
|
||||
inputSchema: {
|
||||
shell_id: z
|
||||
.string()
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.shell_id}`);
|
||||
}
|
||||
|
||||
if (bgTerm.status === "started") {
|
||||
const newOutput = await bgTerm.handle.currentOutput();
|
||||
const strippedOutput = stripCommonPrefix(
|
||||
bgTerm.lastOutput?.output ?? "",
|
||||
newOutput.output,
|
||||
);
|
||||
bgTerm.lastOutput = newOutput;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toolCommandOutput(bgTerm.status, {
|
||||
...newOutput,
|
||||
output: strippedOutput,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: toolCommandOutput(bgTerm.status, bgTerm.pendingOutput),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
unqualifiedToolNames.killShell,
|
||||
{
|
||||
title: unqualifiedToolNames.killShell,
|
||||
description: `- Kills a running background bash shell by its ID
|
||||
- Takes a shell_id parameter identifying the shell to kill
|
||||
- Returns a success or failure status
|
||||
- Use this tool when you need to terminate a long-running shell
|
||||
|
||||
In sessions with ${toolNames.killShell} always use it instead of KillShell.`,
|
||||
inputSchema: {
|
||||
shell_id: z
|
||||
.string()
|
||||
.describe(`The id of the background bash command as returned by \`${toolNames.bash}\``),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const bgTerm = agent.backgroundTerminals[input.shell_id];
|
||||
|
||||
if (!bgTerm) {
|
||||
throw new Error(`Unknown shell ${input.shell_id}`);
|
||||
}
|
||||
|
||||
switch (bgTerm.status) {
|
||||
case "started": {
|
||||
await bgTerm.handle.kill();
|
||||
const currentOutput = await bgTerm.handle.currentOutput();
|
||||
agent.backgroundTerminals[bgTerm.handle.id] = {
|
||||
status: "killed",
|
||||
pendingOutput: {
|
||||
...currentOutput,
|
||||
output: stripCommonPrefix(bgTerm.lastOutput?.output ?? "", currentOutput.output),
|
||||
},
|
||||
};
|
||||
await bgTerm.handle.release();
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Command killed successfully." }],
|
||||
};
|
||||
}
|
||||
case "aborted":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command aborted by user." }],
|
||||
};
|
||||
case "exited":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command had already exited." }],
|
||||
};
|
||||
case "killed":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command was already killed." }],
|
||||
};
|
||||
case "timedOut":
|
||||
return {
|
||||
content: [{ type: "text", text: "Command killed by timeout." }],
|
||||
};
|
||||
default: {
|
||||
return unreachable(bgTerm);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const UNQUALIFIED_PERMISSION_TOOL_NAME = "permission";
|
||||
const PERMISSION_SERVER_PREFIX = "mcp__acpPermission__";
|
||||
export const PERMISSION_TOOL_NAME = PERMISSION_SERVER_PREFIX + UNQUALIFIED_PERMISSION_TOOL_NAME;
|
||||
|
||||
/**
|
||||
* Separate permission tool. Needs to be an HTTP server for now, because Claude Code doesn't
|
||||
* support permission tools via SDK servers.
|
||||
* Ideally it moves to the `canUseTool` callback in the future.
|
||||
*/
|
||||
export function createPermissionMcpServer(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
): Promise<Server> {
|
||||
const server = new McpServer(
|
||||
{ name: "acpPermission", version: "1.0.0" },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
const alwaysAllowedTools: { [key: string]: boolean } = {};
|
||||
server.registerTool(
|
||||
UNQUALIFIED_PERMISSION_TOOL_NAME,
|
||||
{
|
||||
title: "Permission Tool",
|
||||
description: "Used to request tool permissions",
|
||||
inputSchema: {
|
||||
tool_name: z.string(),
|
||||
input: z.any(),
|
||||
tool_use_id: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (input) => {
|
||||
const result = await canUseTool(input);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
async function canUseTool(input: {
|
||||
tool_use_id?: string;
|
||||
tool_name: string;
|
||||
input?: any;
|
||||
}): Promise<PermissionResult> {
|
||||
const session = agent.sessions[sessionId];
|
||||
if (!session) {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.tool_name === "ExitPlanMode") {
|
||||
const response = await agent.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Yes, and auto-accept edits",
|
||||
optionId: "acceptEdits",
|
||||
},
|
||||
{ kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" },
|
||||
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: input.tool_use_id!,
|
||||
rawInput: input.input,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
|
||||
) {
|
||||
session.permissionMode = response.outcome.optionId;
|
||||
await agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: response.outcome.optionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
updatedPermissions: [
|
||||
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User rejected request to exit plan mode.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
session.permissionMode === "bypassPermissions" ||
|
||||
(session.permissionMode === "acceptEdits" && editToolNames.includes(input.tool_name)) ||
|
||||
alwaysAllowedTools[input.tool_name]
|
||||
) {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await agent.client.requestPermission({
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
optionId: "allow_always",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
{ kind: "reject_once", name: "Reject", optionId: "reject" },
|
||||
],
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: input.tool_use_id!,
|
||||
rawInput: input.input,
|
||||
},
|
||||
});
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always")
|
||||
) {
|
||||
if (response.outcome.optionId === "allow_always") {
|
||||
alwaysAllowedTools[input.tool_name] = true;
|
||||
}
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input.input,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.post("/mcp", async (req, res) => {
|
||||
try {
|
||||
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
server.close();
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Internal server error: ${error}`,
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const listener = app.listen(0, "127.0.0.1", (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(listener);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stripCommonPrefix(a: string, b: string): string {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
i++;
|
||||
}
|
||||
return b.slice(i);
|
||||
}
|
||||
|
||||
function toolCommandOutput(
|
||||
status: "started" | "aborted" | "exited" | "killed" | "timedOut",
|
||||
output: TerminalOutputResponse,
|
||||
): string {
|
||||
const { exitStatus, output: commandOutput, truncated } = output;
|
||||
|
||||
let toolOutput = "";
|
||||
|
||||
switch (status) {
|
||||
case "started":
|
||||
case "exited": {
|
||||
if (exitStatus && (exitStatus.exitCode ?? null) === null) {
|
||||
toolOutput += `Interrupted by the user. `;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "killed":
|
||||
toolOutput += `Killed. `;
|
||||
break;
|
||||
case "timedOut":
|
||||
toolOutput += `Timed out. `;
|
||||
break;
|
||||
case "aborted":
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = status;
|
||||
return unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
if (exitStatus) {
|
||||
if (typeof exitStatus.exitCode === "number") {
|
||||
toolOutput += `Exited with code ${exitStatus.exitCode}.`;
|
||||
}
|
||||
|
||||
if (typeof exitStatus.signal === "string") {
|
||||
toolOutput += `Signal \`${exitStatus.signal}\`. `;
|
||||
}
|
||||
|
||||
toolOutput += "Final output:\n\n";
|
||||
} else {
|
||||
toolOutput += "New output:\n\n";
|
||||
}
|
||||
|
||||
toolOutput += commandOutput;
|
||||
|
||||
if (truncated) {
|
||||
toolOutput += `\n\nCommand output was too long, so it was truncated to ${commandOutput.length} bytes.`;
|
||||
}
|
||||
|
||||
return toolOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace text in a file and calculate the line numbers where the edits occurred.
|
||||
*
|
||||
* @param fileContent - The full file content
|
||||
* @param edits - Array of edit operations to apply sequentially
|
||||
* @returns the new content and the line numbers where replacements occurred in the final content
|
||||
*/
|
||||
export function replaceAndCalculateLocation(
|
||||
fileContent: string,
|
||||
edits: Array<{
|
||||
oldText: string;
|
||||
newText: string;
|
||||
replaceAll?: boolean;
|
||||
}>,
|
||||
): { newContent: string; lineNumbers: number[] } {
|
||||
let currentContent = fileContent;
|
||||
|
||||
// Use unique markers to track where replacements happen
|
||||
const markerPrefix = `__REPLACE_MARKER_${Math.random().toString(36).substr(2, 9)}_`;
|
||||
let markerCounter = 0;
|
||||
const markers: string[] = [];
|
||||
|
||||
// Apply edits sequentially, inserting markers at replacement positions
|
||||
for (const edit of edits) {
|
||||
// Skip empty oldText
|
||||
if (edit.oldText === "") {
|
||||
throw new Error(`The provided \`old_string\` is empty.\n\nNo edits were applied.`);
|
||||
}
|
||||
|
||||
if (edit.replaceAll) {
|
||||
// Replace all occurrences with marker + newText
|
||||
const parts: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let searchIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const index = currentContent.indexOf(edit.oldText, searchIndex);
|
||||
if (index === -1) {
|
||||
if (searchIndex === 0) {
|
||||
throw new Error(
|
||||
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Add content before the match
|
||||
parts.push(currentContent.substring(lastIndex, index));
|
||||
|
||||
// Add marker and replacement
|
||||
const marker = `${markerPrefix}${markerCounter++}__`;
|
||||
markers.push(marker);
|
||||
parts.push(marker + edit.newText);
|
||||
|
||||
lastIndex = index + edit.oldText.length;
|
||||
searchIndex = lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining content
|
||||
parts.push(currentContent.substring(lastIndex));
|
||||
currentContent = parts.join("");
|
||||
} else {
|
||||
// Replace first occurrence only
|
||||
const index = currentContent.indexOf(edit.oldText);
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
`The provided \`old_string\` does not appear in the file: "${edit.oldText}".\n\nNo edits were applied.`,
|
||||
);
|
||||
} else {
|
||||
const marker = `${markerPrefix}${markerCounter++}__`;
|
||||
markers.push(marker);
|
||||
currentContent =
|
||||
currentContent.substring(0, index) +
|
||||
marker +
|
||||
edit.newText +
|
||||
currentContent.substring(index + edit.oldText.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find line numbers where markers appear in the content
|
||||
const lineNumbers: number[] = [];
|
||||
for (const marker of markers) {
|
||||
const index = currentContent.indexOf(marker);
|
||||
if (index !== -1) {
|
||||
const lineNumber = Math.max(
|
||||
0,
|
||||
currentContent.substring(0, index).split(/\r\n|\r|\n/).length - 1,
|
||||
);
|
||||
lineNumbers.push(lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all markers from the final content
|
||||
let finalContent = currentContent;
|
||||
for (const marker of markers) {
|
||||
finalContent = finalContent.replace(marker, "");
|
||||
}
|
||||
|
||||
// Dedupe and sort line numbers
|
||||
const uniqueLineNumbers = [...new Set(lineNumbers)].sort();
|
||||
|
||||
return { newContent: finalContent, lineNumbers: uniqueLineNumbers };
|
||||
}
|
||||
313
src/settings.ts
Normal file
313
src/settings.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
* Permission rule format examples:
|
||||
* - "Read" - matches all Read tool calls
|
||||
* - "Read(./.env)" - matches specific path
|
||||
* - "Read(./.env.*)" - glob pattern
|
||||
* - "Read(./secrets/**)" - recursive glob
|
||||
* - "Bash(npm run lint)" - exact command prefix
|
||||
* - "Bash(npm run:*)" - command prefix with wildcard
|
||||
*
|
||||
* 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 {
|
||||
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:
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsManagerOptions {
|
||||
onChange?: () => void;
|
||||
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Claude Code settings from multiple sources with proper precedence.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export class SettingsManager {
|
||||
private cwd: string;
|
||||
private userSettings: ClaudeCodeSettings = {};
|
||||
private projectSettings: ClaudeCodeSettings = {};
|
||||
private localSettings: ClaudeCodeSettings = {};
|
||||
private enterpriseSettings: ClaudeCodeSettings = {};
|
||||
private mergedSettings: ClaudeCodeSettings = {};
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
private initialized = false;
|
||||
private disposed = false;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(cwd: string, options?: SettingsManagerOptions) {
|
||||
this.cwd = cwd;
|
||||
this.onChange = options?.onChange;
|
||||
this.logger = options?.logger ?? console;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the settings manager by loading all settings and setting up file watchers
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
this.disposed = false;
|
||||
this.initPromise = this.loadAllSettings().then(() => {
|
||||
if (!this.disposed) {
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
}
|
||||
this.initPromise = null;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the user settings file
|
||||
*/
|
||||
private getUserSettingsPath(): string {
|
||||
return path.join(CLAUDE_CONFIG_DIR, "settings.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
|
||||
if (fs.existsSync(dir)) {
|
||||
const watcher = fs.watch(dir, (eventType, changedFilename) => {
|
||||
if (changedFilename === filename) {
|
||||
this.handleSettingsChange();
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.error(`Settings watcher error for ${filePath}:`, error);
|
||||
});
|
||||
|
||||
this.watchers.push(watcher);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to set up watcher for ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles settings file changes with debouncing to avoid rapid reloads
|
||||
*/
|
||||
private handleSettingsChange(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
this.debounceTimer = null;
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.loadAllSettings();
|
||||
if (!this.disposed) {
|
||||
this.onChange?.();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to reload settings:", error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current merged settings
|
||||
*/
|
||||
getSettings(): ClaudeCodeSettings {
|
||||
return this.mergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current working directory
|
||||
*/
|
||||
getCwd(): string {
|
||||
return this.cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the working directory and reloads project-specific settings
|
||||
*/
|
||||
async setCwd(cwd: string): Promise<void> {
|
||||
if (this.cwd === cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispose();
|
||||
this.cwd = cwd;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of file watchers and cleans up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.initialized = false;
|
||||
this.initPromise = null;
|
||||
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
|
||||
for (const watcher of this.watchers) {
|
||||
watcher.close();
|
||||
}
|
||||
this.watchers = [];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: Say hello
|
||||
argument-hint: [name]
|
||||
argument-hint: name
|
||||
---
|
||||
|
||||
Respond with "Hello $1" and nothing else.
|
||||
|
||||
249
src/tests/acp-agent-settings.test.ts
Normal file
249
src/tests/acp-agent-settings.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({
|
||||
querySpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<any>("@anthropic-ai/claude-agent-sdk");
|
||||
return {
|
||||
...actual,
|
||||
query: querySpy,
|
||||
};
|
||||
});
|
||||
|
||||
describe("ClaudeAcpAgent settings", () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
function mockQuery() {
|
||||
let capturedOptions: any;
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
return { getCapturedOptions: () => capturedOptions, setModelSpy };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-agent-settings-"));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
||||
querySpy.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalClaudeConfigDir) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode for new sessions", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "dontAsk",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
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("dontAsk");
|
||||
expect(getCapturedOptions().settingSources).toEqual(["user", "project", "local"]);
|
||||
expect(response.modes.currentModeId).toBe("dontAsk");
|
||||
});
|
||||
|
||||
it("supports acceptEdits mode defaults", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "acceptEdits",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
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("acceptEdits");
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
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("falls back to 'default' when permissions.defaultMode is invalid", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "not-a-real-mode",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
// Bad mode is ignored at the usage site; session creation must not throw.
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("ignores model from settings when it is not a string", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
model: 123,
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { setModelSpy } = 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 },
|
||||
});
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
it("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
model: "opus[1m]",
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options: _options }: any) => {
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-opus-4-6",
|
||||
displayName: "Claude Opus 4.6",
|
||||
description: "Base",
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-6-1m",
|
||||
displayName: "Claude Opus 4.6 (1M)",
|
||||
description: "Long context",
|
||||
},
|
||||
],
|
||||
}),
|
||||
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-opus-4-6-1m");
|
||||
expect(response.models.currentModelId).toBe("claude-opus-4-6-1m");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
71
src/tests/additional-roots.test.ts
Normal file
71
src/tests/additional-roots.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: ({ options }: { options: Options }) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" }],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("additionalRoots", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
const tempDirs: string[] = [];
|
||||
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
|
||||
agent.newSession({ cwd, mcpServers: [], _meta: meta });
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedOptions = undefined;
|
||||
tempDirs.length = 0;
|
||||
vi.resetModules();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection);
|
||||
});
|
||||
|
||||
afterEach(
|
||||
async () =>
|
||||
void (await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))),
|
||||
);
|
||||
|
||||
it("passes through relative roots as provided", async () => {
|
||||
const projectRoot = await mkdtemp(path.join(os.tmpdir(), "claude-project-"));
|
||||
tempDirs.push(projectRoot);
|
||||
await newSession({ additionalRoots: ["."] }, projectRoot);
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["."]);
|
||||
});
|
||||
|
||||
it("merges additionalRoots with user additionalDirectories without normalization", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "claude-root-"));
|
||||
tempDirs.push(root);
|
||||
await newSession({
|
||||
additionalRoots: ["", root],
|
||||
claudeCode: { options: { additionalDirectories: ["/workspace/shared"] } },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]);
|
||||
});
|
||||
});
|
||||
245
src/tests/authorization.test.ts
Normal file
245
src/tests/authorization.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
initializationResult: vi.fn().mockResolvedValue({
|
||||
models: [{ value: "id", displayName: "name", description: "description" }],
|
||||
}),
|
||||
setModel: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
describe("authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
//await all pending events like
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
|
||||
const connectionMock = {
|
||||
sessionUpdate: async (_: any) => {},
|
||||
} as AgentSideConnection;
|
||||
|
||||
const agent = new ClaudeAcpAgent(connectionMock);
|
||||
|
||||
return [agent, mockQuery];
|
||||
}
|
||||
|
||||
it("gateway auth not offered without capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gateway auth offered when client advertises auth._meta.gateway capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses gateway env after gateway auth", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { terminal: true, _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
|
||||
await agent.authenticate({
|
||||
methodId: "gateway",
|
||||
_meta: { gateway: { baseUrl: "https://gateway.example", headers: { "x-api-key": "test" } } },
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
userEnv: "userEnv",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
|
||||
userEnv: "userEnv",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude authentication without terminal-auth", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude auth but still show console login when terminal-auth is set", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
_meta: { "terminal-auth": true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude auth but still show console login with terminal capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { terminal: true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("SSH session falls back to single legacy login method", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, env: { ...process.env, SSH_TTY: "/dev/pts/0" } });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("CLAUDE_CODE_REMOTE falls back to single legacy login method", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, env: { ...process.env, CLAUDE_CODE_REMOTE: "1" } });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("remote environment respects hide-claude-auth", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", {
|
||||
...process,
|
||||
argv: ["--hide-claude-auth"],
|
||||
env: { ...process.env, SSH_CONNECTION: "192.168.1.1 12345 192.168.1.2 22" },
|
||||
});
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("show claude authentication", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
470
src/tests/create-session-options.test.ts
Normal file
470
src/tests/create-session-options.test.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
query: (args: { prompt: unknown; options: Options }) => {
|
||||
capturedOptions = args.options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" },
|
||||
],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../tools.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
|
||||
return {
|
||||
...actual,
|
||||
registerHookCallback: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("createSession options merging", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedOptions = undefined;
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
});
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
disallowedTools: ["WebSearch", "WebFetch"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User-provided tools should be present
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebFetch");
|
||||
// ACP's internal disallowed tool should also be present
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("works when user provides no disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("works when user provides empty disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
disallowedTools: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("sets tools to empty array when disableBuiltInTools is true", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
claudeCode: {
|
||||
options: {
|
||||
disallowedTools: ["CustomTool"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// disableBuiltInTools removes all built-in tools from context
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
// User-provided and ACP disallowedTools still apply
|
||||
expect(capturedOptions!.disallowedTools).toContain("CustomTool");
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("merges user-provided hooks with ACP hooks", async () => {
|
||||
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
hooks: {
|
||||
PreToolUse: [userPreToolUseHook],
|
||||
PostToolUse: [{ hooks: [{ command: "echo user-post" }] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User's PreToolUse hooks should be preserved
|
||||
expect(capturedOptions!.hooks?.PreToolUse).toEqual([userPreToolUseHook]);
|
||||
// PostToolUse should contain both user and ACP hooks
|
||||
expect(capturedOptions!.hooks?.PostToolUse).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("inherits HOME and PATH from process.env when no env is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
|
||||
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
|
||||
});
|
||||
|
||||
it("merges user-provided env vars on top of process.env", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
CUSTOM_VAR: "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
|
||||
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
|
||||
expect(capturedOptions?.env?.CUSTOM_VAR).toBe("custom-value");
|
||||
});
|
||||
|
||||
it("allows user-provided env vars to override process.env entries", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
HOME: "/custom/home",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe("/custom/home");
|
||||
});
|
||||
|
||||
it("defaults tools to claude_code preset when not provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual({ type: "preset", preset: "claude_code" });
|
||||
});
|
||||
|
||||
it("passes through user-provided tools string array", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: ["Read", "Glob"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
|
||||
});
|
||||
|
||||
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: ["Read", "Glob"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
|
||||
});
|
||||
|
||||
it("passes through empty tools array to disable all built-in tools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
});
|
||||
|
||||
describe("systemPrompt via _meta", () => {
|
||||
it("defaults to the claude_code preset when not provided", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces the preset when a string is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: "custom prompt" },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toBe("custom prompt");
|
||||
});
|
||||
|
||||
it("forwards append", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { append: "extra instructions" } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: "extra instructions",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards excludeDynamicSections", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { excludeDynamicSections: true } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
excludeDynamicSections: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards append and excludeDynamicSections together", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
append: "extra instructions",
|
||||
excludeDynamicSections: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: "extra instructions",
|
||||
excludeDynamicSections: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores caller-provided type/preset overrides", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
type: "something-else",
|
||||
preset: "other-preset",
|
||||
append: "extra",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: "extra",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLAUDE_MODEL_CONFIG", () => {
|
||||
let originalModelConfig: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalModelConfig = process.env.CLAUDE_MODEL_CONFIG;
|
||||
delete process.env.CLAUDE_MODEL_CONFIG;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalModelConfig !== undefined) {
|
||||
process.env.CLAUDE_MODEL_CONFIG = originalModelConfig;
|
||||
} else {
|
||||
delete process.env.CLAUDE_MODEL_CONFIG;
|
||||
}
|
||||
});
|
||||
|
||||
it("passes modelOverrides as settings", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes availableModels as settings", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
availableModels: ["opus", "sonnet"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
availableModels: ["opus", "sonnet"],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes both modelOverrides and availableModels", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add settings when env var is not set", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores env var when _meta provides settings", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
settings: {
|
||||
model: "claude-sonnet-4-5",
|
||||
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// _meta settings take precedence; env var is ignored entirely
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
model: "claude-sonnet-4-5",
|
||||
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on invalid JSON", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = "not-json";
|
||||
|
||||
await expect(agent.newSession({ cwd: "/test", mcpServers: [] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [
|
||||
{
|
||||
name: "acp-server",
|
||||
command: "node",
|
||||
args: ["acp-server.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
mcpServers: {
|
||||
"user-server": {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User-provided MCP server should be present
|
||||
expect(capturedOptions!.mcpServers).toHaveProperty("user-server");
|
||||
// ACP-provided MCP server should also be present
|
||||
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractLinesWithByteLimit } from "../utils.js";
|
||||
|
||||
describe("extractLinesWithByteLimit", () => {
|
||||
const simpleContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
|
||||
|
||||
it("should extract all lines when under byte limit", () => {
|
||||
const result = extractLinesWithByteLimit(simpleContent, 1000);
|
||||
|
||||
expect(result.content).toBe(simpleContent);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(5);
|
||||
});
|
||||
|
||||
it("should limit output when exceeding byte limit", () => {
|
||||
// Create content that will exceed byte limit
|
||||
const longLine = "x".repeat(100);
|
||||
const manyLines = Array(10).fill(longLine).join("\n");
|
||||
|
||||
const result = extractLinesWithByteLimit(manyLines, 250);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const result = extractLinesWithByteLimit("", 1000);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(1); // We read the one empty line
|
||||
});
|
||||
|
||||
it("should handle single line file", () => {
|
||||
const singleLine = "This is a single line without newline";
|
||||
const result = extractLinesWithByteLimit(singleLine, 1000);
|
||||
|
||||
expect(result.content).toBe(singleLine);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(1);
|
||||
});
|
||||
|
||||
it("should correctly count bytes with multi-byte characters", () => {
|
||||
const unicodeContent = "Hello 世界\n你好 World\nEmoji: 🌍\nNormal line";
|
||||
const result = extractLinesWithByteLimit(unicodeContent, 1000);
|
||||
|
||||
expect(result.content).toBe(unicodeContent);
|
||||
expect(result.linesRead).toBe(4);
|
||||
});
|
||||
|
||||
it("should stop at byte limit even with one more line available", () => {
|
||||
// Create lines where adding one more would exceed limit
|
||||
const line1 = "a".repeat(40);
|
||||
const line2 = "b".repeat(40);
|
||||
const line3 = "c".repeat(40);
|
||||
const content = `${line1}\n${line2}\n${line3}`;
|
||||
|
||||
const result = extractLinesWithByteLimit(content, 85);
|
||||
|
||||
expect(result.content).toBe(`${line1}\n${line2}\n`);
|
||||
expect(result.wasLimited).toBe(true);
|
||||
expect(result.linesRead).toBe(2);
|
||||
});
|
||||
|
||||
it("should read exactly to limit when possible", () => {
|
||||
const exactContent = "12345\n67890\n12345"; // 17 bytes total
|
||||
const result = extractLinesWithByteLimit(exactContent, 17);
|
||||
|
||||
expect(result.content).toBe(exactContent);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
expect(result.linesRead).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle Windows-style line endings", () => {
|
||||
const windowsContent = "Line 1\r\nLine 2\r\nLine 3";
|
||||
const result = extractLinesWithByteLimit(windowsContent, 1000);
|
||||
|
||||
// Note: split("\n") will keep the \r characters
|
||||
expect(result.content).toBe("Line 1\r\nLine 2\r\nLine 3");
|
||||
expect(result.linesRead).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle very large files efficiently", () => {
|
||||
// Create a 100KB file
|
||||
const largeLine = "x".repeat(1000);
|
||||
const largeContent = Array(110).fill(largeLine).join("\n");
|
||||
|
||||
const result = extractLinesWithByteLimit(largeContent, 50000);
|
||||
|
||||
expect(result.wasLimited).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow at least one line even if it exceeds byte limit", () => {
|
||||
const veryLongLine = "x".repeat(100000); // 100KB line
|
||||
const result = extractLinesWithByteLimit(veryLongLine, 50000);
|
||||
|
||||
// Should return the line even though it exceeds the byte limit
|
||||
// because we always allow at least one line if no lines have been added yet
|
||||
expect(result.content).toBe(veryLongLine);
|
||||
expect(result.linesRead).toBe(1);
|
||||
expect(result.wasLimited).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,334 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { replaceAndCalculateLocation } from "../mcp-server.js";
|
||||
|
||||
describe("replaceAndCalculateLocation", () => {
|
||||
it("should replace first occurrence and return correct line number", () => {
|
||||
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with text\nline 4");
|
||||
expect(result.lineNumbers).toEqual([1]); // Line 2 (0-based indexing)
|
||||
});
|
||||
|
||||
it("should replace all occurrences when replaceAll is true", () => {
|
||||
const content = "line 1\nline 2 with text\nline 3 with text\nline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\nline 3 with replaced\nline 4");
|
||||
expect(result.lineNumbers).toEqual([1, 2]); // Lines 2 and 3 (0-based)
|
||||
});
|
||||
|
||||
it("should return empty line numbers array when no match found", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content, [{ oldText: "notfound", newText: "replaced" }]);
|
||||
}).toThrow('The provided `old_string` does not appear in the file: "notfound"');
|
||||
});
|
||||
|
||||
it("should handle Windows line endings (CRLF)", () => {
|
||||
const content = "line 1\r\nline 2 with text\r\nline 3 with text\r\nline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe(
|
||||
"line 1\r\nline 2 with replaced\r\nline 3 with replaced\r\nline 4",
|
||||
);
|
||||
expect(result.lineNumbers).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle old Mac line endings (CR)", () => {
|
||||
const content = "line 1\rline 2 with text\rline 3 with text\rline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\rline 2 with replaced\rline 3 with replaced\rline 4");
|
||||
expect(result.lineNumbers).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle mixed line endings", () => {
|
||||
const content = "line 1\nline 2 with text\r\nline 3 with text\rline 4";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2 with replaced\r\nline 3 with replaced\rline 4");
|
||||
expect(result.lineNumbers).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("should handle text at the beginning of file", () => {
|
||||
const content = "text at start\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("replaced at start\nline 2\nline 3");
|
||||
expect(result.lineNumbers).toEqual([0]); // First line (0-based)
|
||||
});
|
||||
|
||||
it("should handle text at the end of file", () => {
|
||||
const content = "line 1\nline 2\nlast line with text";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2\nlast line with replaced");
|
||||
expect(result.lineNumbers).toEqual([2]); // Last line (0-based)
|
||||
});
|
||||
|
||||
it("should handle multiple occurrences on the same line", () => {
|
||||
const content = "line 1\ntext text text\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nreplaced replaced replaced\nline 3");
|
||||
expect(result.lineNumbers).toEqual([1]); // All on line 2 (0-based), deduplicated
|
||||
});
|
||||
|
||||
it("should handle empty file content", () => {
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation("", [{ oldText: "text", newText: "replaced" }]);
|
||||
}).toThrowError(
|
||||
'The provided `old_string` does not appear in the file: "text".\n\nNo edits were applied.',
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty search text", () => {
|
||||
// Test with replaceAll false (default)
|
||||
const content1 = "line 1\nline 2\nline 3";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content1, [{ oldText: "", newText: "replaced" }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on single line
|
||||
const content2 = "abc";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content2, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
|
||||
// Test with replaceAll true on multiline content
|
||||
const content3 = "ab\ncd";
|
||||
expect(() => {
|
||||
replaceAndCalculateLocation(content3, [{ oldText: "", newText: "X", replaceAll: true }]);
|
||||
}).toThrowError("The provided `old_string` is empty.\n\nNo edits were applied.");
|
||||
});
|
||||
|
||||
it("should handle single line content", () => {
|
||||
const content = "single line with text here";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("single line with replaced here");
|
||||
expect(result.lineNumbers).toEqual([0]); // Line 1 (0-based)
|
||||
});
|
||||
|
||||
it("should handle special characters in search text", () => {
|
||||
const content = "line 1\nline with $special.chars*\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "$special.chars*", newText: "replaced" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline with replaced\nline 3");
|
||||
expect(result.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle newlines in search text", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "1\nline", newText: "1\nmodified line" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nmodified line 2\nline 3");
|
||||
expect(result.lineNumbers).toEqual([0]); // Match starts at line 1 (0-based)
|
||||
});
|
||||
|
||||
it("should handle newlines in replacement text", () => {
|
||||
const content = "line 1\nline 2\nline 3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "line 2", newText: "line 2a\nline 2b" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nline 2a\nline 2b\nline 3");
|
||||
expect(result.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle very long lines", () => {
|
||||
const longLine = "a".repeat(10000) + "text" + "b".repeat(10000);
|
||||
const content = `line 1\n${longLine}\nline 3`;
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe(
|
||||
`line 1\n${"a".repeat(10000)}replaced${"b".repeat(10000)}\nline 3`,
|
||||
);
|
||||
expect(result.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle many lines", () => {
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => `line ${i}`);
|
||||
lines[500] = "line with text";
|
||||
const content = lines.join("\n");
|
||||
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.lineNumbers).toEqual([500]);
|
||||
expect(result.newContent).includes("line with replaced");
|
||||
});
|
||||
|
||||
it("should handle overlapping matches correctly", () => {
|
||||
const content = "aaaa\nbbbb\ncccc";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "aa", newText: "xx", replaceAll: true },
|
||||
]);
|
||||
|
||||
// Should replace non-overlapping occurrences
|
||||
expect(result.newContent).toBe("xxxx\nbbbb\ncccc");
|
||||
expect(result.lineNumbers).toEqual([0]); // Two matches on first line, deduplicated
|
||||
});
|
||||
|
||||
it("should preserve content after replacement with different lengths", () => {
|
||||
const content = "short\nmedium line\nlong line here";
|
||||
|
||||
// Replacing with longer text
|
||||
const result1 = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "short", newText: "very very long text" },
|
||||
]);
|
||||
expect(result1.newContent).toBe("very very long text\nmedium line\nlong line here");
|
||||
expect(result1.lineNumbers).toEqual([0]);
|
||||
|
||||
// Replacing with shorter text
|
||||
const result2 = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "medium line", newText: "m" },
|
||||
]);
|
||||
expect(result2.newContent).toBe("short\nm\nlong line here");
|
||||
expect(result2.lineNumbers).toEqual([1]);
|
||||
});
|
||||
|
||||
it("should handle consecutive replacements", () => {
|
||||
const content = "text1 text2 text3";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("replaced1 replaced2 replaced3");
|
||||
expect(result.lineNumbers).toEqual([0]); // All on first line, deduplicated
|
||||
});
|
||||
|
||||
it("should handle case-sensitive replacements", () => {
|
||||
const content = "Text TEXT text";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "text", newText: "replaced", replaceAll: true },
|
||||
]);
|
||||
|
||||
// Should only replace exact matches
|
||||
expect(result.newContent).toBe("Text TEXT replaced");
|
||||
expect(result.lineNumbers).toEqual([0]); // Only lowercase "text" matched
|
||||
});
|
||||
|
||||
it("should handle replacement at exact end of line", () => {
|
||||
const content = "line ends with text\nnext line";
|
||||
const result = replaceAndCalculateLocation(content, [{ oldText: "text", newText: "replaced" }]);
|
||||
|
||||
expect(result.newContent).toBe("line ends with replaced\nnext line");
|
||||
expect(result.lineNumbers).toEqual([0]);
|
||||
});
|
||||
|
||||
it("should handle replacement spanning multiple lines", () => {
|
||||
const content = "line 1\nspan\nacross\nlines\nline 5";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "span\nacross\nlines", newText: "single" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nsingle\nline 5");
|
||||
expect(result.lineNumbers).toEqual([1]); // Match starts at line 2 (0-based)
|
||||
});
|
||||
|
||||
it("should default replaceAll to false", () => {
|
||||
const content = "match match match";
|
||||
|
||||
// Without passing replaceAll parameter
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "match", newText: "replaced" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("replaced match match");
|
||||
expect(result.lineNumbers).toEqual([0]); // Only first match
|
||||
});
|
||||
|
||||
it("should handle multiple edits with correct line numbers", () => {
|
||||
const content = "line 1\nfoo bar\nline 3\nfoo baz\nline 5\nbar foo\nline 7";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "foo", newText: "FOO", replaceAll: true },
|
||||
{ oldText: "bar", newText: "BAR", replaceAll: false },
|
||||
{ oldText: "line 3", newText: "LINE THREE" },
|
||||
]);
|
||||
|
||||
// Should replace:
|
||||
// - All "foo" occurrences (lines 1, 3, 5 in original)
|
||||
// - First "bar" occurrence (line 1 after first edit)
|
||||
// - "line 3" (line 2 after previous edits)
|
||||
expect(result.newContent).toBe("line 1\nFOO BAR\nLINE THREE\nFOO baz\nline 5\nbar FOO\nline 7");
|
||||
|
||||
// Line numbers reflect position in final content, deduplicated and sorted
|
||||
expect(result.lineNumbers).toEqual([
|
||||
1, // FOO and BAR on line 1
|
||||
2, // LINE THREE on line 2
|
||||
3, // FOO on line 3
|
||||
5, // FOO on line 5
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle multiple edits with overlapping text", () => {
|
||||
const content = "hello world\nhello hello\nworld hello world";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "hello", newText: "hi", replaceAll: false },
|
||||
{ oldText: "world", newText: "earth", replaceAll: true },
|
||||
{ oldText: "hello", newText: "greetings", replaceAll: true },
|
||||
]);
|
||||
|
||||
// First edit replaces first "hello" only
|
||||
// Second edit replaces all "world" in the modified content
|
||||
// Third edit replaces remaining "hello"s in the modified content
|
||||
expect(result.newContent).toBe("hi earth\ngreetings greetings\nearth greetings earth");
|
||||
|
||||
// Line numbers reflect position in final content, deduplicated and sorted
|
||||
expect(result.lineNumbers).toEqual([
|
||||
0, // hi and earth on line 0
|
||||
1, // greetings (multiple) on line 1
|
||||
2, // earth and greetings on line 2
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle complex scenarios with marker-like text", () => {
|
||||
// Test that our marker approach doesn't get confused by similar text
|
||||
const content = "line 1\n__MARKER__text\nline 3\n__REPLACE_MARKER_abc__\nline 5";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "__MARKER__", newText: "REPLACED", replaceAll: true },
|
||||
{ oldText: "text", newText: "content" },
|
||||
{ oldText: "__REPLACE_MARKER_abc__", newText: "DONE" },
|
||||
]);
|
||||
|
||||
expect(result.newContent).toBe("line 1\nREPLACEDcontent\nline 3\nDONE\nline 5");
|
||||
|
||||
// Line numbers should be correct even with marker-like text
|
||||
expect(result.lineNumbers).toEqual([
|
||||
1, // REPLACED on line 1
|
||||
3, // DONE on line 3
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle edits where newText matches subsequent oldText", () => {
|
||||
const content = "foo\nbar\nbaz";
|
||||
const result = replaceAndCalculateLocation(content, [
|
||||
{ oldText: "foo", newText: "bar" },
|
||||
{ oldText: "bar", newText: "baz", replaceAll: true },
|
||||
{ oldText: "baz", newText: "qux", replaceAll: true },
|
||||
]);
|
||||
|
||||
// First edit: foo -> bar
|
||||
// Second edit: both original bar and new bar -> baz
|
||||
// Third edit: all baz instances -> qux
|
||||
expect(result.newContent).toBe("qux\nqux\nqux");
|
||||
|
||||
// All replacements end up on their respective lines
|
||||
expect(result.lineNumbers).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
57
src/tests/resolve-permission-mode.test.ts
Normal file
57
src/tests/resolve-permission-mode.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { resolvePermissionMode, type Logger } from "../acp-agent.js";
|
||||
|
||||
function mockLogger() {
|
||||
const error = vi.fn<(...args: any[]) => void>();
|
||||
const log = vi.fn<(...args: any[]) => void>();
|
||||
const logger: Logger = { log, error };
|
||||
return { logger, error, log };
|
||||
}
|
||||
|
||||
describe("resolvePermissionMode", () => {
|
||||
it("returns 'default' when no mode is provided", () => {
|
||||
expect(resolvePermissionMode()).toBe("default");
|
||||
expect(resolvePermissionMode(undefined)).toBe("default");
|
||||
});
|
||||
|
||||
it("resolves exact canonical modes", () => {
|
||||
expect(resolvePermissionMode("default")).toBe("default");
|
||||
expect(resolvePermissionMode("acceptEdits")).toBe("acceptEdits");
|
||||
expect(resolvePermissionMode("dontAsk")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("plan")).toBe("plan");
|
||||
expect(resolvePermissionMode("bypassPermissions")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("resolves case-insensitive aliases", () => {
|
||||
expect(resolvePermissionMode("DontAsk")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("DONTASK")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("AcceptEdits")).toBe("acceptEdits");
|
||||
expect(resolvePermissionMode("bypass")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
|
||||
});
|
||||
|
||||
it("falls back to 'default' and logs on non-string values", () => {
|
||||
for (const value of [123, true, {}]) {
|
||||
const { logger, error } = mockLogger();
|
||||
expect(resolvePermissionMode(value, logger)).toBe("default");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a string"));
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to 'default' and logs on empty string", () => {
|
||||
for (const value of ["", " "]) {
|
||||
const { logger, error } = mockLogger();
|
||||
expect(resolvePermissionMode(value, logger)).toBe("default");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a non-empty string"));
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to 'default' and logs on unknown mode", () => {
|
||||
const { logger, error } = mockLogger();
|
||||
expect(resolvePermissionMode("yolo", logger)).toBe("default");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("yolo"));
|
||||
});
|
||||
});
|
||||
814
src/tests/session-config-options.test.ts
Normal file
814
src/tests/session-config-options.test.ts
Normal file
@@ -0,0 +1,814 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
const { registerHookCallbackSpy } = vi.hoisted(() => ({
|
||||
registerHookCallbackSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../tools.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
|
||||
return {
|
||||
...actual,
|
||||
registerHookCallback: registerHookCallbackSpy,
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION_ID = "test-session-id";
|
||||
|
||||
const MOCK_MODES = {
|
||||
currentModeId: "default",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Default", description: "Standard behavior" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
],
|
||||
};
|
||||
|
||||
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" },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_CONFIG_OPTIONS = [
|
||||
{
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
type: "select",
|
||||
category: "mode",
|
||||
currentValue: "default",
|
||||
options: MOCK_MODES.availableModes.map((m) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
type: "select",
|
||||
category: "model",
|
||||
currentValue: "claude-opus-4-5",
|
||||
options: MOCK_MODELS.availableModels.map((m) => ({
|
||||
value: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
type: "select",
|
||||
category: "effort",
|
||||
currentValue: "high",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "medium", name: "Medium" },
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("session config options", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
||||
let sessionUpdates: SessionNotification[];
|
||||
let createSessionSpy: ReturnType<typeof vi.fn>;
|
||||
let setPermissionModeSpy: ReturnType<typeof vi.fn>;
|
||||
let setModelSpy: ReturnType<typeof vi.fn>;
|
||||
let applyFlagSettingsSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
sessionUpdates.push(notification);
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
function populateSession() {
|
||||
setPermissionModeSpy = vi.fn();
|
||||
setModelSpy = vi.fn();
|
||||
applyFlagSettingsSpy = vi.fn();
|
||||
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
|
||||
query: {
|
||||
setPermissionMode: setPermissionModeSpy,
|
||||
setModel: setModelSpy,
|
||||
applyFlagSettings: applyFlagSettingsSpy,
|
||||
supportedCommands: async () => [],
|
||||
},
|
||||
input: null,
|
||||
cancelled: false,
|
||||
permissionMode: "default",
|
||||
settingsManager: {},
|
||||
modes: structuredClone(MOCK_MODES),
|
||||
models: structuredClone(MOCK_MODELS),
|
||||
modelInfos: MOCK_MODELS.availableModels.map(
|
||||
(m): ModelInfo => ({
|
||||
value: m.modelId,
|
||||
displayName: m.name,
|
||||
description: m.description,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
}),
|
||||
),
|
||||
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
|
||||
contextWindowSize: 200000,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
sessionUpdates = [];
|
||||
registerHookCallbackSpy.mockClear();
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
createSessionSpy = vi.fn(async () => ({
|
||||
sessionId: SESSION_ID,
|
||||
modes: MOCK_MODES,
|
||||
models: MOCK_MODELS,
|
||||
configOptions: MOCK_CONFIG_OPTIONS,
|
||||
}));
|
||||
(agent as unknown as { createSession: typeof createSessionSpy }).createSession =
|
||||
createSessionSpy;
|
||||
});
|
||||
|
||||
describe("newSession returns configOptions", () => {
|
||||
it("includes configOptions in the response", async () => {
|
||||
const response = await agent.newSession({ cwd: "/test", 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 modeOption = response.configOptions?.find((o) => o.id === "mode");
|
||||
const modelOption = response.configOptions?.find((o) => o.id === "model");
|
||||
expect(modeOption).toBeDefined();
|
||||
expect(modelOption).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSession returns configOptions", () => {
|
||||
it("includes configOptions from createSession", async () => {
|
||||
// loadSession calls findSessionFile first - override the whole method
|
||||
const loadSessionSpy = vi.fn(async () => ({
|
||||
modes: MOCK_MODES,
|
||||
models: MOCK_MODELS,
|
||||
configOptions: MOCK_CONFIG_OPTIONS,
|
||||
}));
|
||||
(agent as unknown as { loadSession: typeof loadSessionSpy }).loadSession = loadSessionSpy;
|
||||
|
||||
const response = await agent.loadSession({
|
||||
cwd: "/test",
|
||||
sessionId: SESSION_ID,
|
||||
mcpServers: [],
|
||||
});
|
||||
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionConfigOption", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("throws when session not found", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: "nonexistent",
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
}),
|
||||
).rejects.toThrow("Session not found");
|
||||
});
|
||||
|
||||
it("throws when config option not found", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "unknown-option",
|
||||
value: "some-value",
|
||||
}),
|
||||
).rejects.toThrow("Unknown config option: unknown-option");
|
||||
});
|
||||
|
||||
it("throws when value is not valid for the option", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "invalid-mode",
|
||||
}),
|
||||
).rejects.toThrow("Invalid value for config option mode: invalid-mode");
|
||||
});
|
||||
|
||||
it("changes mode, sends current_mode_update but not config_option_update", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
});
|
||||
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("plan");
|
||||
|
||||
const modeUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
expect(modeUpdate?.update).toMatchObject({
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "plan",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes model and does not send a config_option_update notification", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves model alias 'opus' to full model ID", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "opus",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-5");
|
||||
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("resolves model alias 'sonnet' to full model ID", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("resolves display name to model ID", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "Claude Sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("still works with exact model ID", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("throws for completely invalid model value", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "gpt-4",
|
||||
}),
|
||||
).rejects.toThrow("Invalid value for config option model: gpt-4");
|
||||
});
|
||||
|
||||
it("returns full configOptions in the response", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
});
|
||||
|
||||
expect(response.configOptions).toHaveLength(MOCK_CONFIG_OPTIONS.length);
|
||||
const modeOption = response.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption?.currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("other options are unchanged when one is updated", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionMode sends config_option_update", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("sends config_option_update when mode is changed via setSessionMode", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "acceptEdits" });
|
||||
|
||||
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: "mode", currentValue: "acceptEdits" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates stored configOptions currentValue when mode changes", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
|
||||
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
const modeOption = session.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption?.currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("does not send config_option_update for an invalid mode", async () => {
|
||||
await expect(
|
||||
agent.setSessionMode({ sessionId: SESSION_ID, modeId: "not-a-mode" as any }),
|
||||
).rejects.toThrow("Invalid Mode");
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unstable_setSessionModel sends config_option_update", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("sends config_option_update when model is changed via setSessionModel", async () => {
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
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" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates stored configOptions currentValue when model changes", async () => {
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
const modelOption = session.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("includes updated effort in config_option_update when model drops effort support", async () => {
|
||||
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"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
expect(effortOption).toBeUndefined();
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined });
|
||||
});
|
||||
|
||||
it("clamps effort in config_option_update 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");
|
||||
if (effortOpt) effortOpt.currentValue = "max";
|
||||
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
];
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
const effortOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
expect(effortOption).toBeDefined();
|
||||
expect(effortOption.currentValue).toBe("high");
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
|
||||
});
|
||||
|
||||
it("preserves effort in config_option_update 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({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
const effortOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
expect(effortOption?.currentValue).toBe("low");
|
||||
// Effort didn't change, so applyFlagSettings should NOT be called
|
||||
expect(applyFlagSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no config_option_update notification when using setSessionConfigOption", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("sends no config_option_update when setting mode via config option", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
});
|
||||
|
||||
const configUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends no config_option_update when setting model via config option", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const configUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionConfigOption for effort", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("calls applyFlagSettings with effortLevel", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
});
|
||||
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "low" });
|
||||
});
|
||||
|
||||
it("updates effort currentValue in returned configOptions", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "medium",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption?.currentValue).toBe("medium");
|
||||
});
|
||||
|
||||
it("throws for invalid effort value", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "turbo",
|
||||
}),
|
||||
).rejects.toThrow("Invalid value for config option effort: turbo");
|
||||
});
|
||||
|
||||
it("does not send config_option_update notification", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
});
|
||||
|
||||
const configUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("other options are unchanged when effort is updated", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
});
|
||||
|
||||
const modeOption = response.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption?.currentValue).toBe("default");
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("effort level and model switch interactions", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("drops effort option when switching to a model without effort support", async () => {
|
||||
// Make sonnet not support effort
|
||||
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"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears effort via applyFlagSettings when switching to a model without effort", async () => {
|
||||
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"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined });
|
||||
});
|
||||
|
||||
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.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
// Remove effort from current config options
|
||||
session.configOptions = session.configOptions.filter((o: any) => o.id !== "effort");
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("clamps effort to valid value when new model has different supported levels", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
// Set current effort to "max" (not supported by sonnet in our mock)
|
||||
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
|
||||
if (effortOpt) effortOpt.currentValue = "max";
|
||||
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
];
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
|
||||
it("preserves effort value when new model supports the same level", async () => {
|
||||
// Set effort to "low"
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
});
|
||||
|
||||
// Switch model — both support "low"
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption?.currentValue).toBe("low");
|
||||
// applyFlagSettings was called once for the effort change, but not again for the model switch
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bidirectional consistency", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("setSessionConfigOption for mode also calls underlying setPermissionMode", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "mode",
|
||||
value: "acceptEdits",
|
||||
});
|
||||
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("acceptEdits");
|
||||
});
|
||||
|
||||
it("setSessionConfigOption for model also calls underlying setModel", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("setSessionMode also syncs configOptions", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
|
||||
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
expect(session.configOptions.find((o) => o.id === "mode")?.currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("setSessionModel also syncs configOptions", async () => {
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-sonnet-4-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
333
src/tests/session-load.test.ts
Normal file
333
src/tests/session-load.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecycle", () => {
|
||||
it("SDK: session created but never prompted has no messages and is not resumable", async () => {
|
||||
// Create a session via the SDK, initialize it, but never send a prompt
|
||||
const sessionId = randomUUID();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
// initializationResult() works without needing a prompt pushed
|
||||
const initResult = await q.initializationResult();
|
||||
expect(initResult).toBeDefined();
|
||||
|
||||
// Close without ever prompting
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
|
||||
// Verify no messages were stored
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
expect(messages).toEqual([]);
|
||||
|
||||
// Verify the session is not resumable
|
||||
const input2 = new Pushable<SDKUserMessage>();
|
||||
const q2 = query({
|
||||
prompt: input2,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
resume: sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(q2.initializationResult()).rejects.toThrow(
|
||||
/No conversation found with session ID/,
|
||||
);
|
||||
|
||||
input2.end();
|
||||
q2.return(undefined);
|
||||
}, 30000);
|
||||
|
||||
it("ACP: loadSession throws resourceNotFound for a non-existent session", async () => {
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
const bogusSessionId = randomUUID();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agent.loadSession({
|
||||
sessionId: bogusSessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toThrow(RequestError);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("ACP: resumeSession throws resourceNotFound for a non-existent session", async () => {
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
const bogusSessionId = randomUUID();
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agent.resumeSession({
|
||||
sessionId: bogusSessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toThrow(RequestError);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("ACP: newSession without prompt, then loadSession on fresh agent throws resourceNotFound", async () => {
|
||||
// Step 1: Create a real session via ACP, never prompt, dispose
|
||||
const agentA = new ClaudeAcpAgent(createMockClient());
|
||||
const { sessionId } = await agentA.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
expect(sessionId).toBeDefined();
|
||||
await agentA.dispose();
|
||||
|
||||
// Step 2: Fresh agent tries to load that session
|
||||
const agentB = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agentB.loadSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toThrow(RequestError);
|
||||
} finally {
|
||||
await agentB.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("ACP: newSession without prompt, then resumeSession on fresh agent throws resourceNotFound", async () => {
|
||||
const agentA = new ClaudeAcpAgent(createMockClient());
|
||||
const { sessionId } = await agentA.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
await agentA.dispose();
|
||||
|
||||
const agentB = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agentB.resumeSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toThrow(RequestError);
|
||||
} finally {
|
||||
await agentB.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Regression test for https://github.com/zed-industries/claude-code-acp/issues/579
|
||||
// The client (Zed) renders its own local slash commands — e.g. `/model` —
|
||||
// by injecting user-message prompts whose text is wrapped in
|
||||
// `<command-name>` / `<local-command-stdout>` markers. The Claude SDK
|
||||
// persists those messages verbatim in the session transcript. Without
|
||||
// filtering, `loadSession` replays the markers as user_message_chunks
|
||||
// ahead of the real prompt, leaking CLI internals into the transcript.
|
||||
it("ACP: loadSession does not replay local-command metadata user messages", async () => {
|
||||
const recordedUserChunks: string[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
if (notification.update.sessionUpdate === "user_message_chunk") {
|
||||
const content = notification.update.content;
|
||||
if (content.type === "text") recordedUserChunks.push(content.text);
|
||||
}
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const commandName =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>";
|
||||
const commandStdout =
|
||||
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>";
|
||||
|
||||
// Step 1: create a session via the SDK, push the marker user messages
|
||||
// followed by a real "hi" prompt. We bypass the ACP agent's prompt loop
|
||||
// so we're only testing what the loadSession replay path does, not how
|
||||
// prompts get routed.
|
||||
const sessionId = randomUUID();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
await q.initializationResult();
|
||||
|
||||
// Zed renders its local slash commands by mixing the marker blocks into
|
||||
// the user's prompt content array alongside the real text. The Claude
|
||||
// SDK persists the message verbatim — including the marker blocks —
|
||||
// which is what loadSession must filter when it replays.
|
||||
input.push({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: commandName },
|
||||
{ type: "text", text: commandStdout },
|
||||
{ type: "text", text: "hi" },
|
||||
],
|
||||
},
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
|
||||
for await (const msg of q) {
|
||||
if (msg.type === "result") break;
|
||||
}
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
|
||||
// Sanity check: the SDK transcript contains the metadata we're filtering.
|
||||
const sdkMessages = await getSessionMessages(sessionId);
|
||||
const sdkTexts = sdkMessages
|
||||
.map((m) => {
|
||||
const c = (m as { message?: { content?: unknown } }).message?.content;
|
||||
if (typeof c === "string") return c;
|
||||
if (Array.isArray(c))
|
||||
return c
|
||||
.map((b) =>
|
||||
b && typeof b === "object" && "text" in b
|
||||
? String((b as { text: unknown }).text)
|
||||
: "",
|
||||
)
|
||||
.join("");
|
||||
return "";
|
||||
})
|
||||
.join("\n");
|
||||
expect(sdkTexts).toContain("<command-name>");
|
||||
expect(sdkTexts).toContain("<local-command-stdout>");
|
||||
expect(sdkTexts).toContain("hi");
|
||||
|
||||
// Step 2: load the session through the ACP agent and confirm the markers
|
||||
// never reach the client as user_message_chunks, while the real "hi"
|
||||
// prompt does.
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
try {
|
||||
await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
|
||||
for (const chunk of recordedUserChunks) {
|
||||
expect(chunk).not.toContain("<command-name>");
|
||||
expect(chunk).not.toContain("<command-message>");
|
||||
expect(chunk).not.toContain("<command-args>");
|
||||
expect(chunk).not.toContain("<local-command-stdout>");
|
||||
expect(chunk).not.toContain("<local-command-stderr>");
|
||||
}
|
||||
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
// Second regression: the original issue showed the markers and the real
|
||||
// "hi" prompt all concatenated into a single string ending with "hi".
|
||||
// loadSession must strip the marker tags in-place rather than dropping the
|
||||
// whole message.
|
||||
it("ACP: loadSession strips marker tags from a concatenated single-string prompt", async () => {
|
||||
const recordedUserChunks: string[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
if (notification.update.sessionUpdate === "user_message_chunk") {
|
||||
const content = notification.update.content;
|
||||
if (content.type === "text") recordedUserChunks.push(content.text);
|
||||
}
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
// Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579.
|
||||
const concatenated =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>" +
|
||||
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>\n" +
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus[1m]</command-args>" +
|
||||
"<local-command-stdout>Set model to opus[1m] (claude-opus-4-7[1m])</local-command-stdout>" +
|
||||
"hi";
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
await q.initializationResult();
|
||||
|
||||
input.push({
|
||||
type: "user",
|
||||
message: { role: "user", content: concatenated },
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
for await (const msg of q) {
|
||||
if (msg.type === "result") break;
|
||||
}
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
try {
|
||||
await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
|
||||
expect(recordedUserChunks.length).toBeGreaterThan(0);
|
||||
for (const chunk of recordedUserChunks) {
|
||||
expect(chunk).not.toContain("<command-name>");
|
||||
expect(chunk).not.toContain("<command-message>");
|
||||
expect(chunk).not.toContain("<command-args>");
|
||||
expect(chunk).not.toContain("<local-command-stdout>");
|
||||
expect(chunk).not.toContain("<local-command-stderr>");
|
||||
}
|
||||
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
|
||||
}, 60000);
|
||||
});
|
||||
93
src/tests/settings.test.ts
Normal file
93
src/tests/settings.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { SettingsManager } from "../settings.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
describe("SettingsManager", () => {
|
||||
let tempDir: string;
|
||||
let settingsManager: SettingsManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
settingsManager?.dispose();
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("settings merging", () => {
|
||||
it("should merge model setting with later sources taking precedence", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
// Project settings with one model
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
model: "claude-3-5-sonnet",
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
let settings = settingsManager.getSettings();
|
||||
expect(settings.model).toBe("claude-3-5-sonnet");
|
||||
|
||||
// Add local settings that override the model
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
model: "claude-3-5-haiku",
|
||||
}),
|
||||
);
|
||||
|
||||
// Re-initialize to pick up local settings
|
||||
settingsManager.dispose();
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
settings = settingsManager.getSettings();
|
||||
expect(settings.model).toBe("claude-3-5-haiku");
|
||||
});
|
||||
|
||||
it("should merge permissions.defaultMode with later sources taking precedence", 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({
|
||||
permissions: {
|
||||
defaultMode: "dontAsk",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
let settings = settingsManager.getSettings();
|
||||
expect(settings.permissions?.defaultMode).toBe("dontAsk");
|
||||
|
||||
// Local settings override the mode
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "plan",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager.dispose();
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
settings = settingsManager.getSettings();
|
||||
expect(settings.permissions?.defaultMode).toBe("plan");
|
||||
});
|
||||
});
|
||||
});
|
||||
1387
src/tests/tools.test.ts
Normal file
1387
src/tests/tools.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
678
src/tools.ts
678
src/tools.ts
@@ -1,14 +1,81 @@
|
||||
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
|
||||
import { replaceAndCalculateLocation, SYSTEM_REMINDER, toolNames } from "./mcp-server.js";
|
||||
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
ContentBlock,
|
||||
PlanEntry,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
ImageBlockParam,
|
||||
TextBlockParam,
|
||||
ToolResultBlockParam,
|
||||
WebSearchResultBlock,
|
||||
WebSearchToolResultBlockParam,
|
||||
WebSearchToolResultError,
|
||||
} from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaBashCodeExecutionToolResultError,
|
||||
BetaCodeExecutionResultBlock,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultError,
|
||||
BetaImageBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionCreateResultBlock,
|
||||
BetaTextEditorCodeExecutionStrReplaceResultBlock,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultError,
|
||||
BetaTextEditorCodeExecutionViewResultBlock,
|
||||
BetaToolReferenceBlock,
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaToolSearchToolResultError,
|
||||
BetaToolSearchToolSearchResultBlock,
|
||||
BetaWebFetchBlock,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebFetchToolResultErrorBlock,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import path from "node:path";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
|
||||
* These are transformed to valid ACP ContentBlock types by toValidAcpContent().
|
||||
*/
|
||||
type ToolResultContent =
|
||||
| TextBlockParam
|
||||
| ImageBlockParam
|
||||
| BetaImageBlockParam
|
||||
| BetaToolReferenceBlock
|
||||
| BetaToolSearchToolSearchResultBlock
|
||||
| BetaToolSearchToolResultError
|
||||
| WebSearchResultBlock
|
||||
| WebSearchToolResultError
|
||||
| BetaWebFetchBlock
|
||||
| BetaWebFetchToolResultErrorBlock
|
||||
| BetaCodeExecutionResultBlock
|
||||
| BetaCodeExecutionToolResultError
|
||||
| BetaBashCodeExecutionResultBlock
|
||||
| BetaBashCodeExecutionToolResultError
|
||||
| BetaTextEditorCodeExecutionViewResultBlock
|
||||
| BetaTextEditorCodeExecutionCreateResultBlock
|
||||
| BetaTextEditorCodeExecutionStrReplaceResultBlock
|
||||
| BetaTextEditorCodeExecutionToolResultError;
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -21,22 +88,52 @@ interface ToolUpdate {
|
||||
title?: string;
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
_meta?: {
|
||||
terminal_info?: {
|
||||
terminal_id: string;
|
||||
};
|
||||
terminal_output?: {
|
||||
terminal_id: string;
|
||||
data: string;
|
||||
};
|
||||
terminal_exit?: {
|
||||
terminal_id: string;
|
||||
exit_code: number;
|
||||
signal: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an absolute file path to a project-relative path for display.
|
||||
* Returns the original path if it's outside the project directory or if no cwd is provided.
|
||||
*/
|
||||
export function toDisplayPath(filePath: string, cwd?: string): string {
|
||||
if (!cwd) return filePath;
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
const resolvedFile = path.resolve(filePath);
|
||||
if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) {
|
||||
return path.relative(resolvedCwd, resolvedFile);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
cachedFileContent: { [key: string]: string },
|
||||
supportsTerminalOutput: boolean = false,
|
||||
cwd?: string,
|
||||
): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
const input = toolUse.input;
|
||||
|
||||
switch (name) {
|
||||
case "Task":
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
const input = toolUse.input as AgentInput | BashInput | undefined;
|
||||
return {
|
||||
title: input?.description ? input.description : "Task",
|
||||
kind: "think",
|
||||
content:
|
||||
input && input.prompt
|
||||
input && "prompt" in input
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
@@ -45,38 +142,16 @@ export function toolInfoFromToolUse(
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "NotebookRead":
|
||||
case "Bash": {
|
||||
const input = toolUse.input as BashInput | undefined;
|
||||
return {
|
||||
title: input?.notebook_path ? `Read Notebook ${input.notebook_path}` : "Read Notebook",
|
||||
kind: "read",
|
||||
content: [],
|
||||
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
|
||||
};
|
||||
|
||||
case "NotebookEdit":
|
||||
return {
|
||||
title: input?.notebook_path ? `Edit Notebook ${input.notebook_path}` : "Edit Notebook",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && input.new_source
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: input.new_source },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: input?.notebook_path ? [{ path: input.notebook_path }] : [],
|
||||
};
|
||||
|
||||
case "Bash":
|
||||
case toolNames.bash:
|
||||
return {
|
||||
title: input?.command ? "`" + input.command.replaceAll("`", "\\`") + "`" : "Terminal",
|
||||
title: input?.command ? input.command : "Terminal",
|
||||
kind: "execute",
|
||||
content:
|
||||
input && input.description
|
||||
content: supportsTerminalOutput
|
||||
? [{ type: "terminal" as const, terminalId: toolUse.id }]
|
||||
: input && input.description
|
||||
? [
|
||||
{
|
||||
type: "content",
|
||||
@@ -85,39 +160,25 @@ export function toolInfoFromToolUse(
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "BashOutput":
|
||||
case toolNames.bashOutput:
|
||||
return {
|
||||
title: "Tail Logs",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case "KillShell":
|
||||
case toolNames.killShell:
|
||||
return {
|
||||
title: "Kill Process",
|
||||
kind: "execute",
|
||||
content: [],
|
||||
};
|
||||
|
||||
case toolNames.read: {
|
||||
case "Read": {
|
||||
const input = toolUse.input as FileReadInput | undefined;
|
||||
let limit = "";
|
||||
if (input.limit) {
|
||||
limit =
|
||||
" (" + ((input.offset ?? 0) + 1) + " - " + ((input.offset ?? 0) + input.limit) + ")";
|
||||
} else if (input.offset) {
|
||||
limit = " (from line " + (input.offset + 1) + ")";
|
||||
if (input?.limit && input.limit > 0) {
|
||||
limit = " (" + (input.offset ?? 1) + " - " + ((input.offset ?? 1) + input.limit - 1) + ")";
|
||||
} else if (input?.offset) {
|
||||
limit = " (from line " + input.offset + ")";
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : "File";
|
||||
return {
|
||||
title: "Read " + (input.file_path ?? "File") + limit,
|
||||
title: "Read " + displayPath + limit,
|
||||
kind: "read",
|
||||
locations: input.file_path
|
||||
locations: input?.file_path
|
||||
? [
|
||||
{
|
||||
path: input.file_path,
|
||||
line: input.offset ?? 0,
|
||||
line: input.offset ?? 1,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -125,69 +186,8 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "Read":
|
||||
return {
|
||||
title: "Read File",
|
||||
kind: "read",
|
||||
content: [],
|
||||
locations: [{ path: input.file_path, line: input.offset ?? 0 }],
|
||||
};
|
||||
|
||||
case "LS":
|
||||
return {
|
||||
title: `List the ${input?.path ? "`" + input.path + "`" : "current"} directory's contents`,
|
||||
kind: "search",
|
||||
content: [],
|
||||
locations: [],
|
||||
};
|
||||
|
||||
case toolNames.edit:
|
||||
case "Edit": {
|
||||
const path = input?.file_path ?? input?.file_path;
|
||||
let oldText = input.old_string ?? null;
|
||||
let newText = input.new_string ?? "";
|
||||
let affectedLines: number[] = [];
|
||||
|
||||
if (path && oldText) {
|
||||
try {
|
||||
const oldContent = cachedFileContent[path] || "";
|
||||
const newContent = replaceAndCalculateLocation(oldContent, [
|
||||
{
|
||||
oldText,
|
||||
newText,
|
||||
replaceAll: false,
|
||||
},
|
||||
]);
|
||||
oldText = oldContent;
|
||||
newText = newContent.newContent;
|
||||
affectedLines = newContent.lineNumbers;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: path ? `Edit \`${path}\`` : "Edit",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && path
|
||||
? [
|
||||
{
|
||||
type: "diff",
|
||||
path,
|
||||
oldText,
|
||||
newText,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: path
|
||||
? affectedLines.length > 0
|
||||
? affectedLines.map((line) => ({ line, path }))
|
||||
: [{ path }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case toolNames.write: {
|
||||
case "Write": {
|
||||
const input = toolUse.input as FileWriteInput | undefined;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path) {
|
||||
content = [
|
||||
@@ -206,101 +206,110 @@ export function toolInfoFromToolUse(
|
||||
},
|
||||
];
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
title: displayPath ? `Write ${displayPath}` : "Write",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Write":
|
||||
case "Edit": {
|
||||
const input = toolUse.input as FileEditInput | undefined;
|
||||
let content: ToolCallContent[] = [];
|
||||
if (input && input.file_path && (input.old_string || input.new_string)) {
|
||||
content = [
|
||||
{
|
||||
type: "diff",
|
||||
path: input.file_path,
|
||||
oldText: input.old_string || null,
|
||||
newText: input.new_string ?? "",
|
||||
},
|
||||
];
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
title: displayPath ? `Edit ${displayPath}` : "Edit",
|
||||
kind: "edit",
|
||||
content:
|
||||
input && input.file_path
|
||||
? [
|
||||
{
|
||||
type: "diff",
|
||||
path: input.file_path,
|
||||
oldText: null,
|
||||
newText: input.content,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Glob": {
|
||||
const input = toolUse.input as GlobInput | undefined;
|
||||
let label = "Find";
|
||||
if (input.path) {
|
||||
if (input?.path) {
|
||||
label += ` \`${input.path}\``;
|
||||
}
|
||||
if (input.pattern) {
|
||||
if (input?.pattern) {
|
||||
label += ` \`${input.pattern}\``;
|
||||
}
|
||||
return {
|
||||
title: label,
|
||||
kind: "search",
|
||||
content: [],
|
||||
locations: input.path ? [{ path: input.path }] : [],
|
||||
locations: input?.path ? [{ path: input.path }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Grep": {
|
||||
const input = toolUse.input as GrepInput | undefined;
|
||||
let label = "grep";
|
||||
|
||||
if (input["-i"]) {
|
||||
if (input?.["-i"]) {
|
||||
label += " -i";
|
||||
}
|
||||
if (input["-n"]) {
|
||||
if (input?.["-n"]) {
|
||||
label += " -n";
|
||||
}
|
||||
|
||||
if (input["-A"] !== undefined) {
|
||||
if (input?.["-A"] !== undefined) {
|
||||
label += ` -A ${input["-A"]}`;
|
||||
}
|
||||
if (input["-B"] !== undefined) {
|
||||
if (input?.["-B"] !== undefined) {
|
||||
label += ` -B ${input["-B"]}`;
|
||||
}
|
||||
if (input["-C"] !== undefined) {
|
||||
if (input?.["-C"] !== undefined) {
|
||||
label += ` -C ${input["-C"]}`;
|
||||
}
|
||||
|
||||
if (input.output_mode) {
|
||||
if (input?.output_mode) {
|
||||
switch (input.output_mode) {
|
||||
case "FilesWithMatches":
|
||||
case "files_with_matches":
|
||||
label += " -l";
|
||||
break;
|
||||
case "Count":
|
||||
case "count":
|
||||
label += " -c";
|
||||
break;
|
||||
case "Content":
|
||||
case "content":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.head_limit !== undefined) {
|
||||
if (input?.head_limit !== undefined) {
|
||||
label += ` | head -${input.head_limit}`;
|
||||
}
|
||||
|
||||
if (input.glob) {
|
||||
if (input?.glob) {
|
||||
label += ` --include="${input.glob}"`;
|
||||
}
|
||||
|
||||
if (input.type) {
|
||||
if (input?.type) {
|
||||
label += ` --type=${input.type}`;
|
||||
}
|
||||
|
||||
if (input.multiline) {
|
||||
if (input?.multiline) {
|
||||
label += " -P";
|
||||
}
|
||||
|
||||
label += ` "${input.pattern}"`;
|
||||
if (input?.pattern) {
|
||||
label += ` "${input.pattern}"`;
|
||||
}
|
||||
|
||||
if (input.path) {
|
||||
if (input?.path) {
|
||||
label += ` ${input.path}`;
|
||||
}
|
||||
|
||||
@@ -311,7 +320,8 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "WebFetch":
|
||||
case "WebFetch": {
|
||||
const input = toolUse.input as WebFetchInput;
|
||||
return {
|
||||
title: input?.url ? `Fetch ${input.url}` : "Fetch",
|
||||
kind: "fetch",
|
||||
@@ -325,15 +335,17 @@ export function toolInfoFromToolUse(
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "WebSearch": {
|
||||
let label = `"${input.query}"`;
|
||||
const input = toolUse.input as WebSearchInput | undefined;
|
||||
let label = input?.query ? `"${input.query}"` : "Web search";
|
||||
|
||||
if (input.allowed_domains && input.allowed_domains.length > 0) {
|
||||
if (input?.allowed_domains && input.allowed_domains.length > 0) {
|
||||
label += ` (allowed: ${input.allowed_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
if (input.blocked_domains && input.blocked_domains.length > 0) {
|
||||
if (input?.blocked_domains && input.blocked_domains.length > 0) {
|
||||
label += ` (blocked: ${input.blocked_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
@@ -344,7 +356,8 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "TodoWrite":
|
||||
case "TodoWrite": {
|
||||
const input = toolUse.input as TodoWriteInput | undefined;
|
||||
return {
|
||||
title: Array.isArray(input?.todos)
|
||||
? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}`
|
||||
@@ -352,18 +365,21 @@ export function toolInfoFromToolUse(
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "ExitPlanMode":
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string } | undefined;
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content:
|
||||
input && input.plan
|
||||
? [{ type: "content", content: { type: "text", text: input.plan } }]
|
||||
: [],
|
||||
content: planInput?.plan
|
||||
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "Other": {
|
||||
const input = toolUse.input;
|
||||
let output;
|
||||
try {
|
||||
output = JSON.stringify(input, null, 2);
|
||||
@@ -397,18 +413,30 @@ export function toolInfoFromToolUse(
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult:
|
||||
| ToolResultBlockParam
|
||||
| BetaToolResultBlockParam
|
||||
| BetaWebSearchToolResultBlockParam
|
||||
| BetaWebFetchToolResultBlockParam
|
||||
| WebSearchToolResultBlockParam
|
||||
| BetaCodeExecutionToolResultBlockParam
|
||||
| BetaBashCodeExecutionToolResultBlockParam
|
||||
| BetaTextEditorCodeExecutionToolResultBlockParam
|
||||
| BetaRequestMCPToolResultBlockParam,
|
||||
| BetaRequestMCPToolResultBlockParam
|
||||
| BetaToolSearchToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
): ToolUpdate {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
|
||||
switch (toolUse?.name) {
|
||||
case "Read":
|
||||
case toolNames.read:
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
@@ -417,9 +445,9 @@ export function toolUpdateFromToolResult(
|
||||
content.type === "text"
|
||||
? {
|
||||
type: "text",
|
||||
text: markdownEscape(content.text.replace(SYSTEM_REMINDER, "")),
|
||||
text: markdownEscape(content.text),
|
||||
}
|
||||
: content,
|
||||
: toAcpContentBlock(content, false),
|
||||
})),
|
||||
};
|
||||
} else if (typeof toolResult.content === "string" && toolResult.content.length > 0) {
|
||||
@@ -429,7 +457,7 @@ export function toolUpdateFromToolResult(
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: markdownEscape(toolResult.content.replace(SYSTEM_REMINDER, "")),
|
||||
text: markdownEscape(toolResult.content),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -437,21 +465,76 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
return {};
|
||||
|
||||
case toolNames.bash:
|
||||
case "edit":
|
||||
case "Edit":
|
||||
case toolNames.edit:
|
||||
case toolNames.write:
|
||||
case "Write": {
|
||||
case "Bash": {
|
||||
const result = toolResult.content;
|
||||
const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
|
||||
const isError = "is_error" in toolResult && toolResult.is_error;
|
||||
|
||||
// Extract output and exit code from either format:
|
||||
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
|
||||
// 2. Plain string content from a regular tool_result
|
||||
// 3. Array content (e.g. [{ type: "text", text: "..." }])
|
||||
let output = "";
|
||||
let exitCode = isError ? 1 : 0;
|
||||
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"type" in result &&
|
||||
result.type === "bash_code_execution_result"
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
const bashResult = result as BetaBashCodeExecutionResultBlock;
|
||||
output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n");
|
||||
exitCode = bashResult.return_code;
|
||||
} else if (typeof result === "string") {
|
||||
output = result;
|
||||
} else if (
|
||||
Array.isArray(result) &&
|
||||
result.length > 0 &&
|
||||
"text" in result[0] &&
|
||||
typeof result[0].text === "string"
|
||||
) {
|
||||
output = result.map((c: any) => c.text).join("\n");
|
||||
}
|
||||
|
||||
if (supportsTerminalOutput) {
|
||||
return {
|
||||
content: [{ type: "terminal" as const, terminalId }],
|
||||
_meta: {
|
||||
terminal_info: {
|
||||
terminal_id: terminalId,
|
||||
},
|
||||
terminal_output: {
|
||||
terminal_id: terminalId,
|
||||
data: output,
|
||||
},
|
||||
terminal_exit: {
|
||||
terminal_id: terminalId,
|
||||
exit_code: exitCode,
|
||||
signal: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// Fallback: format output as a code block without terminal _meta
|
||||
if (output.trim()) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `\`\`\`console\n${output.trimEnd()}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Edit": // Edit is handled in hooks
|
||||
case "Write": {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -459,20 +542,6 @@ export function toolUpdateFromToolResult(
|
||||
return { title: "Exited Plan Mode" };
|
||||
}
|
||||
|
||||
case "Task":
|
||||
case "NotebookEdit":
|
||||
case "NotebookRead":
|
||||
case "TodoWrite":
|
||||
case "exit_plan_mode":
|
||||
case "Bash":
|
||||
case "BashOutput":
|
||||
case "KillBash":
|
||||
case "LS":
|
||||
case "Glob":
|
||||
case "Grep":
|
||||
case "WebFetch":
|
||||
case "WebSearch":
|
||||
case "Other":
|
||||
default: {
|
||||
return toAcpContentUpdate(
|
||||
toolResult.content,
|
||||
@@ -488,17 +557,20 @@ function toAcpContentUpdate(
|
||||
): { content?: ToolCallContent[] } {
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
return {
|
||||
content: content.map((content: any) => ({
|
||||
type: "content",
|
||||
content:
|
||||
isError && content.type === "text"
|
||||
? {
|
||||
...content,
|
||||
text: `\`\`\`\n${content.text}\n\`\`\``,
|
||||
}
|
||||
: content,
|
||||
content: content.map((c: any) => ({
|
||||
type: "content" as const,
|
||||
content: toAcpContentBlock(c, isError),
|
||||
})),
|
||||
};
|
||||
} else if (typeof content === "object" && content !== null && "type" in content) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: toAcpContentBlock(content, isError),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (typeof content === "string" && content.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
@@ -515,16 +587,84 @@ function toAcpContentUpdate(
|
||||
return {};
|
||||
}
|
||||
|
||||
function toAcpContentBlock(content: ToolResultContent, isError: boolean): ContentBlock {
|
||||
const wrapText = (text: string): ContentBlock => ({
|
||||
type: "text" as const,
|
||||
text: isError ? `\`\`\`\n${text}\n\`\`\`` : text,
|
||||
});
|
||||
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: isError ? `\`\`\`\n${content.text}\n\`\`\`` : content.text,
|
||||
};
|
||||
case "image":
|
||||
if (content.source.type === "base64") {
|
||||
return {
|
||||
type: "image" as const,
|
||||
data: content.source.data,
|
||||
mimeType: content.source.media_type,
|
||||
};
|
||||
}
|
||||
// URL and file-based images can't be converted to ACP format (requires data)
|
||||
return wrapText(
|
||||
content.source.type === "url"
|
||||
? `[image: ${content.source.url}]`
|
||||
: "[image: file reference]",
|
||||
);
|
||||
|
||||
case "tool_reference":
|
||||
return wrapText(`Tool: ${content.tool_name}`);
|
||||
case "tool_search_tool_search_result":
|
||||
return wrapText(
|
||||
`Tools found: ${content.tool_references.map((r) => r.tool_name).join(", ") || "none"}`,
|
||||
);
|
||||
case "tool_search_tool_result_error":
|
||||
return wrapText(
|
||||
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
|
||||
);
|
||||
case "web_search_result":
|
||||
return wrapText(`${content.title} (${content.url})`);
|
||||
case "web_search_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "web_fetch_result":
|
||||
return wrapText(`Fetched: ${content.url}`);
|
||||
case "web_fetch_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "code_execution_result":
|
||||
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
|
||||
case "bash_code_execution_result":
|
||||
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
|
||||
case "code_execution_tool_result_error":
|
||||
case "bash_code_execution_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "text_editor_code_execution_view_result":
|
||||
return wrapText(content.content);
|
||||
case "text_editor_code_execution_create_result":
|
||||
return wrapText(content.is_file_update ? "File updated" : "File created");
|
||||
case "text_editor_code_execution_str_replace_result":
|
||||
return wrapText(content.lines?.join("\n") || "");
|
||||
case "text_editor_code_execution_tool_result_error":
|
||||
return wrapText(
|
||||
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
|
||||
);
|
||||
|
||||
default:
|
||||
return wrapText(JSON.stringify(content));
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudePlanEntry = {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
};
|
||||
|
||||
export function planEntries(input: { todos: ClaudePlanEntry[] }): PlanEntry[] {
|
||||
return input.todos.map((input) => ({
|
||||
content: input.content,
|
||||
status: input.status,
|
||||
export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): PlanEntry[] {
|
||||
return (input?.todos ?? []).map((todo) => ({
|
||||
content: todo.content,
|
||||
status: todo.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
}
|
||||
@@ -538,3 +678,121 @@ export function markdownEscape(text: string): string {
|
||||
}
|
||||
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
|
||||
}
|
||||
|
||||
interface EditToolResponseHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface EditToolResponse {
|
||||
filePath?: string;
|
||||
structuredPatch?: EditToolResponseHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
} {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as EditToolResponse;
|
||||
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
|
||||
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
|
||||
for (const { lines, newStart } of response.structuredPatch) {
|
||||
const oldText: string[] = [];
|
||||
const newText: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("-")) {
|
||||
oldText.push(line.slice(1));
|
||||
} else if (line.startsWith("+")) {
|
||||
newText.push(line.slice(1));
|
||||
} else {
|
||||
oldText.push(line.slice(1));
|
||||
newText.push(line.slice(1));
|
||||
}
|
||||
}
|
||||
if (oldText.length > 0 || newText.length > 0) {
|
||||
locations.push({ path: response.filePath, line: newStart });
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: response.filePath,
|
||||
oldText: oldText.join("\n") || null,
|
||||
newText: newText.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {};
|
||||
if (content.length > 0) result.content = content;
|
||||
if (locations.length > 0) result.locations = locations;
|
||||
return result;
|
||||
}
|
||||
|
||||
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
|
||||
const toolUseCallbacks: {
|
||||
[toolUseId: string]: {
|
||||
onPostToolUseHook?: (
|
||||
toolUseID: string,
|
||||
toolInput: unknown,
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
};
|
||||
} = {};
|
||||
|
||||
/* Setup callbacks that will be called when receiving hooks from Claude Code */
|
||||
export const registerHookCallback = (
|
||||
toolUseID: string,
|
||||
{
|
||||
onPostToolUseHook,
|
||||
}: {
|
||||
onPostToolUseHook?: (
|
||||
toolUseID: string,
|
||||
toolInput: unknown,
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
toolUseCallbacks[toolUseID] = {
|
||||
onPostToolUseHook,
|
||||
};
|
||||
};
|
||||
|
||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
||||
export const createPostToolUseHook =
|
||||
(
|
||||
logger: Logger = console,
|
||||
options?: {
|
||||
onEnterPlanMode?: () => Promise<void>;
|
||||
},
|
||||
): HookCallback =>
|
||||
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse") {
|
||||
// Handle EnterPlanMode tool - notify client of mode change after successful execution
|
||||
if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
|
||||
await options.onEnterPlanMode();
|
||||
}
|
||||
|
||||
if (toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
101
src/utils.ts
101
src/utils.ts
@@ -3,7 +3,8 @@
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { WritableStream, ReadableStream } from "node:stream/web";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
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> {
|
||||
@@ -75,120 +76,32 @@ export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Arr
|
||||
});
|
||||
}
|
||||
|
||||
export function unreachable(value: never): never {
|
||||
export function unreachable(value: never, logger: Logger = console) {
|
||||
let valueAsString;
|
||||
try {
|
||||
valueAsString = JSON.stringify(value);
|
||||
} catch {
|
||||
valueAsString = value;
|
||||
}
|
||||
throw new Error(`Unexpected case: ${valueAsString}`);
|
||||
logger.error(`Unexpected case: ${valueAsString}`);
|
||||
}
|
||||
|
||||
export function sleep(time: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
interface ManagedSettings {
|
||||
permissions?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files
|
||||
// This can be removed once the SDK supports it natively.
|
||||
function getManagedSettingsPath(): string {
|
||||
const os = platform();
|
||||
switch (os) {
|
||||
case "darwin":
|
||||
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
||||
case "linux": // including WSL
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
case "win32":
|
||||
return "C:\\ProgramData\\ClaudeCode\\managed-settings.json";
|
||||
default:
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
}
|
||||
}
|
||||
|
||||
export function loadManagedSettings(): ManagedSettings | null {
|
||||
export function loadManagedSettings(): ClaudeCodeSettings | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ManagedSettings;
|
||||
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEnvironmentSettings(settings: ManagedSettings): void {
|
||||
export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
|
||||
if (settings.env) {
|
||||
for (const [key, value] of Object.entries(settings.env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtractLinesResult {
|
||||
content: string;
|
||||
wasLimited: boolean;
|
||||
linesRead: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts lines from file content with byte limit enforcement.
|
||||
*
|
||||
* @param fullContent - The complete file content
|
||||
* @param maxContentLength - Maximum number of UTF-16 Code Units to return
|
||||
* @returns Object containing extracted content and metadata
|
||||
*/
|
||||
export function extractLinesWithByteLimit(
|
||||
fullContent: string,
|
||||
maxContentLength: number,
|
||||
): ExtractLinesResult {
|
||||
if (fullContent === "") {
|
||||
return {
|
||||
content: "",
|
||||
wasLimited: false,
|
||||
linesRead: 1,
|
||||
};
|
||||
}
|
||||
|
||||
let linesSeen = 0;
|
||||
let index = 0;
|
||||
linesSeen = 0;
|
||||
|
||||
let contentLength = 0;
|
||||
let wasLimited = false;
|
||||
|
||||
while (true) {
|
||||
const nextIndex = fullContent.indexOf("\n", index);
|
||||
|
||||
if (nextIndex < 0) {
|
||||
// Last line in file (no trailing newline)
|
||||
if (linesSeen > 0 && fullContent.length > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = fullContent.length;
|
||||
break;
|
||||
} else {
|
||||
// Line with newline - include up to the newline
|
||||
const newContentLength = nextIndex + 1;
|
||||
if (linesSeen > 0 && newContentLength > maxContentLength) {
|
||||
wasLimited = true;
|
||||
break;
|
||||
}
|
||||
linesSeen += 1;
|
||||
contentLength = newContentLength;
|
||||
index = newContentLength;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: fullContent.slice(0, contentLength),
|
||||
wasLimited,
|
||||
linesRead: linesSeen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2021",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2021"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user