mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0111786af2 | ||
|
|
aa82193330 | ||
|
|
26f3e8a521 | ||
|
|
769255c9a9 | ||
|
|
bea1a40e9b | ||
|
|
f9b15ba1c5 | ||
|
|
0990a8e520 | ||
|
|
15e91fb5c3 | ||
|
|
f422c7c954 | ||
|
|
063cd35380 | ||
|
|
a16145396f | ||
|
|
65a2230385 | ||
|
|
1d36b58cfb | ||
|
|
ee324b54dd | ||
|
|
d07799d7b3 | ||
|
|
7506223cff | ||
|
|
ae6c388314 | ||
|
|
320f4d70a1 | ||
|
|
e00a43901f | ||
|
|
93d87c2e71 | ||
|
|
fe94156852 | ||
|
|
3c6cb3208b | ||
|
|
cf16fabcc4 | ||
|
|
23b3073025 | ||
|
|
2bf75c1a9f | ||
|
|
27395a196d | ||
|
|
a7b3e3c97d | ||
|
|
886b9b3a75 | ||
|
|
f919d615eb | ||
|
|
f51f3a7937 | ||
|
|
b409782992 | ||
|
|
ce4a67ae33 | ||
|
|
5268661b7f | ||
|
|
9583f97536 | ||
|
|
95721e4e39 | ||
|
|
7f1c6c2078 | ||
|
|
3500b6c16e | ||
|
|
07db59ef0d | ||
|
|
34c847a9e3 | ||
|
|
d2428a49ef | ||
|
|
91d629d6c6 | ||
|
|
5fd7873c7c | ||
|
|
83496544b2 | ||
|
|
c5d5c11f40 |
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.25.3"
|
||||
}
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -1,5 +1,78 @@
|
||||
# Changelog
|
||||
|
||||
## [0.25.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.2...v0.25.3) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Drop claude-agent-sdk back to 0.2.91 to fix broken import ([#513](https://github.com/agentclientprotocol/claude-agent-acp/issues/513)) ([26f3e8a](https://github.com/agentclientprotocol/claude-agent-acp/commit/26f3e8a5216295985fadb80fb3b977045c0c1b2c))
|
||||
* Recreate resumed sessions when params change ([#515](https://github.com/agentclientprotocol/claude-agent-acp/issues/515)) ([aa82193](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa82193330026bae132fed8391c47dde777dcf5a))
|
||||
|
||||
## [0.25.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.1...v0.25.2) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prioritize ANTHROPIC_MODEL env var over settings.model in model … ([#505](https://github.com/agentclientprotocol/claude-agent-acp/issues/505)) ([bea1a40](https://github.com/agentclientprotocol/claude-agent-acp/commit/bea1a40e9bfe1e06672b118a727f9339def3be23))
|
||||
|
||||
## [0.25.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.0...v0.25.1) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add `auto` to valid modes in applySessionMode to fix mode cycling ([#507](https://github.com/agentclientprotocol/claude-agent-acp/issues/507)) ([15e91fb](https://github.com/agentclientprotocol/claude-agent-acp/commit/15e91fb5c3449de2600b583cb7a8d36b5b510443))
|
||||
|
||||
## [0.25.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.2...v0.25.0) (2026-04-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add auto permission mode support ([#501](https://github.com/agentclientprotocol/claude-agent-acp/issues/501)) ([a161453](https://github.com/agentclientprotocol/claude-agent-acp/commit/a16145396fe2e6ead8734478961b2de65707e335))
|
||||
* Add separate Claude and Console terminal logins ([#502](https://github.com/agentclientprotocol/claude-agent-acp/issues/502)) ([063cd35](https://github.com/agentclientprotocol/claude-agent-acp/commit/063cd353809df8f3dda9e57d8ea848c0a6d44257))
|
||||
* Update to claude-agent-sdk 0.2.91 ([#500](https://github.com/agentclientprotocol/claude-agent-acp/issues/500)) ([65a2230](https://github.com/agentclientprotocol/claude-agent-acp/commit/65a223038576d72b74e1483fed10e982a1f842bd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* log warnings for malformed settings files instead of silent fallback ([#486](https://github.com/agentclientprotocol/claude-agent-acp/issues/486)) ([ae6c388](https://github.com/agentclientprotocol/claude-agent-acp/commit/ae6c38831415f9fc1de2d3dd1d4a247becbbd32f))
|
||||
* prevent race conditions in SettingsManager setCwd and debounce ([#485](https://github.com/agentclientprotocol/claude-agent-acp/issues/485)) ([7506223](https://github.com/agentclientprotocol/claude-agent-acp/commit/7506223cffb1aba4b4560feda11f69a1395a8c9d))
|
||||
* use current model's context window for usage_update size ([#412](https://github.com/agentclientprotocol/claude-agent-acp/issues/412)) ([d07799d](https://github.com/agentclientprotocol/claude-agent-acp/commit/d07799d7b3b4e438c8b158266c79723a0b592c07))
|
||||
|
||||
## [0.24.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.1...v0.24.2) (2026-03-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add explicit type checks for MCP servers (http/sse) ([#487](https://github.com/agentclientprotocol/claude-agent-acp/issues/487)) ([e00a439](https://github.com/agentclientprotocol/claude-agent-acp/commit/e00a43901fa2b4fd7d582e1de57277be88233007))
|
||||
|
||||
## [0.24.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.0...v0.24.1) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cleanup based on new idle state [#463](https://github.com/agentclientprotocol/claude-agent-acp/issues/463) ([#480](https://github.com/agentclientprotocol/claude-agent-acp/issues/480)) ([23b3073](https://github.com/agentclientprotocol/claude-agent-acp/commit/23b30730253752f0bc4e30b619a6236f16fafdb9))
|
||||
|
||||
## 0.24.0
|
||||
|
||||
Rename from `@zed-industries/claude-agent-acp` to `@agentclientprotocol/claude-agent-acp`.
|
||||
|
||||
We are moving this to the main ACP org to better allow multiple teams to contribute and maintain this adapter.
|
||||
|
||||
## 0.23.1
|
||||
|
||||
- Add back error_during_execution break point (#469)
|
||||
|
||||
## 0.23.0
|
||||
|
||||
- Use idle session state as end of turn (#463)
|
||||
- Update claude-agent-sdk to 0.2.83 (#462)
|
||||
- Fix handling of local-only slash commands (#432)
|
||||
- fix: correct null check for gatewayAuthMeta in subscription validation (#455)
|
||||
- fix: include both stdout and stderr in Bash tool output (#456)
|
||||
- fix: prevent prompt loop hang when cancel races with first result (#458)
|
||||
- fix: dispose SettingsManager on session close to prevent resource leaks (#454)
|
||||
- fix: restore plan content in ExitPlanMode tool call (#451)
|
||||
|
||||
## 0.22.2
|
||||
|
||||
- Add experimental meta param for testing additional directories
|
||||
|
||||
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).
|
||||
|
||||
1654
package-lock.json
generated
1654
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@zed-industries/claude-agent-acp",
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.22.2",
|
||||
"version": "0.25.3",
|
||||
"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.16.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.76",
|
||||
"@agentclientprotocol/sdk": "0.18.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.91",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.78.0",
|
||||
"@anthropic-ai/sdk": "0.82.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"eslint": "10.0.3",
|
||||
"@types/node": "25.5.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.0",
|
||||
"@typescript-eslint/parser": "8.58.0",
|
||||
"eslint": "10.2.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.1.0"
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
318
src/acp-agent.ts
318
src/acp-agent.ts
@@ -40,6 +40,7 @@ import {
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
StopReason,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
CanUseTool,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
listSessions,
|
||||
McpServerConfig,
|
||||
ModelInfo,
|
||||
ModelUsage,
|
||||
Options,
|
||||
PermissionMode,
|
||||
Query,
|
||||
@@ -112,6 +114,9 @@ type Session = {
|
||||
input: Pushable<SDKUserMessage>;
|
||||
cancelled: boolean;
|
||||
cwd: string;
|
||||
/** Serialized snapshot of session-defining params (cwd, mcpServers) used to
|
||||
* detect when loadSession/resumeSession is called with changed values. */
|
||||
sessionFingerprint: string;
|
||||
settingsManager: SettingsManager;
|
||||
accumulatedUsage: AccumulatedUsage;
|
||||
modes: SessionModeState;
|
||||
@@ -123,6 +128,18 @@ type Session = {
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
* detect when a loadSession/resumeSession call requires tearing down and
|
||||
* recreating the underlying Query process. MCP servers are sorted by name
|
||||
* so that ordering differences don't trigger unnecessary recreations. */
|
||||
function computeSessionFingerprint(params: {
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
}): string {
|
||||
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
||||
}
|
||||
|
||||
type BackgroundTerminal =
|
||||
| {
|
||||
handle: TerminalHandle;
|
||||
@@ -227,7 +244,12 @@ 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> = {
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
acceptedits: "acceptEdits",
|
||||
dontask: "dontAsk",
|
||||
@@ -301,25 +323,42 @@ 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;
|
||||
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
||||
|
||||
const claudeLoginMethod: any = {
|
||||
description: "Use Claude subscription ",
|
||||
name: "Claude Subscription",
|
||||
id: "claude-ai-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
};
|
||||
|
||||
const consoleLoginMethod: any = {
|
||||
description: "Use Anthropic Console (API usage billing)",
|
||||
name: "Anthropic Console",
|
||||
id: "console-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
};
|
||||
|
||||
// If client supports terminal-auth capability, use that instead.
|
||||
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
||||
if (supportsMetaTerminalAuth) {
|
||||
terminalAuthMethod._meta = {
|
||||
const baseArgs = process.argv.slice(1);
|
||||
claudeLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...process.argv.slice(1), "--cli"],
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--claudeai"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
};
|
||||
consoleLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--console"],
|
||||
label: "Anthropic Console Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -353,8 +392,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
authMethods: [
|
||||
...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
|
||||
? [terminalAuthMethod]
|
||||
? [claudeLoginMethod]
|
||||
: []),
|
||||
...(supportsTerminalAuth || supportsMetaTerminalAuth ? [consoleLoginMethod] : []),
|
||||
...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
|
||||
],
|
||||
};
|
||||
@@ -463,13 +503,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
|
||||
let lastAssistantTotalUsage: number | null = null;
|
||||
let lastAssistantModel: string | null = null;
|
||||
let lastContextWindowSize: number = 200000;
|
||||
|
||||
const userMessage = promptToClaude(params);
|
||||
|
||||
const promptUuid = randomUUID();
|
||||
userMessage.uuid = promptUuid;
|
||||
|
||||
let promptReplayed = false;
|
||||
// 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) {
|
||||
session.input.push(userMessage);
|
||||
@@ -480,15 +527,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (cancelled) {
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
// The replay resolved the promise, mark in this loop too,
|
||||
// so we don't treat the next result as a background task's result.
|
||||
promptReplayed = true;
|
||||
} else {
|
||||
session.input.push(userMessage);
|
||||
}
|
||||
|
||||
session.promptRunning = true;
|
||||
let handedOff = false;
|
||||
let stopReason: StopReason = "end_turn";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
@@ -519,9 +564,26 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
case "compact_boundary": {
|
||||
// We don't know the exact size, but since we compacted,
|
||||
// we set it to zero. The client gets the exact size on the next message.
|
||||
// Send used:0 immediately so the client doesn't keep showing
|
||||
// the stale pre-compaction context size until the next turn.
|
||||
//
|
||||
// This is a deliberate approximation: we don't know the exact
|
||||
// post-compaction token count (only the SDK's next API call
|
||||
// reveals that). But used:0 is directionally correct — context
|
||||
// just dropped dramatically — and the real value replaces it
|
||||
// within seconds when the next result message arrives.
|
||||
// The alternative (no update) leaves the client showing e.g.
|
||||
// "944k/1m" right after the user sees "Compacting completed",
|
||||
// which is confusing and wrong.
|
||||
lastAssistantTotalUsage = 0;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 0,
|
||||
size: lastContextWindowSize,
|
||||
},
|
||||
});
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
@@ -529,7 +591,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
content: { type: "text", text: "\n\nCompacting completed." },
|
||||
},
|
||||
});
|
||||
promptReplayed = true;
|
||||
break;
|
||||
}
|
||||
case "local_command_output": {
|
||||
@@ -540,7 +601,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
content: { type: "text", text: message.content },
|
||||
},
|
||||
});
|
||||
promptReplayed = true;
|
||||
break;
|
||||
}
|
||||
case "session_state_changed": {
|
||||
if (message.state === "idle") {
|
||||
return { stopReason, usage: sessionUsage(session) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "hook_started":
|
||||
@@ -551,6 +617,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:
|
||||
@@ -565,10 +632,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
|
||||
session.accumulatedUsage.cachedWriteTokens += message.usage.cache_creation_input_tokens;
|
||||
|
||||
// Calculate context window size from modelUsage (minimum across all models used)
|
||||
const contextWindows = Object.values(message.modelUsage).map((m) => m.contextWindow);
|
||||
const contextWindowSize =
|
||||
contextWindows.length > 0 ? Math.min(...contextWindows) : 200000;
|
||||
const matchingModelUsage = lastAssistantModel
|
||||
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
||||
: null;
|
||||
const contextWindowSize = matchingModelUsage?.contextWindow ?? 200000;
|
||||
lastContextWindowSize = contextWindowSize;
|
||||
|
||||
// Send usage_update notification
|
||||
if (lastAssistantTotalUsage !== null) {
|
||||
@@ -586,47 +654,43 @@ export class ClaudeAcpAgent implements Agent {
|
||||
});
|
||||
}
|
||||
|
||||
if (!promptReplayed) {
|
||||
// This result is from a background task that finished after
|
||||
// the previous prompt loop ended. Consume it and continue
|
||||
// waiting for our own prompt's result.
|
||||
this.logger.log(`Session ${params.sessionId}: consuming background task result`);
|
||||
if (session.cancelled) {
|
||||
stopReason = "cancelled";
|
||||
break;
|
||||
}
|
||||
|
||||
if (session.cancelled) {
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
switch (message.subtype) {
|
||||
case "success": {
|
||||
if (message.result.includes("Please run /login")) {
|
||||
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(
|
||||
@@ -634,7 +698,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":
|
||||
@@ -644,7 +710,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;
|
||||
@@ -676,11 +743,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// Check for prompt replay
|
||||
if (message.type === "user" && "uuid" in message && message.uuid) {
|
||||
if (message.uuid === promptUuid) {
|
||||
// Our own prompt was replayed back — we're now processing
|
||||
// our prompt's response (not a background task's).
|
||||
promptReplayed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const pending = session.pendingMessages.get(message.uuid as string);
|
||||
if (pending) {
|
||||
pending.resolve(false);
|
||||
@@ -688,7 +753,7 @@ 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
|
||||
@@ -697,6 +762,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
|
||||
// Store latest assistant usage (excluding subagents)
|
||||
// Sum all token types as a proxy for post-turn context occupancy:
|
||||
// current turn's output will become next turn's input.
|
||||
// Note: per the Anthropic API, input_tokens excludes cache tokens —
|
||||
// cache_read and cache_creation are reported separately, so summing
|
||||
// all four fields is not double-counting.
|
||||
if ((message.message as any).usage && message.parent_tool_use_id === null) {
|
||||
const messageWithUsage = message.message as unknown as SDKResultMessage;
|
||||
lastAssistantTotalUsage =
|
||||
@@ -705,6 +775,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
messageWithUsage.usage.cache_read_input_tokens +
|
||||
messageWithUsage.usage.cache_creation_input_tokens;
|
||||
}
|
||||
// Track the current top-level model for context window size lookup
|
||||
// (exclude subagent messages to stay in sync with lastAssistantTotalUsage)
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
message.parent_tool_use_id === null &&
|
||||
message.message.model &&
|
||||
message.message.model !== "<synthetic>"
|
||||
) {
|
||||
lastAssistantModel = message.message.model;
|
||||
}
|
||||
|
||||
// Slash commands like /compact can generate invalid output... doesn't match
|
||||
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
|
||||
@@ -712,22 +792,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;
|
||||
}
|
||||
@@ -811,6 +875,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,16 +916,24 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await session.query.interrupt();
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
const session = this.sessions[params.sessionId];
|
||||
/** Cleanly tear down a session: cancel in-flight work, dispose resources,
|
||||
* and remove it from the session map. */
|
||||
private async teardownSession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
await this.cancel({ sessionId });
|
||||
session.settingsManager.dispose();
|
||||
session.abortController.abort();
|
||||
delete this.sessions[sessionId];
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
await this.cancel({ sessionId: params.sessionId });
|
||||
|
||||
session.abortController.abort();
|
||||
delete this.sessions[params.sessionId];
|
||||
|
||||
await this.teardownSession(params.sessionId);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -955,6 +1028,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
private async applySessionMode(sessionId: string, modeId: string): Promise<void> {
|
||||
switch (modeId) {
|
||||
case "auto":
|
||||
case "default":
|
||||
case "acceptEdits":
|
||||
case "bypassPermissions":
|
||||
@@ -1208,12 +1282,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}): Promise<NewSessionResponse> {
|
||||
const existingSession = this.sessions[params.sessionId];
|
||||
if (existingSession) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
};
|
||||
const fingerprint = computeSessionFingerprint(params);
|
||||
if (fingerprint === existingSession.sessionFingerprint) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
// Session-defining params changed (e.g. cwd pointed at a git worktree,
|
||||
// or MCP servers reconfigured). Tear down the existing session and
|
||||
// recreate it so the underlying Query process picks up the new values.
|
||||
await this.teardownSession(params.sessionId);
|
||||
}
|
||||
|
||||
const response = await this.createSession(
|
||||
@@ -1260,7 +1342,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,
|
||||
@@ -1269,6 +1352,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,
|
||||
@@ -1330,6 +1414,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,
|
||||
@@ -1417,7 +1503,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (
|
||||
shouldHideClaudeAuth() &&
|
||||
initializationResult.account.subscriptionType &&
|
||||
this.gatewayAuthMeta === null
|
||||
!this.gatewayAuthMeta
|
||||
) {
|
||||
throw RequestError.authRequired(
|
||||
undefined,
|
||||
@@ -1428,6 +1514,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
|
||||
|
||||
const availableModes = [
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
decription: "Use a model classifier to approve/deny permission prompts.",
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
@@ -1470,6 +1561,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
input: input,
|
||||
cancelled: false,
|
||||
cwd: params.cwd,
|
||||
sessionFingerprint: computeSessionFingerprint(params),
|
||||
settingsManager,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -1495,6 +1587,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 {};
|
||||
@@ -1624,7 +1730,16 @@ async function getAvailableModels(
|
||||
|
||||
let currentModel = models[0];
|
||||
|
||||
if (settings.model) {
|
||||
// Model priority (highest to lowest):
|
||||
// 1. ANTHROPIC_MODEL environment variable
|
||||
// 2. settings.model (user configuration)
|
||||
// 3. models[0] (default first model)
|
||||
if (process.env.ANTHROPIC_MODEL) {
|
||||
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
}
|
||||
} else if (settings.model) {
|
||||
const match = resolveModelPreference(models, settings.model);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
@@ -1702,10 +1817,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;
|
||||
@@ -2095,3 +2210,28 @@ export function runAcp() {
|
||||
const stream = ndJsonStream(input, output);
|
||||
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
|
||||
}
|
||||
|
||||
function commonPrefixLength(a: string, b: string) {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function getMatchingModelUsage(modelUsage: Record<string, ModelUsage>, currentModel: string) {
|
||||
let bestKey: string | null = null;
|
||||
let bestLen = 0;
|
||||
|
||||
for (const key of Object.keys(modelUsage)) {
|
||||
const len = commonPrefixLength(key, currentModel);
|
||||
if (len > bestLen) {
|
||||
bestLen = len;
|
||||
bestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestKey) {
|
||||
return modelUsage[bestKey];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,14 @@ export interface ClaudeCodeSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid.
|
||||
* Silently ignores missing files (ENOENT) but logs warnings for other errors
|
||||
* (malformed JSON, permission errors, etc.) to aid debugging.
|
||||
*/
|
||||
async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSettings> {
|
||||
async function loadSettingsFile(
|
||||
filePath: string | null,
|
||||
logger?: { error: (...args: any[]) => void },
|
||||
): Promise<ClaudeCodeSettings> {
|
||||
if (!filePath) {
|
||||
return {};
|
||||
}
|
||||
@@ -35,7 +40,11 @@ async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSett
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
logger?.error(`Failed to load settings from ${filePath}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -83,7 +92,9 @@ export class SettingsManager {
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
private initialized = false;
|
||||
private disposed = false;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(cwd: string, options?: SettingsManagerOptions) {
|
||||
this.cwd = cwd;
|
||||
@@ -98,10 +109,19 @@ export class SettingsManager {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
await this.loadAllSettings();
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
this.disposed = false;
|
||||
this.initPromise = this.loadAllSettings().then(() => {
|
||||
if (!this.disposed) {
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
}
|
||||
this.initPromise = null;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,10 +150,10 @@ export class SettingsManager {
|
||||
*/
|
||||
private async loadAllSettings(): Promise<void> {
|
||||
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
|
||||
loadSettingsFile(this.getUserSettingsPath()),
|
||||
loadSettingsFile(this.getProjectSettingsPath()),
|
||||
loadSettingsFile(this.getLocalSettingsPath()),
|
||||
loadSettingsFile(getManagedSettingsPath()),
|
||||
loadSettingsFile(this.getUserSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getProjectSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getLocalSettingsPath(), this.logger),
|
||||
loadSettingsFile(getManagedSettingsPath(), this.logger),
|
||||
]);
|
||||
|
||||
this.userSettings = userSettings;
|
||||
@@ -226,9 +246,14 @@ export class SettingsManager {
|
||||
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
this.debounceTimer = null;
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.loadAllSettings();
|
||||
this.onChange?.();
|
||||
if (!this.disposed) {
|
||||
this.onChange?.();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to reload settings:", error);
|
||||
}
|
||||
@@ -259,7 +284,6 @@ export class SettingsManager {
|
||||
|
||||
this.dispose();
|
||||
this.cwd = cwd;
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
@@ -267,6 +291,10 @@ export class SettingsManager {
|
||||
* Disposes of file watchers and cleans up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.initialized = false;
|
||||
this.initPromise = null;
|
||||
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
@@ -276,6 +304,5 @@ export class SettingsManager {
|
||||
watcher.close();
|
||||
}
|
||||
this.watchers = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1328,6 +1328,7 @@ describe("stop reason propagation", () => {
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
@@ -1336,7 +1337,7 @@ describe("stop reason propagation", () => {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: {} as any,
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
@@ -1355,6 +1356,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({
|
||||
@@ -1374,6 +1376,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({
|
||||
@@ -1393,6 +1396,7 @@ describe("stop reason propagation", () => {
|
||||
is_error: true,
|
||||
errors: ["some error"],
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1407,6 +1411,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({
|
||||
@@ -1455,12 +1460,14 @@ describe("stop reason propagation", () => {
|
||||
|
||||
// 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",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/tmp/test", mcpServers: [] }),
|
||||
cancelled: false,
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
@@ -1470,7 +1477,7 @@ describe("stop reason propagation", () => {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: {} as any,
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
@@ -1535,6 +1542,7 @@ describe("session/close", () => {
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
@@ -1543,7 +1551,7 @@ describe("session/close", () => {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: {} as any,
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
@@ -1570,6 +1578,7 @@ describe("session/close", () => {
|
||||
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 () => {
|
||||
@@ -1602,3 +1611,582 @@ describe("session/close", () => {
|
||||
expect(agent.sessions["session-b"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrCreateSession param change detection", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function injectSession(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
opts: { cwd?: string; mcpServers?: { name: string }[] } = {},
|
||||
) {
|
||||
const cwd = opts.cwd ?? "/test";
|
||||
const mcpServers = (opts.mcpServers ?? []) as any[];
|
||||
function* empty() {}
|
||||
const gen = Object.assign(empty(), {
|
||||
interrupt: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd,
|
||||
sessionFingerprint: JSON.stringify({
|
||||
cwd,
|
||||
mcpServers: [...mcpServers].sort((a: any, b: any) => a.name.localeCompare(b.name)),
|
||||
}),
|
||||
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("returns cached session when params are unchanged", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/project" });
|
||||
|
||||
await agent.unstable_resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
// Session object should be the exact same reference (not recreated)
|
||||
expect(agent.sessions["s1"]).toBe(session);
|
||||
expect(session.settingsManager.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tears down existing session when cwd changes", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/old" });
|
||||
|
||||
// Mock createSession to avoid spawning a real process.
|
||||
// It will throw, but we can catch that — we only need to verify
|
||||
// the old session was torn down before createSession was attempted.
|
||||
const createSessionSpy = vi
|
||||
.spyOn(agent as any, "createSession")
|
||||
.mockRejectedValue(new Error("mock"));
|
||||
|
||||
await expect(
|
||||
agent.unstable_resumeSession({ sessionId: "s1", cwd: "/new", mcpServers: [] }),
|
||||
).rejects.toThrow("mock");
|
||||
|
||||
// Old session should have been fully torn down
|
||||
expect(session.settingsManager.dispose).toHaveBeenCalled();
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
expect(session.query.interrupt).toHaveBeenCalled();
|
||||
expect(agent.sessions["s1"]).toBeUndefined();
|
||||
|
||||
// createSession should have been called with the new cwd
|
||||
expect(createSessionSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/new" }),
|
||||
expect.objectContaining({ resume: "s1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("tears down existing session when mcpServers change", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/project" });
|
||||
|
||||
const createSessionSpy = vi
|
||||
.spyOn(agent as any, "createSession")
|
||||
.mockRejectedValue(new Error("mock"));
|
||||
|
||||
await expect(
|
||||
agent.unstable_resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [{ name: "new-server", command: "node", args: ["server.js"], env: [] }],
|
||||
}),
|
||||
).rejects.toThrow("mock");
|
||||
|
||||
expect(session.settingsManager.dispose).toHaveBeenCalled();
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
expect(agent.sessions["s1"]).toBeUndefined();
|
||||
expect(createSessionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats mcpServers in different order as unchanged", async () => {
|
||||
const agent = createMockAgent();
|
||||
const servers = [
|
||||
{ name: "b-server", command: "node", args: ["b.js"], env: [] },
|
||||
{ name: "a-server", command: "node", args: ["a.js"], env: [] },
|
||||
] as const;
|
||||
const session = injectSession(agent, "s1", {
|
||||
cwd: "/project",
|
||||
mcpServers: servers as any,
|
||||
});
|
||||
|
||||
// Same servers but reversed order — should NOT trigger teardown
|
||||
await agent.unstable_resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [...servers].reverse() as any,
|
||||
});
|
||||
|
||||
expect(agent.sessions["s1"]).toBe(session);
|
||||
expect(session.settingsManager.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage_update computation", () => {
|
||||
function createAssistantMessage(overrides: {
|
||||
model: string;
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
model: overrides.model,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
usage: overrides.usage ?? {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 20,
|
||||
cache_creation_input_tokens: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResultMessageWithModel(overrides: {
|
||||
modelUsage: Record<
|
||||
string,
|
||||
{
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
webSearchRequests: number;
|
||||
costUSD: number;
|
||||
contextWindow: number;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
>;
|
||||
}) {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0.01,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: overrides.modelUsage,
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
function createMockAgentWithCapture() {
|
||||
const updates: any[] = [];
|
||||
const mockClient = {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates };
|
||||
}
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
|
||||
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: messageGenerator() as any,
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: {} as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
}
|
||||
|
||||
it("used sums all token types as post-turn context occupancy proxy", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({
|
||||
model: "claude-opus-4-20250514",
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_input_tokens: 200,
|
||||
cache_creation_input_tokens: 100,
|
||||
},
|
||||
}),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
cacheReadInputTokens: 200,
|
||||
cacheCreationInputTokens: 100,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// used = input(1000) + output(500) + cache_read(200) + cache_creation(100) = 1800
|
||||
expect(usageUpdate.update.used).toBe(1800);
|
||||
});
|
||||
|
||||
it("size reflects the current model's context window, not min across all", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-20250514" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
inputTokens: 50,
|
||||
outputTokens: 25,
|
||||
cacheReadInputTokens: 10,
|
||||
cacheCreationInputTokens: 5,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.005,
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 1000000 (Opus), not 200000 (min of both)
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("after model switch, size updates to the new model's window", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Simulate: assistant on Sonnet with both models in modelUsage
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-sonnet-4-20250514" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
inputTokens: 50,
|
||||
outputTokens: 25,
|
||||
cacheReadInputTokens: 10,
|
||||
cacheCreationInputTokens: 5,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.005,
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 200000 (Sonnet - the current model)
|
||||
expect(usageUpdate.update.size).toBe(200000);
|
||||
});
|
||||
|
||||
it("after switching back to original model, size returns to original window", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Last assistant message is Opus again
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-sonnet-4-20250514" }),
|
||||
createAssistantMessage({ model: "claude-opus-4-20250514" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 200,
|
||||
outputTokens: 100,
|
||||
cacheReadInputTokens: 40,
|
||||
cacheCreationInputTokens: 20,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.02,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
inputTokens: 50,
|
||||
outputTokens: 25,
|
||||
cacheReadInputTokens: 10,
|
||||
cacheCreationInputTokens: 5,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.005,
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 1000000 (Opus - switched back)
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("subagent assistant messages do not affect size (top-level model is used)", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Top-level assistant on Opus, then subagent on Haiku (parent_tool_use_id set)
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-20250514" }),
|
||||
{
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: "tool_use_123",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
content: [{ type: "text", text: "subagent response" }],
|
||||
usage: {
|
||||
input_tokens: 50,
|
||||
output_tokens: 25,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
"claude-haiku-4-5-20251001": {
|
||||
inputTokens: 50,
|
||||
outputTokens: 25,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.001,
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 1000000 (Opus - the top-level model), NOT 200000 (Haiku subagent)
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("prefix-matches when assistant model has date suffix but modelUsage key does not", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// The API response has the full versioned model ID on assistant messages,
|
||||
// but the SDK's streaming path may key modelUsage by the shorter alias.
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-6-20250514" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-6": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// Should match via prefix: "claude-opus-4-6-20250514".startsWith("claude-opus-4-6")
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("prefix-matches when modelUsage key has date suffix but assistant model does not", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-6" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-6-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("synthetic assistant messages do not override lastAssistantModel", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Real assistant on Opus, then a synthetic message (e.g. from /compact)
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-20250514" }),
|
||||
{
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
model: "<synthetic>",
|
||||
content: [{ type: "text", text: "compacted" }],
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 1000000 (Opus), not 200000 (the fallback if <synthetic> overrode the model)
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -123,14 +123,17 @@ describe("authorization", () => {
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide terminal auth even when terminal-auth is set", async () => {
|
||||
it("hide claude auth but still show console login when terminal-auth is set", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
@@ -140,7 +143,30 @@ describe("authorization", () => {
|
||||
_meta: { "terminal-auth": true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).toStrictEqual([]);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude auth but still show console login with terminal capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { terminal: true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("show claude authentication", async () => {
|
||||
@@ -152,7 +178,10 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -368,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 } }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -481,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