mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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: "weekly"
|
||||
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@53b83947a5a98c8d113130e565377fae1a50d02f
|
||||
with:
|
||||
node-version: latest
|
||||
node-version: "lts/*"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
72
.github/workflows/publish.yml
vendored
72
.github/workflows/publish.yml
vendored
@@ -1,14 +1,44 @@
|
||||
name: Publish and Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
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@f8d387b68d61c58ab83c6c016672934102569859
|
||||
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@16a9c90856f42705d54a6fda1823352bdc62cf38
|
||||
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 }}
|
||||
|
||||
build-binaries:
|
||||
name: Build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 15
|
||||
@@ -43,9 +73,9 @@ jobs:
|
||||
archive: claude-agent-acp-windows-arm64.zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
@@ -69,7 +99,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
|
||||
with:
|
||||
name: ${{ matrix.archive }}
|
||||
path: ${{ matrix.archive }}
|
||||
@@ -77,16 +107,16 @@ jobs:
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [build-binaries, 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@53b83947a5a98c8d113130e565377fae1a50d02f
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
@@ -99,22 +129,26 @@ jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, publish-npm]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [build-binaries, publish-npm, release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: artifacts/*
|
||||
- name: Upload artifacts
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload \
|
||||
"${{ needs.release-please.outputs.tag_name }}" \
|
||||
artifacts/* \
|
||||
--clobber
|
||||
|
||||
@@ -1,3 +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.24.2"
|
||||
}
|
||||
63
CHANGELOG.md
63
CHANGELOG.md
@@ -1,5 +1,68 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
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.
|
||||
|
||||
43
README.md
43
README.md
@@ -1,8 +1,8 @@
|
||||
# ACP adapter for the Claude Agent SDK
|
||||
|
||||
[](https://www.npmjs.com/package/@zed-industries/claude-agent-acp)
|
||||
[](https://www.npmjs.com/package/@agentclientprotocol/claude-agent-acp)
|
||||
|
||||
Use [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview#branding-guidelines) 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 Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview), supporting:
|
||||
|
||||
@@ -18,41 +18,8 @@ This tool implements an ACP agent by using the official [Claude Agent 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 Agent, open the Agent Panel and click "New Claude Agent 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
|
||||
|
||||
Or try it with any of the other [ACP compatible clients](https://agentclientprotocol.com/overview/clients)!
|
||||
|
||||
#### Installation
|
||||
|
||||
Install the adapter from `npm`:
|
||||
|
||||
```bash
|
||||
npm install -g @zed-industries/claude-agent-acp
|
||||
```
|
||||
|
||||
You can then use `claude-agent-acp` as a regular ACP agent:
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY=sk-... claude-agent-acp
|
||||
```
|
||||
|
||||
#### Single-file executable
|
||||
|
||||
Pre-built single-file binaries are available on the [Releases](https://github.com/zed-industries/claude-agent-acp/releases) page for Linux, macOS, and Windows.
|
||||
These binaries bundle everything needed and don't require Node.js.
|
||||
|
||||
## 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).
|
||||
|
||||
1616
package-lock.json
generated
1616
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@zed-industries/claude-agent-acp",
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.20.2",
|
||||
"version": "0.24.2",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -49,33 +49,33 @@
|
||||
"sdk",
|
||||
"code"
|
||||
],
|
||||
"homepage": "https://github.com/zed-industries/claude-agent-acp#readme",
|
||||
"homepage": "https://github.com/agentclientprotocol/claude-agent-acp#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/zed-industries/claude-agent-acp/issues"
|
||||
"url": "https://github.com/agentclientprotocol/claude-agent-acp/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zed-industries/claude-agent-acp.git"
|
||||
"url": "git+https://github.com/agentclientprotocol/claude-agent-acp.git"
|
||||
},
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.68",
|
||||
"@agentclientprotocol/sdk": "0.17.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.84",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.78.0",
|
||||
"@anthropic-ai/sdk": "0.80.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"eslint": "10.0.2",
|
||||
"@types/node": "25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"eslint": "10.1.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.4.0",
|
||||
"prettier": "3.8.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.18"
|
||||
"vitest": "4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
464
src/acp-agent.ts
464
src/acp-agent.ts
@@ -34,10 +34,13 @@ import {
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
CloseSessionRequest,
|
||||
CloseSessionResponse,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
StopReason,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
CanUseTool,
|
||||
@@ -60,7 +63,6 @@ import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { SettingsManager } from "./settings.js";
|
||||
import {
|
||||
@@ -110,13 +112,16 @@ type Session = {
|
||||
query: Query;
|
||||
input: Pushable<SDKUserMessage>;
|
||||
cancelled: boolean;
|
||||
permissionMode: PermissionMode;
|
||||
cwd: string;
|
||||
settingsManager: SettingsManager;
|
||||
accumulatedUsage: AccumulatedUsage;
|
||||
modes: SessionModeState;
|
||||
models: SessionModelState;
|
||||
configOptions: SessionConfigOption[];
|
||||
promptRunning: boolean;
|
||||
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
|
||||
nextPendingOrder: number;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
type BackgroundTerminal =
|
||||
@@ -148,21 +153,11 @@ export type NewSessionMeta = {
|
||||
* - hooks (merged with ACP's hooks)
|
||||
* - mcpServers (merged with ACP's mcpServers)
|
||||
* - disallowedTools (merged with ACP's disallowedTools)
|
||||
* - tools (passed through; defaults to claude_code preset if not provided)
|
||||
*/
|
||||
options?: Options;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended ClientCapabilities with `auth` field.
|
||||
* TODO: Remove once `auth` is added to the ACP SDK schema.
|
||||
*/
|
||||
type ClientCapabilitiesWithAuth = ClientCapabilities & {
|
||||
auth?: {
|
||||
_meta?: {
|
||||
gateway?: boolean;
|
||||
};
|
||||
};
|
||||
additionalRoots?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -219,6 +214,12 @@ function isStaticBinary(): boolean {
|
||||
return process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN !== undefined;
|
||||
}
|
||||
|
||||
export async function claudeCliPath(): Promise<string> {
|
||||
return isStaticBinary()
|
||||
? (await import("@anthropic-ai/claude-agent-sdk/embed")).default
|
||||
: import.meta.resolve("@anthropic-ai/claude-agent-sdk").replace("sdk.mjs", "cli.js");
|
||||
}
|
||||
|
||||
function shouldHideClaudeAuth(): boolean {
|
||||
return process.argv.includes("--hide-claude-auth");
|
||||
}
|
||||
@@ -227,6 +228,10 @@ function shouldHideClaudeAuth(): boolean {
|
||||
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
||||
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
||||
|
||||
// Slash commands that the SDK handles locally without replaying the user
|
||||
// message and without invoking the model.
|
||||
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
||||
|
||||
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
default: "default",
|
||||
acceptedits: "acceptEdits",
|
||||
@@ -286,17 +291,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.clientCapabilities = request.clientCapabilities;
|
||||
|
||||
// Default authMethod
|
||||
const authMethod: any = {
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
};
|
||||
|
||||
// Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway.
|
||||
// Only offered when the client advertises `auth._meta.gateway` capability.
|
||||
const clientCaps = request.clientCapabilities as ClientCapabilitiesWithAuth | undefined;
|
||||
const supportsGatewayAuth = clientCaps?.auth?._meta?.gateway === true;
|
||||
const supportsGatewayAuth = request.clientCapabilities?.auth?._meta?.gateway === true;
|
||||
|
||||
const gatewayAuthMethod: AuthMethod = {
|
||||
id: "gateway",
|
||||
@@ -309,25 +306,22 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
};
|
||||
|
||||
const terminalAuthMethod: any = {
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
type: "terminal",
|
||||
args: ["--cli"],
|
||||
};
|
||||
const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
|
||||
|
||||
// If client supports terminal-auth capability, use that instead.
|
||||
const supportsTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
||||
if (supportsTerminalAuth) {
|
||||
let command: string;
|
||||
let args: string[];
|
||||
|
||||
if (isStaticBinary()) {
|
||||
command = process.execPath;
|
||||
args = ["--cli"];
|
||||
} else {
|
||||
const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
|
||||
command = "node";
|
||||
args = [cliPath];
|
||||
}
|
||||
|
||||
authMethod._meta = {
|
||||
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
||||
if (supportsMetaTerminalAuth) {
|
||||
terminalAuthMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command,
|
||||
args,
|
||||
command: process.execPath,
|
||||
args: [...process.argv.slice(1), "--cli"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
};
|
||||
@@ -354,6 +348,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
close: {},
|
||||
},
|
||||
},
|
||||
agentInfo: {
|
||||
@@ -362,8 +357,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
version: packageJson.version,
|
||||
},
|
||||
authMethods: [
|
||||
// Terminal auth can also be used for API keys, so don't gate it on --hide-claude-auth.
|
||||
...(shouldHideClaudeAuth() && !supportsTerminalAuth ? [] : [authMethod]),
|
||||
...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
|
||||
? [terminalAuthMethod]
|
||||
: []),
|
||||
...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
|
||||
],
|
||||
};
|
||||
@@ -409,34 +405,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
|
||||
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
resume: params.sessionId,
|
||||
},
|
||||
);
|
||||
const result = await this.getOrCreateSession(params);
|
||||
|
||||
// Needs to happen after we return the session
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(response.sessionId);
|
||||
this.sendAvailableCommandsUpdate(params.sessionId);
|
||||
}, 0);
|
||||
return response;
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
resume: params.sessionId,
|
||||
},
|
||||
);
|
||||
const result = await this.getOrCreateSession(params);
|
||||
|
||||
await this.replaySessionHistory(params.sessionId);
|
||||
|
||||
@@ -445,14 +424,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
modes: response.modes,
|
||||
models: response.models,
|
||||
configOptions: response.configOptions,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||
const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined });
|
||||
const sessions = [];
|
||||
|
||||
@@ -496,13 +471,21 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
const userMessage = promptToClaude(params);
|
||||
|
||||
const promptUuid = randomUUID();
|
||||
userMessage.uuid = promptUuid;
|
||||
|
||||
// These local-only commands return a result without replaying the user
|
||||
// message. Mark promptReplayed=true so their result isn't consumed as a
|
||||
// background task result.
|
||||
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
||||
const isLocalOnlyCommand =
|
||||
firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]);
|
||||
|
||||
if (session.promptRunning) {
|
||||
const uuid = randomUUID();
|
||||
userMessage.uuid = uuid;
|
||||
session.input.push(userMessage);
|
||||
const order = session.nextPendingOrder++;
|
||||
const cancelled = await new Promise<boolean>((resolve) => {
|
||||
session.pendingMessages.set(uuid, { resolve, order });
|
||||
session.pendingMessages.set(promptUuid, { resolve, order });
|
||||
});
|
||||
if (cancelled) {
|
||||
return { stopReason: "cancelled" };
|
||||
@@ -513,6 +496,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
session.promptRunning = true;
|
||||
let handedOff = false;
|
||||
let stopReason: StopReason = "end_turn";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -565,6 +549,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "session_state_changed": {
|
||||
if (message.state === "idle") {
|
||||
return { stopReason, usage: sessionUsage(session) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "hook_started":
|
||||
case "hook_progress":
|
||||
case "hook_response":
|
||||
@@ -573,6 +563,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "task_notification":
|
||||
case "task_progress":
|
||||
case "elicitation_complete":
|
||||
case "api_retry":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
default:
|
||||
@@ -581,10 +572,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
break;
|
||||
case "result": {
|
||||
if (session.cancelled) {
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
|
||||
// Accumulate usage from this result
|
||||
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
|
||||
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
|
||||
@@ -612,18 +599,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
});
|
||||
}
|
||||
|
||||
// Build the usage response
|
||||
const usage: PromptResponse["usage"] = {
|
||||
inputTokens: session.accumulatedUsage.inputTokens,
|
||||
outputTokens: session.accumulatedUsage.outputTokens,
|
||||
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
|
||||
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
|
||||
totalTokens:
|
||||
session.accumulatedUsage.inputTokens +
|
||||
session.accumulatedUsage.outputTokens +
|
||||
session.accumulatedUsage.cachedReadTokens +
|
||||
session.accumulatedUsage.cachedWriteTokens,
|
||||
};
|
||||
if (session.cancelled) {
|
||||
stopReason = "cancelled";
|
||||
break;
|
||||
}
|
||||
|
||||
switch (message.subtype) {
|
||||
case "success": {
|
||||
@@ -631,16 +610,32 @@ export class ClaudeAcpAgent implements Agent {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
if (message.stop_reason === "max_tokens") {
|
||||
return { stopReason: "max_tokens", usage };
|
||||
stopReason = "max_tokens";
|
||||
break;
|
||||
}
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(undefined, message.result);
|
||||
}
|
||||
return { stopReason: "end_turn", usage };
|
||||
// For local-only commands (no model invocation), the result
|
||||
// text is the command output — forward it to the client.
|
||||
if (isLocalOnlyCommand) {
|
||||
for (const notification of toAcpNotifications(
|
||||
message.result,
|
||||
"assistant",
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error_during_execution":
|
||||
case "error_during_execution": {
|
||||
if (message.stop_reason === "max_tokens") {
|
||||
return { stopReason: "max_tokens", usage };
|
||||
stopReason = "max_tokens";
|
||||
break;
|
||||
}
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(
|
||||
@@ -648,7 +643,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message.errors.join(", ") || message.subtype,
|
||||
);
|
||||
}
|
||||
return { stopReason: "end_turn", usage };
|
||||
stopReason = "end_turn";
|
||||
break;
|
||||
}
|
||||
case "error_max_budget_usd":
|
||||
case "error_max_turns":
|
||||
case "error_max_structured_output_retries":
|
||||
@@ -658,7 +655,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message.errors.join(", ") || message.subtype,
|
||||
);
|
||||
}
|
||||
return { stopReason: "max_turn_requests", usage };
|
||||
stopReason = "max_turn_requests";
|
||||
break;
|
||||
default:
|
||||
unreachable(message, this.logger);
|
||||
break;
|
||||
@@ -672,7 +670,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{ clientCapabilities: this.clientCapabilities },
|
||||
{
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
cwd: session.cwd,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -684,8 +685,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for queued prompt replay
|
||||
// Check for prompt replay
|
||||
if (message.type === "user" && "uuid" in message && message.uuid) {
|
||||
if (message.uuid === promptUuid) {
|
||||
break;
|
||||
}
|
||||
|
||||
const pending = session.pendingMessages.get(message.uuid as string);
|
||||
if (pending) {
|
||||
pending.resolve(false);
|
||||
@@ -693,7 +698,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
handedOff = true;
|
||||
// the current loop stops with end_turn,
|
||||
// the loop of the next prompt continues running
|
||||
return { stopReason: "end_turn" };
|
||||
return { stopReason: "end_turn", usage: sessionUsage(session) };
|
||||
}
|
||||
if ("isReplay" in message && message.isReplay) {
|
||||
// not pending or unrelated replay message
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,22 +722,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
typeof message.message.content === "string" &&
|
||||
message.message.content.includes("<local-command-stdout>")
|
||||
) {
|
||||
// Handle /context by sending its reply as regular agent message.
|
||||
if (message.message.content.includes("Context Usage")) {
|
||||
for (const notification of toAcpNotifications(
|
||||
message.message.content
|
||||
.replace("<local-command-stdout>", "")
|
||||
.replace("</local-command-stdout>", ""),
|
||||
"assistant",
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{ clientCapabilities: this.clientCapabilities },
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
}
|
||||
this.logger.log(message.message.content);
|
||||
break;
|
||||
}
|
||||
@@ -780,6 +773,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
{
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: session.cwd,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
@@ -811,6 +805,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
message.includes("Failed to write to process stdin")
|
||||
) {
|
||||
this.logger.error(`Session ${params.sessionId}: Claude Agent process died: ${message}`);
|
||||
session.settingsManager.dispose();
|
||||
session.input.end();
|
||||
delete this.sessions[params.sessionId];
|
||||
throw RequestError.internalError(
|
||||
@@ -851,6 +846,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await session.query.interrupt();
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
const session = this.sessions[params.sessionId];
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
await this.cancel({ sessionId: params.sessionId });
|
||||
|
||||
session.settingsManager.dispose();
|
||||
session.abortController.abort();
|
||||
delete this.sessions[params.sessionId];
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
@@ -878,6 +887,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
if (typeof params.value !== "string") {
|
||||
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
||||
}
|
||||
|
||||
const option = session.configOptions.find((o) => o.id === params.configId);
|
||||
if (!option) {
|
||||
@@ -888,26 +900,50 @@ export class ClaudeAcpAgent implements Agent {
|
||||
"options" in option && Array.isArray(option.options)
|
||||
? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
|
||||
: [];
|
||||
const validValue = allValues.find((o) => o.value === params.value);
|
||||
let validValue = allValues.find((o) => o.value === params.value);
|
||||
|
||||
// For model options, fall back to resolveModelPreference when the exact
|
||||
// value doesn't match. This lets callers use human-friendly aliases like
|
||||
// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
|
||||
if (!validValue && params.configId === "model") {
|
||||
const modelInfos: ModelInfo[] = allValues.map((o) => ({
|
||||
value: o.value,
|
||||
displayName: o.name,
|
||||
description: o.description ?? "",
|
||||
}));
|
||||
const resolved = resolveModelPreference(modelInfos, params.value);
|
||||
if (resolved) {
|
||||
validValue = allValues.find((o) => o.value === resolved.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!validValue) {
|
||||
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
||||
}
|
||||
|
||||
// Use the canonical option value so downstream code always receives the
|
||||
// model ID rather than the caller-supplied alias.
|
||||
const resolvedValue = validValue.value;
|
||||
|
||||
if (params.configId === "mode") {
|
||||
await this.applySessionMode(params.sessionId, params.value);
|
||||
await this.applySessionMode(params.sessionId, resolvedValue);
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: params.value,
|
||||
currentModeId: resolvedValue,
|
||||
},
|
||||
});
|
||||
} else if (params.configId === "model") {
|
||||
await this.sessions[params.sessionId].query.setModel(params.value);
|
||||
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
||||
}
|
||||
|
||||
this.syncSessionConfigState(session, params.configId, params.value);
|
||||
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === params.configId ? { ...o, currentValue: params.value } : o,
|
||||
o.id === params.configId && typeof o.currentValue === "string"
|
||||
? { ...o, currentValue: resolvedValue }
|
||||
: o,
|
||||
);
|
||||
|
||||
return { configOptions: session.configOptions };
|
||||
@@ -924,7 +960,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
default:
|
||||
throw new Error("Invalid Mode");
|
||||
}
|
||||
this.sessions[sessionId].permissionMode = modeId;
|
||||
try {
|
||||
await this.sessions[sessionId].query.setPermissionMode(modeId);
|
||||
} catch (error) {
|
||||
@@ -954,7 +989,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{ registerHooks: false, clientCapabilities: this.clientCapabilities },
|
||||
{
|
||||
registerHooks: false,
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
cwd: this.sessions[sessionId]?.cwd,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
@@ -979,21 +1018,29 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "Session not found",
|
||||
interrupt: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const 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" },
|
||||
];
|
||||
if (ALLOW_BYPASS) {
|
||||
options.unshift({
|
||||
kind: "allow_always",
|
||||
name: "Yes, and bypass permissions",
|
||||
optionId: "bypassPermissions",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await this.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" },
|
||||
],
|
||||
options,
|
||||
sessionId,
|
||||
toolCall: {
|
||||
toolCallId: toolUseID,
|
||||
@@ -1001,6 +1048,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
...toolInfoFromToolUse(
|
||||
{ name: toolName, input: toolInput, id: toolUseID },
|
||||
supportsTerminalOutput,
|
||||
session?.cwd,
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -1010,9 +1058,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "default" || response.outcome.optionId === "acceptEdits")
|
||||
(response.outcome.optionId === "default" ||
|
||||
response.outcome.optionId === "acceptEdits" ||
|
||||
response.outcome.optionId === "bypassPermissions")
|
||||
) {
|
||||
session.permissionMode = response.outcome.optionId;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
@@ -1033,12 +1082,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User rejected request to exit plan mode.",
|
||||
interrupt: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (session.permissionMode === "bypassPermissions") {
|
||||
if (session.modes.currentModeId === "bypassPermissions") {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
@@ -1065,6 +1113,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
...toolInfoFromToolUse(
|
||||
{ name: toolName, input: toolInput, id: toolUseID },
|
||||
supportsTerminalOutput,
|
||||
session?.cwd,
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -1098,7 +1147,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
interrupt: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1125,8 +1173,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return;
|
||||
|
||||
this.syncSessionConfigState(session, configId, value);
|
||||
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId ? { ...o, currentValue: value } : o,
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
|
||||
await this.client.sessionUpdate({
|
||||
@@ -1138,6 +1188,49 @@ export class ClaudeAcpAgent implements Agent {
|
||||
});
|
||||
}
|
||||
|
||||
private syncSessionConfigState(session: Session, configId: string, value: string): void {
|
||||
if (configId === "mode") {
|
||||
session.modes = { ...session.modes, currentModeId: value };
|
||||
} else if (configId === "model") {
|
||||
session.models = { ...session.models, currentModelId: value };
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateSession(params: {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
_meta?: NewSessionRequest["_meta"];
|
||||
}): Promise<NewSessionResponse> {
|
||||
const existingSession = this.sessions[params.sessionId];
|
||||
if (existingSession) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
resume: params.sessionId,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: response.sessionId,
|
||||
modes: response.modes,
|
||||
models: response.models,
|
||||
configOptions: response.configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
params: NewSessionRequest,
|
||||
creationOpts: { resume?: string; forkSession?: boolean } = {},
|
||||
@@ -1163,7 +1256,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const mcpServers: Record<string, McpServerConfig> = {};
|
||||
if (Array.isArray(params.mcpServers)) {
|
||||
for (const server of params.mcpServers) {
|
||||
if ("type" in server) {
|
||||
if ("type" in server && (server.type === "http" || server.type === "sse")) {
|
||||
// HTTP or SSE type MCP server
|
||||
mcpServers[server.name] = {
|
||||
type: server.type,
|
||||
url: server.url,
|
||||
@@ -1172,6 +1266,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
// Stdio type MCP server (with or without explicit type field)
|
||||
mcpServers[server.name] = {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
@@ -1203,7 +1298,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
);
|
||||
|
||||
// Extract options from _meta if provided
|
||||
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
|
||||
const sessionMeta = params._meta as NewSessionMeta | undefined;
|
||||
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
||||
|
||||
// Configure thinking tokens from environment variable
|
||||
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
|
||||
@@ -1213,26 +1309,15 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
|
||||
const disallowedTools = ["AskUserQuestion"];
|
||||
|
||||
// Check if built-in tools should be disabled
|
||||
if (params._meta?.disableBuiltInTools === true) {
|
||||
// When built-in tools are disabled, explicitly disallow all of them
|
||||
disallowedTools.push(
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Task",
|
||||
"TodoWrite",
|
||||
"ExitPlanMode",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
"SlashCommand",
|
||||
"Skill",
|
||||
"NotebookEdit",
|
||||
);
|
||||
}
|
||||
// Resolve which built-in tools to expose.
|
||||
// Explicit tools array from _meta.claudeCode.options takes precedence.
|
||||
// disableBuiltInTools is a legacy shorthand for tools: [] — kept for
|
||||
// backward compatibility but callers should prefer the tools array.
|
||||
const tools: Options["tools"] =
|
||||
userProvidedOptions?.tools ??
|
||||
(params._meta?.disableBuiltInTools === true ? [] : { type: "preset", preset: "claude_code" });
|
||||
|
||||
const abortController = userProvidedOptions?.abortController || new AbortController();
|
||||
|
||||
const options: Options = {
|
||||
systemPrompt,
|
||||
@@ -1243,6 +1328,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
...process.env,
|
||||
...userProvidedOptions?.env,
|
||||
...createEnvForGateway(this.gatewayAuthMeta),
|
||||
// Opt-in to session state events like when the agent is idle
|
||||
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
||||
},
|
||||
// Override certain fields that must be controlled by ACP
|
||||
cwd: params.cwd,
|
||||
@@ -1255,19 +1342,18 @@ export class ClaudeAcpAgent implements Agent {
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
// note: although not documented by the types, passing an absolute path
|
||||
// here works to find zed's managed node version.
|
||||
executable: process.execPath as any,
|
||||
executableArgs: isStaticBinary() ? ["--cli"] : undefined,
|
||||
executable: isStaticBinary() ? undefined : (process.execPath as any),
|
||||
...(process.env.CLAUDE_CODE_EXECUTABLE
|
||||
? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE }
|
||||
: isStaticBinary()
|
||||
? { pathToClaudeCodeExecutable: process.execPath }
|
||||
? { pathToClaudeCodeExecutable: await claudeCliPath() }
|
||||
: {}),
|
||||
extraArgs: {
|
||||
...userProvidedOptions?.extraArgs,
|
||||
"replay-user-messages": "",
|
||||
},
|
||||
disallowedTools: [...(userProvidedOptions?.disallowedTools || []), ...disallowedTools],
|
||||
tools: { type: "preset", preset: "claude_code" },
|
||||
tools,
|
||||
hooks: {
|
||||
...userProvidedOptions?.hooks,
|
||||
PostToolUse: [
|
||||
@@ -1276,10 +1362,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
hooks: [
|
||||
createPostToolUseHook(this.logger, {
|
||||
onEnterPlanMode: async () => {
|
||||
const session = this.sessions[sessionId];
|
||||
if (session) {
|
||||
session.permissionMode = "plan";
|
||||
}
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
@@ -1295,15 +1377,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
],
|
||||
},
|
||||
...creationOpts,
|
||||
abortController,
|
||||
};
|
||||
|
||||
options.additionalDirectories = [
|
||||
...(userProvidedOptions?.additionalDirectories ?? []),
|
||||
...(sessionMeta?.additionalRoots ?? []),
|
||||
];
|
||||
|
||||
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
||||
// Set our own session id if not resuming an existing session.
|
||||
options.sessionId = sessionId;
|
||||
}
|
||||
|
||||
// Handle abort controller from meta options
|
||||
const abortController = userProvidedOptions?.abortController;
|
||||
if (abortController?.signal.aborted) {
|
||||
throw new Error("Cancelled");
|
||||
}
|
||||
@@ -1327,7 +1414,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (shouldHideClaudeAuth() && initializationResult.account.subscriptionType) {
|
||||
if (
|
||||
shouldHideClaudeAuth() &&
|
||||
initializationResult.account.subscriptionType &&
|
||||
!this.gatewayAuthMeta
|
||||
) {
|
||||
throw RequestError.authRequired(
|
||||
undefined,
|
||||
"This integration does not support using claude.ai subscriptions.",
|
||||
@@ -1378,7 +1469,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
query: q,
|
||||
input: input,
|
||||
cancelled: false,
|
||||
permissionMode,
|
||||
cwd: params.cwd,
|
||||
settingsManager,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -1386,10 +1477,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1401,6 +1495,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
function sessionUsage(session: Session) {
|
||||
return {
|
||||
inputTokens: session.accumulatedUsage.inputTokens,
|
||||
outputTokens: session.accumulatedUsage.outputTokens,
|
||||
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
|
||||
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
|
||||
totalTokens:
|
||||
session.accumulatedUsage.inputTokens +
|
||||
session.accumulatedUsage.outputTokens +
|
||||
session.accumulatedUsage.cachedReadTokens +
|
||||
session.accumulatedUsage.cachedWriteTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) {
|
||||
if (!gatewayMeta) {
|
||||
return {};
|
||||
@@ -1608,10 +1716,10 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
case "text": {
|
||||
let text = chunk.text;
|
||||
// change /mcp:server:command args -> /server:command (MCP) args
|
||||
const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(\s+.*)?$/);
|
||||
const mcpMatch = text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s(.*))?$/);
|
||||
if (mcpMatch) {
|
||||
const [, server, command, args] = mcpMatch;
|
||||
text = `/${server}:${command} (MCP)${args || ""}`;
|
||||
text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
|
||||
}
|
||||
content.push({ type: "text", text });
|
||||
break;
|
||||
@@ -1693,6 +1801,7 @@ export function toAcpNotifications(
|
||||
registerHooks?: boolean;
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
parentToolUseId?: string | null;
|
||||
cwd?: string;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const registerHooks = options?.registerHooks !== false;
|
||||
@@ -1821,7 +1930,7 @@ export function toAcpNotifications(
|
||||
toolCallId: chunk.id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
rawInput,
|
||||
...toolInfoFromToolUse(chunk, supportsTerminalOutput),
|
||||
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
|
||||
};
|
||||
} else {
|
||||
// First encounter (streaming content_block_start or replay) —
|
||||
@@ -1839,7 +1948,7 @@ export function toAcpNotifications(
|
||||
sessionUpdate: "tool_call",
|
||||
rawInput,
|
||||
status: "pending",
|
||||
...toolInfoFromToolUse(chunk, supportsTerminalOutput),
|
||||
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1945,7 +2054,10 @@ export function streamEventToAcpNotifications(
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
options?: { clientCapabilities?: ClientCapabilities },
|
||||
options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
cwd?: string;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
switch (event.type) {
|
||||
@@ -1960,6 +2072,7 @@ export function streamEventToAcpNotifications(
|
||||
{
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
},
|
||||
);
|
||||
case "content_block_delta":
|
||||
@@ -1973,6 +2086,7 @@ export function streamEventToAcpNotifications(
|
||||
{
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
},
|
||||
);
|
||||
// No content
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
// Load managed settings and apply environment variables
|
||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
||||
import { runAcp } from "./acp-agent.js";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
|
||||
if (process.argv.includes("--cli")) {
|
||||
process.argv = process.argv.filter((arg) => arg !== "--cli");
|
||||
// @ts-expect-error -- no types
|
||||
await import("@anthropic-ai/claude-agent-sdk/cli.js");
|
||||
await import(await claudeCliPath());
|
||||
} else {
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
|
||||
@@ -15,7 +15,12 @@ export {
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
@@ -20,10 +20,11 @@ import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import {
|
||||
markdownEscape,
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
toolUpdateFromToolResult,
|
||||
toolUpdateFromEditToolResponse,
|
||||
} from "../tools.js";
|
||||
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent, claudeCliPath } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -227,7 +228,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toBe("Error: No messages to compact");
|
||||
expect(client.takeReceivedText()).toBe("Compacting...\n\nCompacting completed.");
|
||||
|
||||
// Send something
|
||||
await connection.prompt({
|
||||
@@ -248,7 +249,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toContain("");
|
||||
expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed.");
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -563,6 +564,20 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should use relative path in title when cwd is provided", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01READ_CWD",
|
||||
name: "Read",
|
||||
input: { file_path: "/Users/test/project/src/main.ts" },
|
||||
};
|
||||
|
||||
const result = toolInfoFromToolUse(tool_use, false, "/Users/test/project");
|
||||
expect(result.title).toBe("Read src/main.ts");
|
||||
// locations.path stays absolute for navigation
|
||||
expect(result.locations).toStrictEqual([{ path: "/Users/test/project/src/main.ts", line: 1 }]);
|
||||
});
|
||||
|
||||
it("should handle plan entries", () => {
|
||||
const received: SDKAssistantMessage = {
|
||||
type: "assistant",
|
||||
@@ -969,6 +984,22 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDisplayPath", () => {
|
||||
it("should relativize paths inside cwd and keep absolute paths outside", () => {
|
||||
expect(toDisplayPath("/Users/test/project/src/main.ts", "/Users/test/project")).toBe(
|
||||
"src/main.ts",
|
||||
);
|
||||
expect(toDisplayPath("/etc/hosts", "/Users/test/project")).toBe("/etc/hosts");
|
||||
expect(toDisplayPath("/Users/test/project/src/main.ts")).toBe(
|
||||
"/Users/test/project/src/main.ts",
|
||||
);
|
||||
// Partial directory name match should not be treated as inside cwd
|
||||
expect(toDisplayPath("/Users/test/project-other/file.ts", "/Users/test/project")).toBe(
|
||||
"/Users/test/project-other/file.ts",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolUpdateFromEditToolResponse", () => {
|
||||
it("should return empty for non-object input", () => {
|
||||
expect(toolUpdateFromEditToolResponse(null)).toEqual({});
|
||||
@@ -1140,6 +1171,11 @@ describe("prompt conversion", () => {
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
||||
it("finds vendored cli path", async () => {
|
||||
const path = await claudeCliPath();
|
||||
expect(path).toContain("@anthropic-ai/claude-agent-sdk/cli.js");
|
||||
});
|
||||
|
||||
it("query has a 'default' model", async () => {
|
||||
const q = query({ prompt: "hi" });
|
||||
const models = await q.supportedModels();
|
||||
@@ -1269,18 +1305,38 @@ describe("stop reason propagation", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function* messageGenerator(messages: any[]) {
|
||||
yield* messages;
|
||||
}
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
|
||||
const gen = messageGenerator(messages);
|
||||
const input = new Pushable<any>();
|
||||
async function* messageGenerator() {
|
||||
// Wait for the prompt to push its user message so we can replay it
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage, done } = await iter.next();
|
||||
if (!done && userMessage) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
yield* messages;
|
||||
}
|
||||
agent.sessions["test-session"] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cancelled: false,
|
||||
permissionMode: "default",
|
||||
settingsManager: {} as any,
|
||||
cwd: "/test",
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
@@ -1291,6 +1347,7 @@ describe("stop reason propagation", () => {
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1355,7 @@ describe("stop reason propagation", () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
createResultMessage({ subtype: "success", stop_reason: "max_tokens", is_error: false }),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1317,6 +1375,7 @@ describe("stop reason propagation", () => {
|
||||
is_error: true,
|
||||
result: "Token limit reached",
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1336,6 +1395,7 @@ describe("stop reason propagation", () => {
|
||||
is_error: true,
|
||||
errors: ["some error"],
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1350,6 +1410,7 @@ describe("stop reason propagation", () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
createResultMessage({ subtype: "success", stop_reason: null, is_error: false }),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1360,6 +1421,89 @@ describe("stop reason propagation", () => {
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
});
|
||||
|
||||
it("should consume background task results and return the prompt's own result", async () => {
|
||||
const agent = createMockAgent();
|
||||
const input = new Pushable<any>();
|
||||
|
||||
const backgroundTaskResult = createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: null,
|
||||
is_error: false,
|
||||
});
|
||||
// Background task used some tokens
|
||||
backgroundTaskResult.usage.input_tokens = 100;
|
||||
backgroundTaskResult.usage.output_tokens = 50;
|
||||
|
||||
const promptResult = createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: null,
|
||||
is_error: false,
|
||||
});
|
||||
|
||||
async function* messageGenerator() {
|
||||
// Background task init + result arrive before our prompt's replay
|
||||
yield { type: "system", subtype: "init", session_id: "test-session" };
|
||||
yield backgroundTaskResult;
|
||||
|
||||
// Now the prompt's user message replay arrives
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage } = await iter.next();
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
|
||||
// Then the prompt's own result
|
||||
yield promptResult;
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
|
||||
agent.sessions["test-session"] = {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cwd: "/tmp/test",
|
||||
cancelled: false,
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
abortController: new AbortController(),
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
};
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
// Usage should include both background task and prompt result tokens
|
||||
expect(response.usage?.inputTokens).toBe(
|
||||
backgroundTaskResult.usage.input_tokens + promptResult.usage.input_tokens,
|
||||
);
|
||||
expect(response.usage?.outputTokens).toBe(
|
||||
backgroundTaskResult.usage.output_tokens + promptResult.usage.output_tokens,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw internal error for success with is_error true and no max_tokens", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
@@ -1379,3 +1523,88 @@ describe("stop reason propagation", () => {
|
||||
).rejects.toThrow("Internal error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/close", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
|
||||
function* empty() {}
|
||||
const gen = Object.assign(empty(), { interrupt: vi.fn() });
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
|
||||
it("should close an existing session and remove it", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "session-1");
|
||||
|
||||
expect(agent.sessions["session-1"]).toBeDefined();
|
||||
|
||||
const result = await agent.unstable_closeSession({ sessionId: "session-1" });
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(agent.sessions["session-1"]).toBeUndefined();
|
||||
expect(session.query.interrupt).toHaveBeenCalled();
|
||||
expect(session.settingsManager.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should abort the session's abort controller", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "session-2");
|
||||
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
|
||||
await agent.unstable_closeSession({ sessionId: "session-2" });
|
||||
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw when closing a non-existent session", async () => {
|
||||
const agent = createMockAgent();
|
||||
|
||||
await expect(agent.unstable_closeSession({ sessionId: "non-existent" })).rejects.toThrow(
|
||||
"Session not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not affect other sessions when closing one", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, "session-a");
|
||||
injectSession(agent, "session-b");
|
||||
|
||||
await agent.unstable_closeSession({ sessionId: "session-a" });
|
||||
|
||||
expect(agent.sessions["session-a"]).toBeUndefined();
|
||||
expect(agent.sessions["session-b"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,20 @@ 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();
|
||||
@@ -17,20 +31,6 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
|
||||
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,
|
||||
}));
|
||||
|
||||
const connectionMock = {
|
||||
sessionUpdate: async (_: any) => {},
|
||||
} as AgentSideConnection;
|
||||
@@ -72,7 +72,7 @@ describe("authorization", () => {
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { _meta: { gateway: true } },
|
||||
auth: { terminal: true, _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
@@ -130,7 +130,7 @@ describe("authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("terminal auth still offered when --hide-claude-auth is set", async () => {
|
||||
it("hide terminal auth even when terminal-auth is set", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
@@ -140,9 +140,7 @@ describe("authorization", () => {
|
||||
_meta: { "terminal-auth": true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("show claude authentication", async () => {
|
||||
@@ -150,7 +148,7 @@ describe("authorization", () => {
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("includes both user and built-in disallowed tools when disableBuiltInTools is true", async () => {
|
||||
it("sets tools to empty array when disableBuiltInTools is true", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
@@ -116,15 +116,11 @@ describe("createSession options merging", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const disallowed = capturedOptions!.disallowedTools!;
|
||||
// User-provided
|
||||
expect(disallowed).toContain("CustomTool");
|
||||
// ACP internal
|
||||
expect(disallowed).toContain("AskUserQuestion");
|
||||
// Built-in tools disabled by disableBuiltInTools
|
||||
expect(disallowed).toContain("Read");
|
||||
expect(disallowed).toContain("Write");
|
||||
expect(disallowed).toContain("Bash");
|
||||
// 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 () => {
|
||||
@@ -199,6 +195,64 @@ describe("createSession options merging", () => {
|
||||
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([]);
|
||||
});
|
||||
|
||||
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
|
||||
@@ -224,6 +224,61 @@ describe("session config options", () => {
|
||||
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,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import { toolUpdateFromToolResult, createPostToolUseHook } from "../tools.js";
|
||||
import { toolUpdateFromToolResult, createPostToolUseHook, toolInfoFromToolUse } from "../tools.js";
|
||||
|
||||
describe("rawOutput in tool call updates", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
@@ -1235,3 +1235,39 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInfoFromToolUse - ExitPlanMode", () => {
|
||||
it("should include plan text in content when input.plan is provided", () => {
|
||||
const toolUse = {
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_1",
|
||||
input: {
|
||||
plan: "# My Plan\n\n## Step 1\nDo something",
|
||||
planFilePath: "/tmp/plan.md",
|
||||
},
|
||||
};
|
||||
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
|
||||
expect(info.kind).toBe("switch_mode");
|
||||
expect(info.title).toBe("Ready to code?");
|
||||
expect(info.content).toHaveLength(1);
|
||||
expect(info.content![0]).toEqual({
|
||||
type: "content",
|
||||
content: { type: "text", text: "# My Plan\n\n## Step 1\nDo something" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty content when input.plan is not provided", () => {
|
||||
const toolUse = {
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_2",
|
||||
input: {},
|
||||
};
|
||||
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
|
||||
expect(info.kind).toBe("switch_mode");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
77
src/tools.ts
77
src/tools.ts
@@ -5,6 +5,19 @@ import {
|
||||
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,
|
||||
@@ -14,29 +27,31 @@ import {
|
||||
WebSearchToolResultError,
|
||||
} from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaBashCodeExecutionToolResultError,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionResultBlock,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultError,
|
||||
BetaImageBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionViewResultBlock,
|
||||
BetaTextEditorCodeExecutionCreateResultBlock,
|
||||
BetaTextEditorCodeExecutionStrReplaceResultBlock,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultError,
|
||||
BetaTextEditorCodeExecutionViewResultBlock,
|
||||
BetaToolReferenceBlock,
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaToolReferenceBlock,
|
||||
BetaToolSearchToolSearchResultBlock,
|
||||
BetaToolSearchToolResultError,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaToolSearchToolSearchResultBlock,
|
||||
BetaWebFetchBlock,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebFetchToolResultErrorBlock,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
BetaImageBlockParam,
|
||||
} 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.
|
||||
@@ -61,20 +76,6 @@ type ToolResultContent =
|
||||
| BetaTextEditorCodeExecutionCreateResultBlock
|
||||
| BetaTextEditorCodeExecutionStrReplaceResultBlock
|
||||
| BetaTextEditorCodeExecutionToolResultError;
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import {
|
||||
AgentInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -103,13 +104,29 @@ interface ToolUpdate {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
cwd?: string,
|
||||
): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
|
||||
switch (name) {
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
const input = toolUse.input as AgentInput | BashInput;
|
||||
return {
|
||||
@@ -153,8 +170,9 @@ export function toolInfoFromToolUse(
|
||||
} 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
|
||||
? [
|
||||
@@ -188,8 +206,9 @@ 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 }] : [],
|
||||
@@ -209,8 +228,9 @@ export function toolInfoFromToolUse(
|
||||
},
|
||||
];
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
|
||||
title: displayPath ? `Edit ${displayPath}` : "Edit",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
@@ -348,10 +368,13 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string };
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content: [],
|
||||
content: planInput?.plan
|
||||
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,7 +484,7 @@ export function toolUpdateFromToolResult(
|
||||
result.type === "bash_code_execution_result"
|
||||
) {
|
||||
const bashResult = result as BetaBashCodeExecutionResultBlock;
|
||||
output = bashResult.stdout || bashResult.stderr || "";
|
||||
output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n");
|
||||
exitCode = bashResult.return_code;
|
||||
} else if (typeof result === "string") {
|
||||
output = result;
|
||||
|
||||
Reference in New Issue
Block a user