mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4dba808ea | ||
|
|
de0d0e2b7d | ||
|
|
35aaddb3d5 | ||
|
|
1f09e9a3ca | ||
|
|
ec73cd8560 | ||
|
|
8157ee113e | ||
|
|
3df1ede89f | ||
|
|
1afa940a2c | ||
|
|
6b405138fc | ||
|
|
8aaf608b4e | ||
|
|
f8fd3ab822 | ||
|
|
133337ffe5 | ||
|
|
8b31dea11b | ||
|
|
bba912728f | ||
|
|
7f27c47c5c | ||
|
|
6d608cb399 | ||
|
|
a84b81080a | ||
|
|
b965dd2191 | ||
|
|
0988438428 | ||
|
|
15b4eb46f3 | ||
|
|
fc95717b38 | ||
|
|
f9123f3e18 | ||
|
|
88da9d800f | ||
|
|
08e0e7f14e | ||
|
|
11a3193574 | ||
|
|
c98141201e | ||
|
|
76009f1ede | ||
|
|
fd60fc2b31 | ||
|
|
4302a4b0b6 | ||
|
|
faa04bd0ca | ||
|
|
0936ec281e | ||
|
|
9e50dfadee | ||
|
|
08a62ed537 | ||
|
|
e56f344691 | ||
|
|
43af4ec29e | ||
|
|
d7a65ce1d0 | ||
|
|
3f60921959 | ||
|
|
8e099e8442 | ||
|
|
15979bba79 | ||
|
|
8f67b6a92b | ||
|
|
5559ba890c | ||
|
|
d0604140f9 | ||
|
|
4c7b897183 | ||
|
|
8663170f18 | ||
|
|
53a0c36ce3 | ||
|
|
c91f943f86 | ||
|
|
5d35001563 | ||
|
|
51936049e1 | ||
|
|
14d06273c0 | ||
|
|
8cbaf97254 | ||
|
|
809d41c6b7 | ||
|
|
c19bddcf79 | ||
|
|
edf3af043b | ||
|
|
980ac8dc56 | ||
|
|
d9bd36d8b0 | ||
|
|
0a88de2a83 | ||
|
|
ff9b96d462 | ||
|
|
1887ada215 | ||
|
|
06c3d7bdbd | ||
|
|
92548f0435 | ||
|
|
cc5d5e8b80 | ||
|
|
82cd692e50 | ||
|
|
30b7c06f76 | ||
|
|
c7f5b8fe76 | ||
|
|
7a70f82739 | ||
|
|
e67dacdcac | ||
|
|
61ae8609d4 | ||
|
|
2c19974d5b | ||
|
|
c7dff3cf7e | ||
|
|
3ba2d367a2 | ||
|
|
f4e750decf | ||
|
|
9cd48c597a | ||
|
|
3500ef736f | ||
|
|
8b366d8e9a | ||
|
|
5144aabc52 | ||
|
|
f664ced76d | ||
|
|
e944ceddea | ||
|
|
32b93501d7 | ||
|
|
f08c4521ec | ||
|
|
f3d8ae3eb3 | ||
|
|
b03318f59c | ||
|
|
2bf865eb42 | ||
|
|
1612c07895 | ||
|
|
d74dd1d9d3 | ||
|
|
f1bf4da891 | ||
|
|
837989ef96 | ||
|
|
ff2a8a9635 | ||
|
|
6e0179288a | ||
|
|
307ab82712 | ||
|
|
57b00ccac6 | ||
|
|
15dfd83daa | ||
|
|
648e3f60bd | ||
|
|
8d5febf854 | ||
|
|
15ec9ab0dc | ||
|
|
98c284bb87 | ||
|
|
71d93b3ada | ||
|
|
ef42c46e5a | ||
|
|
fa949a20db | ||
|
|
b8df8e0e54 | ||
|
|
64aa130c66 | ||
|
|
47fee47709 | ||
|
|
c95fc884e8 | ||
|
|
5dd8746237 | ||
|
|
0a0468c720 | ||
|
|
e9163f8553 | ||
|
|
61272acb30 | ||
|
|
9616bdac47 | ||
|
|
1fe7ec09a3 | ||
|
|
228f02ecfb | ||
|
|
23626c9a43 | ||
|
|
8f6ebd1d91 | ||
|
|
07911601cc | ||
|
|
a7e6137f68 | ||
|
|
044c43e0c8 | ||
|
|
9013d1d468 | ||
|
|
d5e1b7fa72 | ||
|
|
960f62d765 | ||
|
|
508453c288 | ||
|
|
8dc8c86426 | ||
|
|
b4b7f561e2 | ||
|
|
41cde99fd7 | ||
|
|
5729c471f9 | ||
|
|
a759e64ef6 | ||
|
|
15fdf26fc7 | ||
|
|
9f38cb67df | ||
|
|
744b2d4112 | ||
|
|
2554c7bf98 | ||
|
|
1c80bf8e56 | ||
|
|
ad3b5fe745 | ||
|
|
794aa84684 | ||
|
|
12d34e64e5 | ||
|
|
59a098c2b5 | ||
|
|
c27e6aec90 | ||
|
|
4f273a20d8 | ||
|
|
b3640599ae | ||
|
|
58549ffe6a | ||
|
|
116f06eab1 | ||
|
|
db6eaaf714 | ||
|
|
d58004a348 | ||
|
|
0525e6dacb | ||
|
|
cc2885f6a9 | ||
|
|
1be5ca57ee | ||
|
|
7de5e4bcca |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "npm"
|
||||
|
||||
48
.github/workflows/conventional-prs.yml
vendored
Normal file
48
.github/workflows/conventional-prs.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Conventional PRs
|
||||
|
||||
# Squash merges use the PR title as the commit subject, which is what
|
||||
# release-please parses to compute the next version and CHANGELOG entry.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# labeled/unlabeled so validation re-runs when `dependencies` is applied.
|
||||
types: [opened, reopened, edited, synchronize, labeled, unlabeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: "conventional-prs-${{ github.event.pull_request.number }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
# Config reference: https://github.com/amannn/action-semantic-pull-request#configuration
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
# Exactly the types release-please understands. feat/fix/perf/revert/docs
|
||||
# appear in the changelog; the rest are valid but hidden. Any other type
|
||||
# would be dropped silently, so it is rejected here instead.
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
perf
|
||||
revert
|
||||
docs
|
||||
style
|
||||
chore
|
||||
refactor
|
||||
test
|
||||
build
|
||||
ci
|
||||
# Dependabot titles itself conventionally already; don't block bot PRs.
|
||||
ignoreLabels: |
|
||||
dependencies
|
||||
54
.github/workflows/publish.yml
vendored
54
.github/workflows/publish.yml
vendored
@@ -3,9 +3,21 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Tag or commit to publish
|
||||
required: true
|
||||
type: string
|
||||
publish_npm:
|
||||
description: Publish the package before updating the registry
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
@@ -27,7 +39,11 @@ jobs:
|
||||
id: release
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
release-type: node
|
||||
# Do not add release-type here. The action ignores config-file whenever
|
||||
# release-type is set, which silently disables the pre-1.0 versioning
|
||||
# rules in release-please-config.json and lets a breaking change jump
|
||||
# straight to 1.0.0. The node release type is declared in that config
|
||||
# instead, so nothing is lost by leaving this out.
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
outputs:
|
||||
@@ -38,20 +54,46 @@ jobs:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }}
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
|
||||
trigger-registry-update:
|
||||
needs: publish-npm
|
||||
if: ${{ always() && (needs.publish-npm.result == 'success' || (github.event_name == 'workflow_dispatch' && !inputs.publish_npm)) }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Generate token scoped to the registry repo
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
id: registry-token
|
||||
with:
|
||||
app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }}
|
||||
private-key: ${{ secrets.REGISTRY_UPDATER_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: registry
|
||||
|
||||
- name: Dispatch registry version update
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.registry-token.outputs.token }}
|
||||
run: |
|
||||
gh workflow run update-versions.yml \
|
||||
--repo ${{ github.repository_owner }}/registry \
|
||||
--ref main \
|
||||
-f apply=true \
|
||||
-f agents=claude-acp
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist
|
||||
.DS_Store
|
||||
.claude/*.local.*
|
||||
CLAUDE.local.md
|
||||
.idea/
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.44.0"
|
||||
".": "0.67.0"
|
||||
}
|
||||
|
||||
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Agent guide
|
||||
|
||||
An ACP-compatible coding agent powered by the Claude Agent SDK. Source in `src/`,
|
||||
TypeScript, ESM, Node >= 22.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm run build # tsc
|
||||
npm run test:run # vitest, single pass
|
||||
npm run check # eslint + prettier --check
|
||||
npm run lint:fix # eslint --fix
|
||||
npm run format # prettier --write
|
||||
```
|
||||
|
||||
CI runs `format:check`, `lint`, `build` and `test:run`. Run `npm run check` before
|
||||
opening a PR.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Squash merges use the PR title as the commit subject, and release-please parses it
|
||||
to compute the next version. Titles must be conventional commits using one of:
|
||||
`feat`, `fix`, `perf`, `revert`, `docs`, `style`, `chore`, `refactor`, `test`,
|
||||
`build`, `ci`. `conventional-prs.yml` rejects anything else.
|
||||
|
||||
The title also decides the release: `feat:` bumps the minor, `fix:`/`perf:` the
|
||||
patch, and `chore:`/`ci:`/`test:` and friends do not release at all.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are fully automated by release-please. There is no manual release
|
||||
workflow, and the version is never chosen by hand — it follows from the commit
|
||||
history.
|
||||
|
||||
```sh
|
||||
npm run release:preflight # verifies it is safe to release, prints the PR and version
|
||||
gh pr merge <pr-number> --squash
|
||||
```
|
||||
|
||||
The preflight is the guard-list as code; if it exits non-zero, follow what it
|
||||
prints rather than merging. Full runbook, including how to recover a stalled
|
||||
release: [`docs/RELEASES.md`](docs/RELEASES.md).
|
||||
322
CHANGELOG.md
322
CHANGELOG.md
@@ -1,5 +1,327 @@
|
||||
# Changelog
|
||||
|
||||
## [0.67.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.66.0...v0.67.0) (2026-08-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Update to @anthropic-ai/claude-agent-sdk v0.3.232 ([#993](https://github.com/agentclientprotocol/claude-agent-acp/issues/993)) ([de0d0e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/de0d0e2b7d185c521c3d1ac7b86e4312c919abfe))
|
||||
* expose typed session failures for AIR ([#979](https://github.com/agentclientprotocol/claude-agent-acp/issues/979)) ([8157ee1](https://github.com/agentclientprotocol/claude-agent-acp/commit/8157ee113e705750be4eb6cc787bdd12f1db84ff))
|
||||
* publish the model fallback as a warning advisory ([#990](https://github.com/agentclientprotocol/claude-agent-acp/issues/990)) ([35aaddb](https://github.com/agentclientprotocol/claude-agent-acp/commit/35aaddb3d5b14eca316582ab285af8a660150dae))
|
||||
* surface resolved model name in default model option description ([#982](https://github.com/agentclientprotocol/claude-agent-acp/issues/982)) ([ec73cd8](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec73cd8560be7d5e8b9741e404d7d45e17336996))
|
||||
* surface Skill tool calls with name and kind in _meta ([#986](https://github.com/agentclientprotocol/claude-agent-acp/issues/986)) ([1f09e9a](https://github.com/agentclientprotocol/claude-agent-acp/commit/1f09e9a3cae787b0effdfae0e7205ef7fe22b9dc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preserve task plans across prompts ([#974](https://github.com/agentclientprotocol/claude-agent-acp/issues/974)) ([1afa940](https://github.com/agentclientprotocol/claude-agent-acp/commit/1afa940a2c8c0f4c610f4f64d30c0961642907b0))
|
||||
* show a pending title while Claude prepares a file ([#978](https://github.com/agentclientprotocol/claude-agent-acp/issues/978)) ([3df1ede](https://github.com/agentclientprotocol/claude-agent-acp/commit/3df1ede89f217312bc237124dc1eccc10c860f99))
|
||||
|
||||
## [0.66.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.65.0...v0.66.0) (2026-08-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** Bump globals from 17.8.0 to 17.9.0 in the minor group ([#960](https://github.com/agentclientprotocol/claude-agent-acp/issues/960)) ([7f27c47](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f27c47c5c7c49e65014e9f7dc55cba17352d33b))
|
||||
* expose provider-neutral ACP goal extension ([#964](https://github.com/agentclientprotocol/claude-agent-acp/issues/964)) ([8b31dea](https://github.com/agentclientprotocol/claude-agent-acp/commit/8b31dea11bed54f86c41217759159c415611346c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* publish and replace Claude goals reliably ([#967](https://github.com/agentclientprotocol/claude-agent-acp/issues/967)) ([f8fd3ab](https://github.com/agentclientprotocol/claude-agent-acp/commit/f8fd3ab8224420f8ced570e974cde09612939d6b))
|
||||
|
||||
## [0.65.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.64.2...v0.65.0) (2026-08-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** Bump nanoid from 3.3.16 to 3.3.17 ([#951](https://github.com/agentclientprotocol/claude-agent-acp/issues/951)) ([b965dd2](https://github.com/agentclientprotocol/claude-agent-acp/commit/b965dd21917e822b56f5012c3572902f26c065c9))
|
||||
* **deps-dev:** Bump tinyexec from 1.2.4 to 1.3.0 in the minor group ([#959](https://github.com/agentclientprotocol/claude-agent-acp/issues/959)) ([15b4eb4](https://github.com/agentclientprotocol/claude-agent-acp/commit/15b4eb46f329566837eae58f2ee4b05e3e81bf64))
|
||||
* **deps:** Bump @hono/node-server from 1.19.17 to 2.1.0 ([#956](https://github.com/agentclientprotocol/claude-agent-acp/issues/956)) ([f9123f3](https://github.com/agentclientprotocol/claude-agent-acp/commit/f9123f3e18560b580398aabf49e2190f69746976))
|
||||
* **deps:** Bump fast-uri from 3.1.4 to 3.1.5 ([#952](https://github.com/agentclientprotocol/claude-agent-acp/issues/952)) ([0988438](https://github.com/agentclientprotocol/claude-agent-acp/commit/098843842895dcb450746bd064dbb1509e3049d1))
|
||||
* **steering:** settle a steered turn at idle, not at the interrupt ([#958](https://github.com/agentclientprotocol/claude-agent-acp/issues/958)) ([a84b810](https://github.com/agentclientprotocol/claude-agent-acp/commit/a84b81080a4127edf40bc448fc8bf2b15503304d))
|
||||
|
||||
## [0.64.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.64.1...v0.64.2) (2026-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* restore the single-tool representation for ExitPlanMode ([#942](https://github.com/agentclientprotocol/claude-agent-acp/issues/942)) ([4302a4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/4302a4b0b6df821b164cbe4857f26cf5b44b532c))
|
||||
|
||||
## [0.64.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.64.0...v0.64.1) (2026-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release 0.65.0 ([#939](https://github.com/agentclientprotocol/claude-agent-acp/issues/939)) ([0936ec2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0936ec281ec730714c605e3da732069ff47d8969))
|
||||
|
||||
## [0.64.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.63.0...v0.64.0) (2026-07-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump actions/checkout from 7.0.0 to 7.0.1 ([#925](https://github.com/agentclientprotocol/claude-agent-acp/issues/925)) ([8e099e8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8e099e844254c3e91508c79a02e3e7dc2239fcbb))
|
||||
* **deps:** Bump the minor group with 7 updates ([#928](https://github.com/agentclientprotocol/claude-agent-acp/issues/928)) ([3f60921](https://github.com/agentclientprotocol/claude-agent-acp/commit/3f609219592e63b947539f79c696b3cedb421060))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **steering:** add opt-in host-owned fallback ([#919](https://github.com/agentclientprotocol/claude-agent-acp/issues/919)) ([43af4ec](https://github.com/agentclientprotocol/claude-agent-acp/commit/43af4ec29ea5396c2614813af05967bfb0b1bac8)), closes [#903](https://github.com/agentclientprotocol/claude-agent-acp/issues/903)
|
||||
|
||||
## [0.63.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.62.0...v0.63.0) (2026-07-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude agent sdk v0.3.220 ([#921](https://github.com/agentclientprotocol/claude-agent-acp/issues/921)) ([4c7b897](https://github.com/agentclientprotocol/claude-agent-acp/commit/4c7b89718306229254879e5045a183233b5ed073))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Only resolve a denied tool call the client was told about ([#923](https://github.com/agentclientprotocol/claude-agent-acp/issues/923)) ([8f67b6a](https://github.com/agentclientprotocol/claude-agent-acp/commit/8f67b6a92bec24ae43b3dfbd087fe35df0531857)), closes [#918](https://github.com/agentclientprotocol/claude-agent-acp/issues/918)
|
||||
* Report tool_progress heartbeats against the tool call they describe ([#916](https://github.com/agentclientprotocol/claude-agent-acp/issues/916)) ([5559ba8](https://github.com/agentclientprotocol/claude-agent-acp/commit/5559ba890ca614cdaa189500aba65d81cc4cd51a))
|
||||
* **tools:** key Bash terminal metas off the announced tool_use id ([#917](https://github.com/agentclientprotocol/claude-agent-acp/issues/917)) ([d060414](https://github.com/agentclientprotocol/claude-agent-acp/commit/d0604140f907adbf9747f26a070690926e1de82d))
|
||||
|
||||
## [0.62.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.61.0...v0.62.0) (2026-07-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @hono/node-server from 1.19.14 to 1.19.15 ([#908](https://github.com/agentclientprotocol/claude-agent-acp/issues/908)) ([5193604](https://github.com/agentclientprotocol/claude-agent-acp/commit/51936049e175274de8e0fd90bc1be2af988c3ca6))
|
||||
* **deps:** Bump media-typer from 1.1.0 to 1.1.1 ([#909](https://github.com/agentclientprotocol/claude-agent-acp/issues/909)) ([5d35001](https://github.com/agentclientprotocol/claude-agent-acp/commit/5d35001563ffd702a29c3bac3b7ca134e40c77f8))
|
||||
* **deps:** Bump the minor group with 2 updates ([#900](https://github.com/agentclientprotocol/claude-agent-acp/issues/900)) ([809d41c](https://github.com/agentclientprotocol/claude-agent-acp/commit/809d41c6b7c9e7ba3cb5b206d00793a70edba64a))
|
||||
* **deps:** Bump the minor group with 2 updates ([#907](https://github.com/agentclientprotocol/claude-agent-acp/issues/907)) ([14d0627](https://github.com/agentclientprotocol/claude-agent-acp/commit/14d06273c01ad4ae944912e36700f6b5599c4c4d))
|
||||
* Update to claude-agent-sdk 0.3.218 ([#904](https://github.com/agentclientprotocol/claude-agent-acp/issues/904)) ([8cbaf97](https://github.com/agentclientprotocol/claude-agent-acp/commit/8cbaf97254576089a3b5ee6ae222fb763003c01d))
|
||||
|
||||
## [0.61.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.60.0...v0.61.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump actions/setup-node from 6.4.0 to 7.0.0 ([#897](https://github.com/agentclientprotocol/claude-agent-acp/issues/897)) ([d9bd36d](https://github.com/agentclientprotocol/claude-agent-acp/commit/d9bd36d8b06764d63656d0387ad9430ec9fcb27d))
|
||||
* **deps:** Update to @anthropic-ai/claude-agent-sdk 0.3.217 ([#899](https://github.com/agentclientprotocol/claude-agent-acp/issues/899)) ([edf3af0](https://github.com/agentclientprotocol/claude-agent-acp/commit/edf3af043b6d00e5caca2cc81a2285c477c8b2ab))
|
||||
|
||||
## [0.60.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.59.0...v0.60.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Update to claude-agent-sdk 0.3.215 ([#890](https://github.com/agentclientprotocol/claude-agent-acp/issues/890)) ([92548f0](https://github.com/agentclientprotocol/claude-agent-acp/commit/92548f043547b0ddac95921ece020e69f7c12c5f))
|
||||
* implement configurable LLM providers ([#853](https://github.com/agentclientprotocol/claude-agent-acp/issues/853)) ([82cd692](https://github.com/agentclientprotocol/claude-agent-acp/commit/82cd692e500eedec182f817b18cebb005c4b98ce))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* parse Agent/Task trailers without regex ([#879](https://github.com/agentclientprotocol/claude-agent-acp/issues/879)) ([06c3d7b](https://github.com/agentclientprotocol/claude-agent-acp/commit/06c3d7bdbd8cc9415c8cabac060a50e0951c758b))
|
||||
* remove ~15s stall on session/new and model switch by seeding the context window synchronously ([#894](https://github.com/agentclientprotocol/claude-agent-acp/issues/894)) ([ff9b96d](https://github.com/agentclientprotocol/claude-agent-acp/commit/ff9b96d462831b1c3b96722ea20215ff6e529cb1))
|
||||
* Silence missing PostToolUse callbacks ([#895](https://github.com/agentclientprotocol/claude-agent-acp/issues/895)) ([1887ada](https://github.com/agentclientprotocol/claude-agent-acp/commit/1887ada215b27bb1025d9b7696a46ae7a4ac0f7a)), closes [#889](https://github.com/agentclientprotocol/claude-agent-acp/issues/889)
|
||||
|
||||
## [0.59.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.58.1...v0.59.0) (2026-07-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** bump nanoid from 3.3.15 to 3.3.16 ([#875](https://github.com/agentclientprotocol/claude-agent-acp/issues/875)) ([e67dacd](https://github.com/agentclientprotocol/claude-agent-acp/commit/e67dacdcac629b00e5bdcda94b3de57459696b1e))
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.207 ([#874](https://github.com/agentclientprotocol/claude-agent-acp/issues/874)) ([c7f5b8f](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7f5b8fe768507afa28cdffe5cc3c054c56306ad))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add subagent parent tool use attribution ([#859](https://github.com/agentclientprotocol/claude-agent-acp/issues/859)) ([9cd48c5](https://github.com/agentclientprotocol/claude-agent-acp/commit/9cd48c597af68f630774ec00bc3adc85f6b0fd4b))
|
||||
* forward result text when the turn emitted no assistant message ([#858](https://github.com/agentclientprotocol/claude-agent-acp/issues/858)) ([61ae860](https://github.com/agentclientprotocol/claude-agent-acp/commit/61ae8609d4343da6758c2376c6cf684c7fb0956a))
|
||||
* hold a turn open while its background subagents are still live ([#870](https://github.com/agentclientprotocol/claude-agent-acp/issues/870)) ([7a70f82](https://github.com/agentclientprotocol/claude-agent-acp/commit/7a70f82739e085014cad878f08513cdef7b7fe16))
|
||||
* Refine tool calls from streamed input ([#867](https://github.com/agentclientprotocol/claude-agent-acp/issues/867)) ([2c19974](https://github.com/agentclientprotocol/claude-agent-acp/commit/2c19974d5b80d3178b35e80a4bb0a1050588aab9))
|
||||
* Seed context window from SDK usage report ([#868](https://github.com/agentclientprotocol/claude-agent-acp/issues/868)) ([3ba2d36](https://github.com/agentclientprotocol/claude-agent-acp/commit/3ba2d367a282fe3053a73804a416a435cb01ee08)), closes [#596](https://github.com/agentclientprotocol/claude-agent-acp/issues/596)
|
||||
* Skip synthetic login messages on replay ([#869](https://github.com/agentclientprotocol/claude-agent-acp/issues/869)) ([c7dff3c](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7dff3cf7eeb03cb993493efafa96344f18de81b)), closes [#863](https://github.com/agentclientprotocol/claude-agent-acp/issues/863)
|
||||
|
||||
## [0.58.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.58.0...v0.58.1) (2026-07-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Use valid npm version for publish ([#855](https://github.com/agentclientprotocol/claude-agent-acp/issues/855)) ([8b366d8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8b366d8e9ad55e9237bf2d618cfc99b57fac570d))
|
||||
|
||||
## [0.58.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.57.0...v0.58.0) (2026-07-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.3.205 ([#854](https://github.com/agentclientprotocol/claude-agent-acp/issues/854)) ([f664ced](https://github.com/agentclientprotocol/claude-agent-acp/commit/f664ced76df00674d88aedd018a4261da3bc3f3a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Preserve live model on resumed sessions ([#848](https://github.com/agentclientprotocol/claude-agent-acp/issues/848)) ([f3d8ae3](https://github.com/agentclientprotocol/claude-agent-acp/commit/f3d8ae3eb389ee9367f48c8762562a55280ade0a)), closes [#845](https://github.com/agentclientprotocol/claude-agent-acp/issues/845)
|
||||
* Report usage for cancelled active turns ([#846](https://github.com/agentclientprotocol/claude-agent-acp/issues/846)) ([b03318f](https://github.com/agentclientprotocol/claude-agent-acp/commit/b03318f59c6165146c8d8e036227f35c3abd0ff4)), closes [#844](https://github.com/agentclientprotocol/claude-agent-acp/issues/844)
|
||||
* tolerate missing text in streamed thinking chunks ([#852](https://github.com/agentclientprotocol/claude-agent-acp/issues/852)) ([e944ced](https://github.com/agentclientprotocol/claude-agent-acp/commit/e944ceddea79950d6bfb6339266862507c4b964f))
|
||||
* Use SDK guards for elicitation validation ([#850](https://github.com/agentclientprotocol/claude-agent-acp/issues/850)) ([32b9350](https://github.com/agentclientprotocol/claude-agent-acp/commit/32b93501d7a35a3b405b5022b4caaad492eed11c))
|
||||
|
||||
## [0.57.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.56.0...v0.57.0) (2026-07-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update @anthropic-ai/claude-agent-sdk to 0.3.202 ([#843](https://github.com/agentclientprotocol/claude-agent-acp/issues/843)) ([1612c07](https://github.com/agentclientprotocol/claude-agent-acp/commit/1612c078953c134e12368a2d968a9bcf14d31b55))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't emit empty agent_message_chunk ([#841](https://github.com/agentclientprotocol/claude-agent-acp/issues/841)) ([d74dd1d](https://github.com/agentclientprotocol/claude-agent-acp/commit/d74dd1d9d356050fc87f9fdab8599f1546b12a2c))
|
||||
|
||||
## [0.56.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.55.0...v0.56.0) (2026-07-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.201 ([#837](https://github.com/agentclientprotocol/claude-agent-acp/issues/837)) ([ff2a8a9](https://github.com/agentclientprotocol/claude-agent-acp/commit/ff2a8a963536a4265700e4f2003a0ffff93c9087))
|
||||
|
||||
## [0.55.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.54.1...v0.55.0) (2026-07-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add refusal fallback consent dialog support ([#834](https://github.com/agentclientprotocol/claude-agent-acp/issues/834)) ([15dfd83](https://github.com/agentclientprotocol/claude-agent-acp/commit/15dfd83daa84ced98ecbaa7c83fd5a53124030c1))
|
||||
* **deps-dev:** bump the minor group with 3 updates ([#831](https://github.com/agentclientprotocol/claude-agent-acp/issues/831)) ([8d5febf](https://github.com/agentclientprotocol/claude-agent-acp/commit/8d5febf85485c1ad19b81501fb5c6d4275448a7c))
|
||||
* Update to claude-agent-sdk 0.3.198 ([#836](https://github.com/agentclientprotocol/claude-agent-acp/issues/836)) ([307ab82](https://github.com/agentclientprotocol/claude-agent-acp/commit/307ab82712213413446466d808e161073e94fc7f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Handle model refusal fallback updates ([#833](https://github.com/agentclientprotocol/claude-agent-acp/issues/833)) ([648e3f6](https://github.com/agentclientprotocol/claude-agent-acp/commit/648e3f60bd7e6eadb5f86b0dc475104ab12e7b02))
|
||||
* Handle SDK idle turns without results ([#835](https://github.com/agentclientprotocol/claude-agent-acp/issues/835)) ([57b00cc](https://github.com/agentclientprotocol/claude-agent-acp/commit/57b00ccac610e2eeaf0a753934a0c3b78d27ccb0))
|
||||
|
||||
## [0.54.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.54.0...v0.54.1) (2026-06-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apply modelOverrides when resolving availableModels allowlist ([#827](https://github.com/agentclientprotocol/claude-agent-acp/issues/827)) ([98c284b](https://github.com/agentclientprotocol/claude-agent-acp/commit/98c284bb871a96710596dfdc2b9ea25d2400f6b2))
|
||||
|
||||
## [0.54.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.53.0...v0.54.0) (2026-06-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add Fast mode session config support ([#828](https://github.com/agentclientprotocol/claude-agent-acp/issues/828)) ([fa949a2](https://github.com/agentclientprotocol/claude-agent-acp/commit/fa949a20db0e33f2d63aebeb37e9fea212d8ee95))
|
||||
* **deps-dev:** bump prettier from 3.9.1 to 3.9.3 in the minor group ([#821](https://github.com/agentclientprotocol/claude-agent-acp/issues/821)) ([b8df8e0](https://github.com/agentclientprotocol/claude-agent-acp/commit/b8df8e0e5460fd782214f4dde488f7476c80c454))
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.197 for Sonnet 5 ([#826](https://github.com/agentclientprotocol/claude-agent-acp/issues/826)) ([ef42c46](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef42c46e5aea3bb53f433b9f9dd36a62a9f2df6a))
|
||||
* **deps:** bump fast-uri from 3.1.2 to 3.1.3 ([#822](https://github.com/agentclientprotocol/claude-agent-acp/issues/822)) ([64aa130](https://github.com/agentclientprotocol/claude-agent-acp/commit/64aa130c66c299f9835193796ba372b3ed40e7c1))
|
||||
|
||||
## [0.53.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.52.0...v0.53.0) (2026-06-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add ACP logout support ([#816](https://github.com/agentclientprotocol/claude-agent-acp/issues/816)) ([0a0468c](https://github.com/agentclientprotocol/claude-agent-acp/commit/0a0468c72092b7ceccc551b4353dd92f9c22cbb8))
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.195 ([#818](https://github.com/agentclientprotocol/claude-agent-acp/issues/818)) ([5dd8746](https://github.com/agentclientprotocol/claude-agent-acp/commit/5dd87462376597eb7b695200f4c716af46e764d6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Emit tool_call before permission request ([#820](https://github.com/agentclientprotocol/claude-agent-acp/issues/820)) ([c95fc88](https://github.com/agentclientprotocol/claude-agent-acp/commit/c95fc884e82e5a297d1b17b8965690260867835b))
|
||||
|
||||
## [0.52.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.51.0...v0.52.0) (2026-06-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add version flag handling ([#813](https://github.com/agentclientprotocol/claude-agent-acp/issues/813)) ([9616bda](https://github.com/agentclientprotocol/claude-agent-acp/commit/9616bdac47505e4a14c36d667fcffc9ae97e1f2a)), closes [#809](https://github.com/agentclientprotocol/claude-agent-acp/issues/809)
|
||||
* **deps-dev:** bump expect-type from 1.3.0 to 1.4.0 in the minor group ([#814](https://github.com/agentclientprotocol/claude-agent-acp/issues/814)) ([61272ac](https://github.com/agentclientprotocol/claude-agent-acp/commit/61272acb30dcafaa2455d334b11ce2ad97339707))
|
||||
* **deps:** Update @anthropic-ai/claude-agent-sdk to 0.3.191 ([#810](https://github.com/agentclientprotocol/claude-agent-acp/issues/810)) ([228f02e](https://github.com/agentclientprotocol/claude-agent-acp/commit/228f02ecfb23be16e59e121c6b42c0f2b2f40a4e))
|
||||
* Push session title updates at turn end ([#812](https://github.com/agentclientprotocol/claude-agent-acp/issues/812)) ([1fe7ec0](https://github.com/agentclientprotocol/claude-agent-acp/commit/1fe7ec09a3a7bcb7501231dae4b0ffe6ef9b70a4))
|
||||
|
||||
## [0.51.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.50.0...v0.51.0) (2026-06-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump the minor group with 11 updates ([#807](https://github.com/agentclientprotocol/claude-agent-acp/issues/807)) ([8f6ebd1](https://github.com/agentclientprotocol/claude-agent-acp/commit/8f6ebd1d9198edf723f4c8c1aa2b49b906c46646))
|
||||
|
||||
## [0.50.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.49.0...v0.50.0) (2026-06-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **acp:** Handle ACP request cancellation signals ([#801](https://github.com/agentclientprotocol/claude-agent-acp/issues/801)) ([9013d1d](https://github.com/agentclientprotocol/claude-agent-acp/commit/9013d1d46883a7f3774a63a9dca16c4a0f634a97))
|
||||
* **deps:** bump actions/checkout from 6.0.3 to 7.0.0 ([#803](https://github.com/agentclientprotocol/claude-agent-acp/issues/803)) ([044c43e](https://github.com/agentclientprotocol/claude-agent-acp/commit/044c43e0c894082b9e747c01e9dbde6e21036823))
|
||||
* **deps:** upgrade to @anthropic-ai/claude-agent-sdk@0.3.186 ([#806](https://github.com/agentclientprotocol/claude-agent-acp/issues/806)) ([a7e6137](https://github.com/agentclientprotocol/claude-agent-acp/commit/a7e6137f6877b72b8daa39e74971c3559db8d28f))
|
||||
|
||||
## [0.49.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.48.0...v0.49.0) (2026-06-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.3.185 ([#798](https://github.com/agentclientprotocol/claude-agent-acp/issues/798)) ([8dc8c86](https://github.com/agentclientprotocol/claude-agent-acp/commit/8dc8c864263fa50d03bec7ebff7aee776604cb3d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Deduplicate streamed assistant blocks by content ([#800](https://github.com/agentclientprotocol/claude-agent-acp/issues/800)) ([960f62d](https://github.com/agentclientprotocol/claude-agent-acp/commit/960f62d76582ae5c9c5575ab66974809049ce1d0))
|
||||
* Infer 1M context from model descriptions ([#799](https://github.com/agentclientprotocol/claude-agent-acp/issues/799)) ([508453c](https://github.com/agentclientprotocol/claude-agent-acp/commit/508453c288b4a12701abd507199e7fa0ab172171))
|
||||
|
||||
## [0.48.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.47.0...v0.48.0) (2026-06-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Agent selection dropdown in config options ([#794](https://github.com/agentclientprotocol/claude-agent-acp/issues/794)) ([5729c47](https://github.com/agentclientprotocol/claude-agent-acp/commit/5729c471f92e1d2a191eb2a6d18c2be47821e9ec))
|
||||
* **deps:** bump the minor group with 11 updates ([#787](https://github.com/agentclientprotocol/claude-agent-acp/issues/787)) ([ad3b5fe](https://github.com/agentclientprotocol/claude-agent-acp/commit/ad3b5fe74527f83964695a1e8056d010b981fc79))
|
||||
* Update to claude-agent-sdk 0.3.183 ([#791](https://github.com/agentclientprotocol/claude-agent-acp/issues/791)) ([744b2d4](https://github.com/agentclientprotocol/claude-agent-acp/commit/744b2d41128f67091d4136283594c0db11ea4db5))
|
||||
* Update to new ACP SDK patterns ([#790](https://github.com/agentclientprotocol/claude-agent-acp/issues/790)) ([2554c7b](https://github.com/agentclientprotocol/claude-agent-acp/commit/2554c7bf980472760a6e7810b826f030d2c3af25))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* duplicate assistant text when turn activates mid-message ([#789](https://github.com/agentclientprotocol/claude-agent-acp/issues/789)) ([1c80bf8](https://github.com/agentclientprotocol/claude-agent-acp/commit/1c80bf8e56a9279dc799e7bbdcae87241e99c18b))
|
||||
* Skip empty thinking chunks ([#793](https://github.com/agentclientprotocol/claude-agent-acp/issues/793)) ([15fdf26](https://github.com/agentclientprotocol/claude-agent-acp/commit/15fdf26fc7d3a6c51e89d3e56fc91dd00cb3d7ae))
|
||||
* surface Bash tool image output instead of dropping it ([#617](https://github.com/agentclientprotocol/claude-agent-acp/issues/617)) ([a759e64](https://github.com/agentclientprotocol/claude-agent-acp/commit/a759e64ef6d9b7c5bfe9a6e6b182db835f8ed3b6))
|
||||
|
||||
## [0.47.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.46.0...v0.47.0) (2026-06-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.3.179 ([#783](https://github.com/agentclientprotocol/claude-agent-acp/issues/783)) ([59a098c](https://github.com/agentclientprotocol/claude-agent-acp/commit/59a098c2b530bbae034e9a2dfbd31f8b4ef2a4d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Duplicate assistant messages in feed ([#785](https://github.com/agentclientprotocol/claude-agent-acp/issues/785)) ([12d34e6](https://github.com/agentclientprotocol/claude-agent-acp/commit/12d34e64e53564602ac1c38a30127e234c5c25ff))
|
||||
|
||||
## [0.46.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.45.1...v0.46.0) (2026-06-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude-agent-sdk 0.3.178 ([#777](https://github.com/agentclientprotocol/claude-agent-acp/issues/777)) ([58549ff](https://github.com/agentclientprotocol/claude-agent-acp/commit/58549ffe6a8b02ce59894e567407bd4299c11428))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Better handle out of turn events ([#780](https://github.com/agentclientprotocol/claude-agent-acp/issues/780)) ([4f273a2](https://github.com/agentclientprotocol/claude-agent-acp/commit/4f273a20d870c9c69f71556b8e0519f1de30f285))
|
||||
* Forward option details in elicitation meta ([#779](https://github.com/agentclientprotocol/claude-agent-acp/issues/779)) ([b364059](https://github.com/agentclientprotocol/claude-agent-acp/commit/b3640599ae685beecacd93e012d5bbc9dac716f7)), closes [#764](https://github.com/agentclientprotocol/claude-agent-acp/issues/764)
|
||||
|
||||
## [0.45.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.45.0...v0.45.1) (2026-06-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix terminal error printing as text instead of terminal output ([#776](https://github.com/agentclientprotocol/claude-agent-acp/issues/776)) ([db6eaaf](https://github.com/agentclientprotocol/claude-agent-acp/commit/db6eaaf71484a321e47093ad65bcf8994943cb31))
|
||||
* Scope custom answers per question ([#774](https://github.com/agentclientprotocol/claude-agent-acp/issues/774)) ([d58004a](https://github.com/agentclientprotocol/claude-agent-acp/commit/d58004a34880e0a76833697319eb2a9efa6a43c7))
|
||||
|
||||
## [0.45.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.44.0...v0.45.0) (2026-06-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** bump the minor group with 3 updates ([#763](https://github.com/agentclientprotocol/claude-agent-acp/issues/763)) ([7de5e4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/7de5e4bcca9bfea70593092060f82bc8abe33e0e))
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.177 ([#771](https://github.com/agentclientprotocol/claude-agent-acp/issues/771)) ([1be5ca5](https://github.com/agentclientprotocol/claude-agent-acp/commit/1be5ca57ee772fe90e41126365dc4186a18ad257))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preserve ANTHROPIC_CUSTOM_MODEL_OPTION when availableModels is set ([#768](https://github.com/agentclientprotocol/claude-agent-acp/issues/768)) ([cc2885f](https://github.com/agentclientprotocol/claude-agent-acp/commit/cc2885f6a9993cf61e759c3c770015f94c218627))
|
||||
|
||||
## [0.44.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.43.0...v0.44.0) (2026-06-09)
|
||||
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -12,12 +12,25 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- Following
|
||||
- Edit review
|
||||
- TODO lists
|
||||
- Nested subagent transcripts
|
||||
- Interactive (and background) terminals
|
||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
||||
- Client MCP servers
|
||||
- Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md)
|
||||
|
||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
### Nested subagent transcripts
|
||||
|
||||
ACP 1.2 has no standard subagent tool kind or nested-message relationship. Clients that can render
|
||||
nested transcripts can opt in with `clientCapabilities._meta["subagent-transcript"] = true`.
|
||||
The agent then forwards subagent text, thinking, and tool calls, relating nested updates to the
|
||||
launching Agent/Task call through `_meta.claudeCode.parentToolUseId`. Agent/Task calls are marked
|
||||
with `_meta.claudeCode.subagent = true`.
|
||||
|
||||
Clients that do not advertise the capability retain the legacy flattened behavior. In both modes,
|
||||
the normal Agent/Task tool result is preserved as the protocol-compatible fallback.
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
This project does not require a Contributor License Agreement (CLA). Instead, contributions are accepted under the following terms:
|
||||
|
||||
117
docs/RELEASES.md
117
docs/RELEASES.md
@@ -1,24 +1,119 @@
|
||||
## How to publish a new release
|
||||
# Releases
|
||||
|
||||
1. Create a new branch prep-vX.X.X
|
||||
Releases are automated by [release-please](https://github.com/googleapis/release-please)
|
||||
in [`.github/workflows/publish.yml`](../.github/workflows/publish.yml). Every push
|
||||
to `main` re-computes the next version from the conventional commit messages and
|
||||
keeps a single release PR open, titled `chore(main): release X.Y.Z` and labelled
|
||||
`autorelease: pending`.
|
||||
|
||||
Merging that PR is what releases. It tags `vX.Y.Z`, creates the GitHub release,
|
||||
publishes to npm, and dispatches a version update to the agent registry.
|
||||
|
||||
There is no manual release button, and versions are never typed in by hand: the
|
||||
version is an output of the commit history, not an input.
|
||||
|
||||
## Releasing
|
||||
|
||||
```sh
|
||||
git checkout -b prep-vX.X.X
|
||||
npm run release:preflight
|
||||
```
|
||||
|
||||
2. Bump the version in package.json
|
||||
This reports the open release PR, the version it will ship, and checks that the
|
||||
repository is in a state where merging is safe. Nothing has to be remembered —
|
||||
if it exits non-zero, follow what it prints instead of merging.
|
||||
|
||||
Then merge it, using the PR number the preflight printed:
|
||||
|
||||
```sh
|
||||
npm version vX.X.X
|
||||
gh pr merge <pr-number> --squash
|
||||
gh run watch "$(gh run list --workflow=publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
|
||||
```
|
||||
|
||||
3. Add a new entry in CHANGELOG.md
|
||||
4. Create a PR for `prev-vX.X.X` & merge it
|
||||
5. Create tag and push it
|
||||
The run is looked up rather than picked interactively, so this is safe to script.
|
||||
If the workflow has already finished, `gh run list --workflow=publish.yml` shows
|
||||
the outcome instead.
|
||||
|
||||
Merging main requires no review, so a green preflight and `Build` are the only
|
||||
gates. Once the workflow finishes, confirm both outputs landed:
|
||||
|
||||
```sh
|
||||
git tag vX.X.X
|
||||
git push origin vX.X.X
|
||||
gh release view "v<version>"
|
||||
npm view "@agentclientprotocol/claude-agent-acp@<version>"
|
||||
```
|
||||
|
||||
6. Paste CHANGELOG into release notes and publish the version on GitHub
|
||||
## How the version is chosen
|
||||
|
||||
Squash merges use the PR title as the commit subject, so the PR title decides the
|
||||
next version. [`conventional-prs.yml`](../.github/workflows/conventional-prs.yml)
|
||||
rejects titles release-please would not understand.
|
||||
|
||||
| PR title prefix | Effect |
|
||||
| --------------------------------------------------------- | --------------------------- |
|
||||
| `fix:`, `perf:`, `revert:`, `docs:` | patch, e.g. 0.66.0 → 0.66.1 |
|
||||
| `feat:` | minor, e.g. 0.66.0 → 0.67.0 |
|
||||
| any of the above with `!`, or BREAKING CHANGE | minor while below 1.0.0 |
|
||||
| `chore:`, `ci:`, `build:`, `test:`, `refactor:`, `style:` | no release on their own |
|
||||
|
||||
Breaking changes bump the minor rather than the major because
|
||||
`bump-minor-pre-major` is set in
|
||||
[`release-please-config.json`](../release-please-config.json) and the package is
|
||||
still below 1.0.0. That is deliberate: it keeps a single `!` in a PR title from
|
||||
shipping 1.0.0 by accident.
|
||||
|
||||
Note that `config-file` only takes effect while the workflow does **not** pass a
|
||||
`release-type` input to the action — with `release-type` set, the action ignores
|
||||
the config entirely. The release type is declared inside the config instead.
|
||||
|
||||
Because the config is what is read, it also has to say
|
||||
`"include-component-in-tag": false`. Left at its default, release-please derives a
|
||||
component from the package name and tags `claude-agent-acp-vX.Y.Z` instead of
|
||||
`vX.Y.Z`. That renames the tag every step here looks up, and because no tag under
|
||||
the new scheme exists, it also walks the entire commit history into the changelog
|
||||
rather than just what landed since the last release. The preflight checks the tag
|
||||
release-please is going to use, so this cannot reach a published release.
|
||||
|
||||
Releasing 1.0.0 is therefore an explicit act: add `"release-as": "1.0.0"` to
|
||||
`release-please-config.json` in its own PR, release, then remove it again.
|
||||
|
||||
## Recovering a stalled release
|
||||
|
||||
### The release PR merged but nothing was tagged
|
||||
|
||||
The preflight fails with `release-please is jammed`. While a merged release PR
|
||||
still carries `autorelease: pending`, release-please refuses to open any new
|
||||
release PR at all, so every later release stalls silently until this is cleared.
|
||||
|
||||
Take the release notes release-please already wrote into the changelog, create
|
||||
the missing release, then move the label the way release-please would have:
|
||||
|
||||
```sh
|
||||
awk '/^## \[<version>\]/{f=1;print;next} /^## \[/{f=0} f' CHANGELOG.md > notes.md
|
||||
gh release create "v<version>" --target <merge-commit-sha> --notes-file notes.md
|
||||
gh pr edit <pr-number> --remove-label "autorelease: pending" \
|
||||
--add-label "autorelease: tagged"
|
||||
```
|
||||
|
||||
Then publish the tag as described below.
|
||||
|
||||
### The tag exists but npm or the registry is missing
|
||||
|
||||
npm publishes through OIDC from inside the workflow, so this cannot be done from
|
||||
a laptop. Re-run the publish workflow against the existing tag:
|
||||
|
||||
```sh
|
||||
gh workflow run publish.yml -f ref="v<version>" -f publish_npm=true
|
||||
```
|
||||
|
||||
npm versions are immutable. If the package already published and only the
|
||||
registry update failed, pass `-f publish_npm=false` so the run skips publishing
|
||||
and only re-dispatches the registry update.
|
||||
|
||||
## Credentials
|
||||
|
||||
| Secret | Used for |
|
||||
| ------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `RELEASE_PLZ_APP_ID`, `RELEASE_PLZ_APP_PRIVATE_KEY` | App token for release PRs and tags, so they can trigger workflows |
|
||||
| `REGISTRY_UPDATER_APP_ID`, `REGISTRY_UPDATER_APP_PRIVATE_KEY` | App token scoped to the `registry` repository |
|
||||
|
||||
Publishing to npm uses OIDC trusted publishing, so there is no npm token. All
|
||||
release jobs run in the `release` environment.
|
||||
|
||||
57
docs/goal-extension.md
Normal file
57
docs/goal-extension.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Goal extension
|
||||
|
||||
This document defines a provider-neutral experimental ACP extension implemented by `claude-agent-acp`. It is intentionally shaped like a possible future first-class ACP API: implementations publish `_meta.goal`, not provider-specific metadata such as `_meta.claudeCode.goal`.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
An agent advertises support in its `initialize` response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"goal": {
|
||||
"version": 1,
|
||||
"controlMethod": "_session/goal",
|
||||
"actions": ["clear"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; a future version may add action-specific fields such as an objective for `set`.
|
||||
|
||||
## Session state
|
||||
|
||||
The current snapshot is published in `session_info_update._meta.goal`. Clearing a goal publishes `goal: null`.
|
||||
|
||||
```json
|
||||
{
|
||||
"objective": "Ship the change",
|
||||
"status": "active",
|
||||
"iterations": 3,
|
||||
"lastReason": "Tests still need work",
|
||||
"createdAt": 1710000000123,
|
||||
"controlMethod": "_session/goal"
|
||||
}
|
||||
```
|
||||
|
||||
Common statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. Optional fields allow implementations to report budgets, usage, iteration count, and the last continuation reason. Timestamps are Unix milliseconds.
|
||||
|
||||
## Lifecycle architecture
|
||||
|
||||
A goal belongs to the ACP session, not to an individual `session/prompt` request. Goal activity and prompt activity are independent:
|
||||
|
||||
- `status: active` means the persistent objective can drive more work; it does not mean an ACP prompt is currently executing.
|
||||
- A prompt completes when its current backend turn reaches a quiescent boundary, even when the goal remains active.
|
||||
- A later autonomous cycle may publish more session updates outside that completed prompt.
|
||||
- While a turn is running, clients use steering or prompt queueing when advertised. While the session is quiescent, clients may send an ordinary `session/prompt`.
|
||||
|
||||
This separation prevents a persistent goal from monopolizing the session's prompt slot and lets clients model “working now” independently from “objective remains active.”
|
||||
|
||||
## Claude mapping
|
||||
|
||||
Claude's session-scoped `/goal` command installs a Stop hook. The runtime emits internal `active_goal` messages when that hook updates or clears its state; the adapter maps them into the neutral snapshot before the exhaustive SDK message switch. `condition`, `iterations`, `set_at`, and `last_reason` become `objective`, `iterations`, `createdAt`, and `lastReason`. Provider-only bookkeeping such as `tokens_at_start` is not exposed.
|
||||
|
||||
Some Claude runtime versions persist the goal and begin the Stop-hook workflow without emitting the initial `active_goal` message on the SDK stream. To avoid leaving clients without visible state for the whole long-running prompt, the adapter publishes a minimal active snapshot as soon as it submits `/goal <condition>`, and publishes `goal: null` for `/goal clear`. While that command is pending, late updates for the previous goal are ignored. A matching runtime `active_goal` remains authoritative and enriches the optimistic snapshot with iteration, reason, and timestamp fields; a failed command restores the previously published state.
|
||||
|
||||
`set` and `clear` are advertised for out-of-band control; pause and resume are not. When a goal turn is running, the adapter injects the corresponding `/goal` command at steering priority `now` so it cannot remain queued behind the Stop-hook workflow; on an idle session it uses the ordinary prompt lifecycle. Cancelling a turn does not clear the session-scoped goal: clients use the goal control method when they intend to replace or remove it.
|
||||
@@ -40,7 +40,7 @@ export default [
|
||||
"default-case": "error",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
eqeqeq: ["error", "always"],
|
||||
eqeqeq: ["error", "smart"],
|
||||
curly: ["error", "all"],
|
||||
},
|
||||
},
|
||||
|
||||
706
examples/simple-client.ts
Normal file
706
examples/simple-client.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
/**
|
||||
* Example: a dead-simple interactive ACP client for this agent.
|
||||
*
|
||||
* This is the "no editor required" way to exercise the agent: it launches
|
||||
* `dist/index.js` as a subprocess, speaks ACP over its stdio using the SDK, and
|
||||
* prints every JSON-RPC message that crosses the wire in both directions. Each
|
||||
* line you type becomes a `session/prompt`; the agent's answer streams back as
|
||||
* `session/update` notifications.
|
||||
*
|
||||
* Every message is preceded by a header line naming its direction:
|
||||
*
|
||||
* [agent] ◂ inbound — everything the agent sends us
|
||||
* [user] ▸ outbound — everything we send the agent, including our own
|
||||
* permission answers and `session/cancel`
|
||||
*
|
||||
* The label and the arrow carry the direction, so it survives NO_COLOR and
|
||||
* redirection to a file (`2> wire.log`). The JSON itself is printed in full,
|
||||
* flush left and undecorated — select it and you have valid JSON on the
|
||||
* clipboard. This exists to show you the protocol, not to summarize it.
|
||||
*
|
||||
* There are four ways for a client to get a message to this agent, and all four
|
||||
* are reachable from the prompt so you can watch what each one looks like on
|
||||
* the wire. Plain text is the first; the rest are `!`-prefixed commands, chosen
|
||||
* so they cannot be confused with the agent's own slash commands (`/compact`
|
||||
* and friends are ordinary prompt text here). `!!` escapes a literal `!`.
|
||||
*
|
||||
* <text> `session/prompt`. Typed mid-turn it is *buffered* by this
|
||||
* client and sent once the running turn responds.
|
||||
* !steer <text> `_session/steering` — injected into the turn that is
|
||||
* already running, so it can change course between tool
|
||||
* calls. Answers "injected", or "startedNewTurn" if the turn
|
||||
* beat us to the finish. An injected reply streams as
|
||||
* `session/update` notifications inside that turn, whose
|
||||
* `stopReason` waits for it. "startedNewTurn" output belongs
|
||||
* to a detached turn no `session/prompt` of ours tracks.
|
||||
* See ./steering.ts.
|
||||
* !queue <text> `session/prompt` sent mid-turn anyway. The agent advertises
|
||||
* `promptQueueing`, so it takes the backlog instead of us —
|
||||
* same ordering, different owner.
|
||||
* !cancel `session/cancel` for the running turn, as Ctrl-C does.
|
||||
*
|
||||
* Run (build the agent first so `dist/index.js` exists):
|
||||
*
|
||||
* npm run build
|
||||
* node examples/simple-client.ts
|
||||
*
|
||||
* or, equivalently, `npm run example:simple-client`.
|
||||
*
|
||||
* To debug the agent itself, pass a port. The agent is then rebuilt with source
|
||||
* maps and started under `--inspect-brk=<port>`, i.e. paused before its first
|
||||
* line, so you can attach (IntelliJ: Run → Attach to Node.js/Chrome; VS Code:
|
||||
* "Attach to Node Process"), set breakpoints in `src/*.ts`, and resume:
|
||||
*
|
||||
* node examples/simple-client.ts --debug-port=9229
|
||||
*
|
||||
* The client will sit waiting for `initialize` until you attach and resume —
|
||||
* that is expected, ACP has no request timeout.
|
||||
*
|
||||
* (Node < 22.18 needs `node --experimental-strip-types examples/simple-client.ts`.)
|
||||
*
|
||||
* Keys: Enter sends (or buffers), Ctrl-C cancels the running turn (again to
|
||||
* quit), Ctrl-D drains anything buffered and exits. `!help` lists the commands.
|
||||
*
|
||||
* Flags: --debug-port=<port>, --no-build, --help.
|
||||
* Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()).
|
||||
*
|
||||
* Requires the agent to be authenticated, since it talks to the real model.
|
||||
*
|
||||
* Note: this client does not advertise the `elicitation` capability, so the
|
||||
* agent disables its AskUserQuestion tool instead of asking us to render a
|
||||
* form. That is the one intentional behaviour difference from a full client.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
client as acpClient,
|
||||
methods,
|
||||
ndJsonStream,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
// Type-only: Node's type stripping does not elide value imports, and these two
|
||||
// exist only in the .d.ts — importing them normally is a runtime SyntaxError.
|
||||
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
|
||||
|
||||
// ---------------------------------------------------------------- steering
|
||||
|
||||
/**
|
||||
* The steering extension (../steering_protocol.md, and ./steering.ts for a
|
||||
* scripted walkthrough). It is not part of ACP proper, so it has no entry in
|
||||
* `methods` and is sent as a raw method name with hand-written types.
|
||||
*/
|
||||
const STEERING_METHOD = "_session/steering";
|
||||
|
||||
type SteeringRequest = {
|
||||
sessionId: string;
|
||||
prompt: Array<{ type: "text"; text: string }>;
|
||||
};
|
||||
|
||||
/** Both outcomes are successes; they say where the message landed, not whether
|
||||
* it worked. "startedNewTurn" means the turn finished before we got there. */
|
||||
type SteeringResponse = {
|
||||
outcome: "injected" | "startedNewTurn";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- arguments
|
||||
|
||||
const ARGV = process.argv.slice(2);
|
||||
|
||||
/** Reads `--name=value` or `--name value`; returns "" for a valueless flag. */
|
||||
function flag(name: string): string | undefined {
|
||||
const index = ARGV.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
|
||||
if (index === -1) return undefined;
|
||||
const [, ...rest] = ARGV[index].split("=");
|
||||
return rest.length > 0 ? rest.join("=") : (ARGV[index + 1] ?? "");
|
||||
}
|
||||
|
||||
if (ARGV.includes("--help") || ARGV.includes("-h")) {
|
||||
process.stdout.write(
|
||||
[
|
||||
"Usage: node examples/simple-client.ts [--debug-port=<port>] [--no-build]",
|
||||
"",
|
||||
" --debug-port=<port> rebuild with source maps and start the agent under",
|
||||
" --inspect-brk=<port>, paused until a debugger attaches",
|
||||
" --no-build skip the tsc rebuild and use dist/ as-is",
|
||||
"",
|
||||
"Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()).",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// The built agent entry. Rebuilt below unless --no-build is passed.
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const AGENT_ENTRY = process.env.AGENT_ENTRY ?? path.join(repoRoot, "dist", "index.js");
|
||||
const CWD = process.env.CWD ?? process.cwd();
|
||||
const BUILD = !ARGV.includes("--no-build");
|
||||
|
||||
const debugPortArg = flag("debug-port");
|
||||
const DEBUG_PORT = debugPortArg === undefined ? undefined : Number(debugPortArg);
|
||||
if (
|
||||
DEBUG_PORT !== undefined &&
|
||||
(!Number.isInteger(DEBUG_PORT) || DEBUG_PORT < 1 || DEBUG_PORT > 65535)
|
||||
) {
|
||||
process.stderr.write(`--debug-port expects a port between 1 and 65535, got "${debugPortArg}"\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Shown by `!help`, and once at startup. */
|
||||
const HELP = [
|
||||
"<text> send it — as a new turn, or buffered until this turn ends",
|
||||
"!steer <text> inject into the running turn (_session/steering)",
|
||||
"!queue <text> send session/prompt now and let the agent queue it",
|
||||
"!cancel session/cancel the running turn (same as Ctrl-C)",
|
||||
"!help this list; !! sends a line that really starts with '!'",
|
||||
];
|
||||
|
||||
// How long to wait for a cancelled turn to settle, and for the agent to exit
|
||||
// after we close its stdin, before escalating during shutdown.
|
||||
const CANCEL_TIMEOUT_MS = 5_000;
|
||||
const EXIT_TIMEOUT_MS = 5_000;
|
||||
|
||||
// ---------------------------------------------------------------- output
|
||||
|
||||
const COLOR = process.stderr.isTTY === true && !process.env.NO_COLOR;
|
||||
const paint = (code: string, text: string) => (COLOR ? `\x1b[${code}m${text}\x1b[0m` : text);
|
||||
const dim = (text: string) => paint("2", text);
|
||||
|
||||
const TTY = process.stdin.isTTY === true && process.stderr.isTTY === true;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let rl: readline.Interface | null = null;
|
||||
let shuttingDown = false;
|
||||
let queue: string[] = [];
|
||||
// How many `session/prompt` requests are awaiting a response. Usually 0 or 1;
|
||||
// `!queue` deliberately drives it higher to exercise the agent's own queueing.
|
||||
let inFlight = 0;
|
||||
const turnRunning = () => inFlight > 0;
|
||||
let cancelRequested = false;
|
||||
let lastUpdateKind = "";
|
||||
// False until `initialize` returns — an agent still paused under --inspect-brk
|
||||
// will never act on stdin EOF, so shutdown skips straight to signalling it.
|
||||
let agentReady = false;
|
||||
|
||||
/**
|
||||
* The single writer for everything this process prints. It wipes the half-typed
|
||||
* input line, writes the block, then asks readline to redraw its prompt with the
|
||||
* buffer intact — without this, streamed output and typing shred each other.
|
||||
* (One caveat: a soft-wrapped input line leaves a stale fragment behind, since
|
||||
* clearLine only clears the row the cursor is on.)
|
||||
*/
|
||||
function writeBlock(text: string) {
|
||||
if (TTY) {
|
||||
readline.cursorTo(process.stderr, 0);
|
||||
readline.clearLine(process.stderr, 0);
|
||||
}
|
||||
process.stderr.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
if (TTY) rl?.prompt(true);
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
writeBlock(`${dim("[client]")} ${message}`);
|
||||
}
|
||||
|
||||
function refreshPrompt() {
|
||||
if (!rl) return;
|
||||
const queued = queue.length > 0 ? `[${queue.length} queued] ` : "";
|
||||
const running = inFlight > 1 ? `[${inFlight} turns] ` : "";
|
||||
const busy = turnRunning() ? dim(`⋯ ${lastUpdateKind} `) : "";
|
||||
rl.setPrompt(`${busy}${running}${queued}› `);
|
||||
if (TTY) rl.prompt(true);
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---------------------------------------------------------------- wire log
|
||||
|
||||
/** A permissive view of the JSON-RPC union, purely for the summary line. */
|
||||
type WireMessage = {
|
||||
id?: string | number | null;
|
||||
method?: string;
|
||||
params?: { update?: { sessionUpdate?: string } };
|
||||
result?: { stopReason?: string };
|
||||
error?: { code: number; message: string };
|
||||
};
|
||||
|
||||
/** One-line summary: method (plus session update kind), or which reply this is. */
|
||||
function describe(message: AnyMessage): string {
|
||||
const wire = message as WireMessage;
|
||||
const id = wire.id === undefined || wire.id === null ? "" : ` #${wire.id}`;
|
||||
if (wire.method !== undefined) {
|
||||
const kind = wire.params?.update?.sessionUpdate;
|
||||
return `${wire.method}${kind ? ` · ${kind}` : ""}${id}`;
|
||||
}
|
||||
if (wire.error !== undefined) {
|
||||
return `error${id} · ${wire.error.code} ${wire.error.message}`;
|
||||
}
|
||||
const stopReason = wire.result?.stopReason;
|
||||
return `result${id}${stopReason ? ` · ${stopReason}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints one JSON-RPC message. The SDK exposes no logging hook, so this is
|
||||
* called from a stream tap (see `tapStream`) that sits between the SDK and the
|
||||
* transport — what you see is exactly what is written to / read from the pipe.
|
||||
*/
|
||||
function logMessage(direction: "agent" | "user", message: AnyMessage) {
|
||||
const inbound = direction === "agent";
|
||||
const color = inbound ? "36" : "35";
|
||||
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(3);
|
||||
const header = [
|
||||
dim(`+${elapsed}s`),
|
||||
paint(`1;${color}`, inbound ? "[agent]" : "[user] "),
|
||||
paint(color, inbound ? "◂" : "▸"),
|
||||
dim(describe(message)),
|
||||
].join(" ");
|
||||
// The body is printed flush left and unadorned so that selecting it in the
|
||||
// terminal yields valid JSON you can paste straight into a tool.
|
||||
writeBlock(`${header}\n${JSON.stringify(message, null, 2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a transport stream so every message is logged post-parse/pre-stringify.
|
||||
* A TransformStream per direction is the only complete interposition point: the
|
||||
* SDK reads inbound with a single reader and takes a fresh writer per outbound
|
||||
* message, and both survive being handed a transform.
|
||||
*/
|
||||
function tapStream(inner: Stream): Stream {
|
||||
const inbound = new TransformStream<AnyMessage, AnyMessage>({
|
||||
transform(message, controller) {
|
||||
logMessage("agent", message);
|
||||
controller.enqueue(message);
|
||||
},
|
||||
});
|
||||
const outbound = new TransformStream<AnyMessage, AnyMessage>({
|
||||
transform(message, controller) {
|
||||
logMessage("user", message);
|
||||
controller.enqueue(message);
|
||||
},
|
||||
});
|
||||
// The SDK never closes its writable, so this pipe only settles on failure —
|
||||
// typically the agent died and its stdin is gone. Teardown handles the rest;
|
||||
// without the catch this would be an unhandled rejection.
|
||||
outbound.readable.pipeTo(inner.writable).catch((err) => {
|
||||
if (!shuttingDown) log(`transport write failed: ${err}`);
|
||||
});
|
||||
return { readable: inner.readable.pipeThrough(inbound), writable: outbound.writable };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- build
|
||||
|
||||
/** Runs the repo's tsc, sending its diagnostics to our stderr. */
|
||||
async function build(sourceMaps: boolean) {
|
||||
const localTsc = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc");
|
||||
const useLocal = existsSync(localTsc);
|
||||
const args = sourceMaps ? ["--sourceMap"] : [];
|
||||
log(`building agent (tsc${sourceMaps ? " --sourceMap" : ""}) …`);
|
||||
|
||||
const child = useLocal
|
||||
? spawn(process.execPath, [localTsc, ...args], { cwd: repoRoot, stdio: ["ignore", 2, 2] })
|
||||
: spawn(process.platform === "win32" ? "npx.cmd" : "npx", ["tsc", ...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", 2, 2],
|
||||
});
|
||||
|
||||
const [code] = (await once(child, "exit")) as [number | null];
|
||||
if (code !== 0) {
|
||||
log(`build failed (tsc exit ${code}) — fix the errors above, or pass --no-build`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
|
||||
async function main() {
|
||||
if (BUILD) {
|
||||
await build(DEBUG_PORT !== undefined);
|
||||
} else if (!existsSync(AGENT_ENTRY)) {
|
||||
log(`${AGENT_ENTRY} not found — run \`npm run build\` first, or unset --no-build`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// An ACP client launches the agent as a subprocess and speaks JSON-RPC over
|
||||
// its stdin/stdout. Its stderr is piped rather than inherited so that agent
|
||||
// logs go through writeBlock() and don't corrupt the readline input line.
|
||||
const nodeArgs =
|
||||
DEBUG_PORT === undefined ? [AGENT_ENTRY] : [`--inspect-brk=${DEBUG_PORT}`, AGENT_ENTRY];
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
cwd: repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
log(`failed to spawn agent (${AGENT_ENTRY}): ${err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
// A write racing the agent's exit surfaces as an unhandled 'error' event that
|
||||
// would take this client down with it.
|
||||
child.stdin?.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code !== "EPIPE") log(`agent stdin error: ${err}`);
|
||||
});
|
||||
child.stdout?.on("error", () => {});
|
||||
process.stderr.on("error", () => {});
|
||||
|
||||
pumpAgentStderr(child);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!shuttingDown) log(`agent exited (code ${code}, signal ${signal})`);
|
||||
});
|
||||
|
||||
const stream = tapStream(
|
||||
ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
|
||||
),
|
||||
);
|
||||
|
||||
const connection = acpClient({ name: "simple-client" })
|
||||
// The tap already prints every update; this handler exists so the prompt line
|
||||
// can show what the agent is currently doing.
|
||||
.onNotification(methods.client.session.update, (ctx) => {
|
||||
lastUpdateKind = ctx.params.update.sessionUpdate;
|
||||
})
|
||||
// Auto-approve, chosen by `kind` and never by a hard-coded optionId: for
|
||||
// ExitPlanMode this agent uses permission-*mode* names as option ids
|
||||
// (auto / acceptEdits / default / plan / bypassPermissions).
|
||||
.onRequest(methods.client.session.requestPermission, (ctx) => {
|
||||
const options = ctx.params.options;
|
||||
const option = options.find((o) => o.kind === "allow_once") ?? options[0];
|
||||
if (!option) return { outcome: { outcome: "cancelled" } };
|
||||
log(`auto-approving permission: ${option.name} (${option.kind}/${option.optionId})`);
|
||||
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
||||
})
|
||||
// Minimal file-system stubs. This agent performs its own file I/O inside the
|
||||
// Claude Agent SDK subprocess and never calls these today.
|
||||
.onRequest(methods.client.fs.readTextFile, () => ({ content: "" }))
|
||||
.onRequest(methods.client.fs.writeTextFile, () => ({}))
|
||||
.connect(stream);
|
||||
|
||||
const agent = connection.agent;
|
||||
|
||||
// Signals are wired up *before* the first request. Under --inspect-brk the
|
||||
// wait below is unbounded, and a client that dies without running shutdown()
|
||||
// orphans a paused agent still holding the debug port.
|
||||
const onInterrupt = () => {
|
||||
if (turnRunning() && !cancelRequested) {
|
||||
cancelRequested = true;
|
||||
const dropped = queue.length;
|
||||
queue = [];
|
||||
// A notification, not a request: every turn in flight settles through its
|
||||
// own session/prompt response, normally with stopReason "cancelled".
|
||||
void agent.notify(methods.agent.session.cancel, { sessionId });
|
||||
log(`sent session/cancel${dropped > 0 ? `, dropped ${dropped} queued line(s)` : ""}`);
|
||||
refreshPrompt();
|
||||
return;
|
||||
}
|
||||
void shutdown(turnRunning() ? "^C while cancelling" : "^C");
|
||||
};
|
||||
// In terminal mode readline puts stdin in raw mode and owns ^C, so this only
|
||||
// ever fires for an explicit `kill -INT`; with piped stdin it is the ^C path.
|
||||
process.on("SIGINT", onInterrupt);
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// 1. Initialize. Under --inspect-brk the agent is paused before it reads a
|
||||
// single byte, so this request stays unanswered until a debugger attaches
|
||||
// and resumes it. ACP has no request timeout, so waiting is safe; the
|
||||
// interval below just keeps the terminal from looking dead.
|
||||
let nudge: NodeJS.Timeout | undefined;
|
||||
if (DEBUG_PORT !== undefined) {
|
||||
log(`agent is PAUSED before its first line (--inspect-brk=${DEBUG_PORT})`);
|
||||
log(`attach a debugger to port ${DEBUG_PORT} and resume it — breakpoints go in src/*.ts`);
|
||||
nudge = setInterval(
|
||||
() => log(`still waiting for a debugger on port ${DEBUG_PORT} … (Ctrl-C to give up)`),
|
||||
10_000,
|
||||
);
|
||||
}
|
||||
let init;
|
||||
try {
|
||||
init = await agent.request(methods.agent.initialize, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
terminal: false,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (nudge) clearInterval(nudge);
|
||||
}
|
||||
agentReady = true;
|
||||
log(`connected to ${init.agentInfo?.name ?? "agent"} ${init.agentInfo?.version ?? ""}`.trim());
|
||||
|
||||
// Steering is advertised at the TOP-LEVEL `_meta` of the initialize result —
|
||||
// a sibling of `agentCapabilities`, not nested inside it. Prompt queueing is
|
||||
// a Claude-specific capability flag and does live inside.
|
||||
const initMeta = init._meta as { steering?: { supported?: boolean } } | null | undefined;
|
||||
const steeringSupported = initMeta?.steering?.supported === true;
|
||||
const queueMeta = init.agentCapabilities?._meta as
|
||||
{ claudeCode?: { promptQueueing?: boolean } } | null | undefined;
|
||||
log(
|
||||
`agent supports: steering=${steeringSupported} ` +
|
||||
`promptQueueing=${queueMeta?.claudeCode?.promptQueueing === true}`,
|
||||
);
|
||||
|
||||
// 2. Open a session.
|
||||
let sessionId: string;
|
||||
try {
|
||||
const session = await agent.request(methods.agent.session.new, { cwd: CWD, mcpServers: [] });
|
||||
sessionId = session.sessionId;
|
||||
} catch (err) {
|
||||
log(`session/new failed: ${err}`);
|
||||
log("if this is an auth error, log in first: node dist/index.js --cli auth login");
|
||||
await shutdown("session/new failed", 1);
|
||||
return;
|
||||
}
|
||||
log(`session ${sessionId} in ${CWD}`);
|
||||
|
||||
// 3. Turns. There are four ways to get a message to the agent, and the client
|
||||
// can drive all of them (see `handleCommand`); plain text uses the first.
|
||||
//
|
||||
// session/prompt a new turn, or — for text typed mid-turn — one
|
||||
// buffered here and sent when the turn responds
|
||||
// session/prompt sent mid-turn anyway, so the *agent* queues it
|
||||
// (!queue) (it advertises `_meta.claudeCode.promptQueueing`)
|
||||
// _session/steering injected into the turn that is already running
|
||||
// (!steer)
|
||||
// session/cancel abandon the running turn
|
||||
// (!cancel, ^C)
|
||||
let draining = false;
|
||||
|
||||
/** One `session/prompt` round-trip. Several may be in flight at once when
|
||||
* `!queue` is used; the agent runs them in order. */
|
||||
async function sendPrompt(text: string) {
|
||||
inFlight += 1;
|
||||
cancelRequested = false;
|
||||
refreshPrompt();
|
||||
try {
|
||||
const result = await agent.request(methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text }],
|
||||
});
|
||||
log(`turn ended: ${result.stopReason}`);
|
||||
} catch (err) {
|
||||
// The error response itself was already printed by the tap; this is
|
||||
// the human-readable "your prompt did not run".
|
||||
log(`prompt failed: ${err}`);
|
||||
if (connection.signal.aborted) queue = [];
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
refreshPrompt();
|
||||
exitWhenIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the buffered lines one turn at a time, oldest first. */
|
||||
async function drain() {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
try {
|
||||
while (queue.length > 0 && !shuttingDown) {
|
||||
await sendPrompt(queue.shift()!);
|
||||
if (connection.signal.aborted) break;
|
||||
}
|
||||
} finally {
|
||||
draining = false;
|
||||
refreshPrompt();
|
||||
exitWhenIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** A piped stdin ends; exiting has to wait for the work already handed out. */
|
||||
function exitWhenIdle() {
|
||||
if (inputClosed && !draining && queue.length === 0 && inFlight === 0) {
|
||||
void shutdown("input closed, queue drained");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side commands, prefixed with `!` so they cannot collide with the
|
||||
* agent's own slash commands (`/compact` and friends, which are just prompt
|
||||
* text as far as this client is concerned). `!!` escapes a literal `!`.
|
||||
*/
|
||||
function handleCommand(line: string) {
|
||||
const word = line.split(/\s+/)[0];
|
||||
const text = line.slice(word.length).trim();
|
||||
switch (word) {
|
||||
case "!steer":
|
||||
if (text.length === 0) {
|
||||
log("usage: !steer <message> — injects into the turn that is running");
|
||||
break;
|
||||
}
|
||||
if (!steeringSupported) log("agent did not advertise steering; trying anyway");
|
||||
void steer(text);
|
||||
break;
|
||||
case "!queue":
|
||||
if (text.length === 0) {
|
||||
log("usage: !queue <message> — sends session/prompt now, agent queues it");
|
||||
break;
|
||||
}
|
||||
void sendPrompt(text);
|
||||
break;
|
||||
case "!cancel":
|
||||
if (!turnRunning()) {
|
||||
log("nothing to cancel");
|
||||
break;
|
||||
}
|
||||
onInterrupt();
|
||||
break;
|
||||
case "!help":
|
||||
for (const entry of HELP) log(entry);
|
||||
break;
|
||||
default:
|
||||
log(`unknown command ${word} — try !help`);
|
||||
break;
|
||||
}
|
||||
refreshPrompt();
|
||||
}
|
||||
|
||||
/** `_session/steering`: deliver a message to the turn already in progress. */
|
||||
async function steer(text: string) {
|
||||
const params: SteeringRequest = { sessionId, prompt: [{ type: "text", text }] };
|
||||
try {
|
||||
const result = await agent.request<SteeringResponse>(STEERING_METHOD, params);
|
||||
// Either way the steered message's own output arrives as `session/update`
|
||||
// notifications, not in this response. When injected, it lands inside the
|
||||
// turn we steered, whose `stopReason` waits for it.
|
||||
log(
|
||||
result.outcome === "injected"
|
||||
? "steer outcome: injected into the running turn"
|
||||
: "steer outcome: startedNewTurn — the turn had already finished, so the " +
|
||||
"agent began a new one that no session/prompt of ours is tracking",
|
||||
);
|
||||
} catch (err) {
|
||||
log(`steer rejected: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
let inputClosed = false;
|
||||
let steerHinted = false;
|
||||
|
||||
rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: TTY,
|
||||
crlfDelay: Infinity,
|
||||
historySize: 200,
|
||||
});
|
||||
|
||||
rl.on("line", (raw) => {
|
||||
const line = raw.trim();
|
||||
if (line.length === 0 || shuttingDown) {
|
||||
refreshPrompt();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith("!") && !line.startsWith("!!")) {
|
||||
handleCommand(line);
|
||||
return;
|
||||
}
|
||||
// "!!steer …" is how you send text that begins with the command prefix.
|
||||
queue.push(line.startsWith("!!") ? line.slice(1) : line);
|
||||
if (turnRunning()) {
|
||||
log(`buffered — will send when this turn ends (${queue.length} queued)`);
|
||||
if (!steerHinted) {
|
||||
steerHinted = true;
|
||||
log("…or use !steer <text> to inject into the running turn instead");
|
||||
}
|
||||
}
|
||||
refreshPrompt();
|
||||
void drain();
|
||||
});
|
||||
|
||||
rl.on("SIGINT", onInterrupt);
|
||||
|
||||
rl.on("close", () => {
|
||||
inputClosed = true;
|
||||
exitWhenIdle();
|
||||
});
|
||||
|
||||
void connection.closed.then(() => {
|
||||
if (!shuttingDown) void shutdown("connection closed");
|
||||
});
|
||||
|
||||
for (const entry of HELP) log(entry);
|
||||
log("Enter sends, Ctrl-C cancels a turn, Ctrl-D exits");
|
||||
refreshPrompt();
|
||||
|
||||
/**
|
||||
* Order matters here. The agent exits cleanly on stdin EOF (src/index.ts wires
|
||||
* `connection.closed` to `agent.dispose()`), whereas killing it skips disposal
|
||||
* and can orphan the Claude Agent SDK subprocess. Closing our connection first
|
||||
* would cancel the reader on the agent's stdout and make it die of EPIPE
|
||||
* mid-write, so that comes last.
|
||||
*/
|
||||
async function shutdown(reason: string, exitCode = 0) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log(`shutting down (${reason})`);
|
||||
queue = [];
|
||||
rl?.removeAllListeners("close");
|
||||
rl?.close();
|
||||
|
||||
if (turnRunning()) {
|
||||
if (!cancelRequested) {
|
||||
try {
|
||||
await agent.notify(methods.agent.session.cancel, { sessionId });
|
||||
} catch {
|
||||
// The connection may already be gone; teardown continues regardless.
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + CANCEL_TIMEOUT_MS;
|
||||
while (turnRunning() && Date.now() < deadline) await delay(50);
|
||||
}
|
||||
|
||||
child.stdin?.end();
|
||||
const exited = await Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
delay(agentReady ? EXIT_TIMEOUT_MS : 250).then(() => false),
|
||||
]);
|
||||
if (!exited) {
|
||||
log("agent did not exit on stdin EOF; sending SIGTERM");
|
||||
child.kill("SIGTERM");
|
||||
const stopped = await Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
delay(2_000).then(() => false),
|
||||
]);
|
||||
if (!stopped) child.kill("SIGKILL");
|
||||
}
|
||||
|
||||
connection.close();
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Streams the agent's stderr through writeBlock, flagging inspector messages. */
|
||||
function pumpAgentStderr(child: ReturnType<typeof spawn>) {
|
||||
let buffered = "";
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stderr?.on("data", (chunk: string) => {
|
||||
buffered += chunk;
|
||||
const lines = buffered.split("\n");
|
||||
buffered = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) continue;
|
||||
if (line.includes("Debugger listening on")) {
|
||||
writeBlock(paint("1;33", `[agent] ${line.trim()}`));
|
||||
} else if (line.includes("address already in use")) {
|
||||
writeBlock(paint("1;31", `[agent] ${line.trim()}`));
|
||||
log("that inspector port is taken — pass a different --debug-port");
|
||||
} else {
|
||||
writeBlock(dim(`[agent:stderr] ${line}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Tearing down rejects whatever request main() is awaiting; that is the
|
||||
// teardown finishing its job, not a failure, and shutdown() owns the exit.
|
||||
if (shuttingDown) return;
|
||||
log(`fatal: ${err?.stack ?? err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
221
examples/steering.ts
Normal file
221
examples/steering.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Example: mid-turn steering over ACP with the `_session/steering` extension.
|
||||
*
|
||||
* "Steering" lets a client deliver a follow-up message to a turn that is still
|
||||
* running, instead of waiting for it to finish and sending a fresh
|
||||
* `session/prompt`. This is what powers "the user typed something while the
|
||||
* agent was still working" — the new message joins the in-flight turn so the
|
||||
* agent can adapt immediately (it shines in multi-step / tool-using turns,
|
||||
* where the message slots in between tool calls).
|
||||
*
|
||||
* The wire protocol has three moving parts:
|
||||
*
|
||||
* 1. The agent advertises support in its `initialize` response, at the
|
||||
* top-level `_meta.steering` (a sibling of `agentCapabilities`).
|
||||
* 2. The client calls the `_session/steering` request with `{ sessionId,
|
||||
* prompt }` while a turn is running.
|
||||
* 3. The agent replies with an `outcome`:
|
||||
* - "injected" the message joined the running turn;
|
||||
* - "startedNewTurn" the turn had already finished and the legacy
|
||||
* detached fallback was used;
|
||||
* - "promptRequired" the turn had already finished, but this request
|
||||
* explicitly opted into Host-owned prompt delivery.
|
||||
*
|
||||
* This example launches the agent as a subprocess, starts a deliberately
|
||||
* long-running prompt, and — as soon as the agent begins streaming — injects a
|
||||
* steering message and prints the outcome. All agent output is streamed to
|
||||
* stdout so you can watch the turn change course.
|
||||
*
|
||||
* Run (build the agent first so `dist/index.js` exists):
|
||||
*
|
||||
* npm run build
|
||||
* node examples/steering.ts
|
||||
*
|
||||
* (Node < 22.18 needs `node --experimental-strip-types examples/steering.ts`.)
|
||||
*
|
||||
* Override the prompts with the PROMPT / STEER env vars. Requires the agent to
|
||||
* be authenticated, since it talks to the real model.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import {
|
||||
client as acpClient,
|
||||
methods,
|
||||
ndJsonStream,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
|
||||
/** The steering extension method, per the ACP steering wire protocol. */
|
||||
const STEERING_METHOD = "_session/steering";
|
||||
|
||||
/** Params for a `_session/steering` request — the same shape as the relevant
|
||||
* subset of a `session/prompt`. */
|
||||
type SteeringRequest = {
|
||||
sessionId: string;
|
||||
prompt: Array<{ type: "text"; text: string }>;
|
||||
_meta?: { steering?: { idleBehavior?: "promptRequired" } };
|
||||
};
|
||||
|
||||
/** Result of a `_session/steering` request. `injected` means the message joined
|
||||
* the running turn; `promptRequired` means the turn had already settled, so the
|
||||
* message was NOT consumed and must be (re)sent through a normal
|
||||
* `session/prompt`. Both are successes. */
|
||||
type SteeringResponse =
|
||||
| { outcome: "injected" }
|
||||
| { outcome: "startedNewTurn" }
|
||||
| { outcome: "promptRequired"; reason: "noRunningTurn" };
|
||||
|
||||
/** The existing steering capability advertised at the top-level `_meta.steering`
|
||||
* of the `initialize` result. The idle behavior is selected per request. */
|
||||
type SteeringCapability = {
|
||||
supported?: boolean;
|
||||
};
|
||||
|
||||
// The built agent entry. Run `npm run build` first so this exists.
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const AGENT_ENTRY = process.env.AGENT_ENTRY ?? path.join(repoRoot, "dist", "index.js");
|
||||
const CWD = process.env.CWD ?? process.cwd();
|
||||
|
||||
// A deliberately long-running first prompt, and the follow-up injected while it
|
||||
// is still streaming. Override either via env vars to experiment.
|
||||
const PROMPT =
|
||||
process.env.PROMPT ??
|
||||
"Count slowly from 1 to 30, one number per line, with a short sentence of " +
|
||||
"commentary after each. Do not stop early.";
|
||||
const STEER =
|
||||
process.env.STEER ?? "Actually stop counting and instead reply with exactly one line: STEERED-OK";
|
||||
|
||||
function log(msg: string) {
|
||||
process.stderr.write(`\x1b[2m[client]\x1b[0m ${msg}\n`);
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function main() {
|
||||
// An ACP client launches the agent as a subprocess and speaks JSON-RPC over
|
||||
// its stdin/stdout. stderr is inherited so the agent's own logs stay visible.
|
||||
const child = spawn(process.execPath, [AGENT_ENTRY], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: process.env,
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
log(`failed to spawn agent (${AGENT_ENTRY}): ${err}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
|
||||
);
|
||||
|
||||
// Resolves the first time the agent streams assistant text — our signal that
|
||||
// the turn is genuinely underway and therefore steerable.
|
||||
let signalFirstOutput = () => {};
|
||||
const firstOutput = new Promise<void>((resolve) => (signalFirstOutput = resolve));
|
||||
|
||||
const connection = acpClient({ name: "steering-example" })
|
||||
.onNotification(methods.client.session.update, (ctx) => {
|
||||
const update = ctx.params.update;
|
||||
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
||||
process.stdout.write(update.content.text);
|
||||
signalFirstOutput();
|
||||
}
|
||||
})
|
||||
// Auto-approve permission prompts so the turn is never blocked on us.
|
||||
.onRequest(methods.client.session.requestPermission, (ctx) => {
|
||||
const options = ctx.params.options;
|
||||
const option = options.find((o) => o.kind === "allow_once") ?? options[0];
|
||||
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
||||
})
|
||||
// Minimal file-system stubs; the example prompts don't touch files.
|
||||
.onRequest(methods.client.fs.readTextFile, () => ({ content: "" }))
|
||||
.onRequest(methods.client.fs.writeTextFile, () => ({}))
|
||||
.connect(stream);
|
||||
|
||||
try {
|
||||
const agent = connection.agent;
|
||||
|
||||
// 1. Initialize and confirm the agent advertises steering. Per the wire
|
||||
// protocol the capability lives at the TOP-LEVEL `_meta` of the initialize
|
||||
// result — a sibling of `agentCapabilities`, not nested inside it.
|
||||
const init = await agent.request(methods.agent.initialize, {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
const steering = (init._meta as { steering?: SteeringCapability } | null | undefined)
|
||||
?.steering;
|
||||
if (steering?.supported !== true) {
|
||||
throw new Error("agent does not advertise steering support");
|
||||
}
|
||||
log("agent advertises steering support");
|
||||
|
||||
// 2. Open a session.
|
||||
const { sessionId } = await agent.request(methods.agent.session.new, {
|
||||
cwd: CWD,
|
||||
mcpServers: [],
|
||||
});
|
||||
log(`session: ${sessionId}`);
|
||||
|
||||
// 3. Start a long turn, but DON'T await it yet — we need it in flight so we
|
||||
// can steer it. Its output streams through the notification handler above.
|
||||
log(`prompt: ${PROMPT}`);
|
||||
process.stdout.write("\n----- agent output -----\n");
|
||||
const turn = agent.request(methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: PROMPT }],
|
||||
});
|
||||
|
||||
// 4. Once the turn is producing output, inject the follow-up. Wait for the
|
||||
// first streamed chunk (with a fallback) plus a beat, so the steer clearly
|
||||
// lands mid-turn.
|
||||
await Promise.race([firstOutput, delay(5000)]);
|
||||
await delay(1000);
|
||||
|
||||
process.stdout.write("\n");
|
||||
log(`steer: ${STEER}`);
|
||||
const steerRequest: SteeringRequest = {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: STEER }],
|
||||
// Opt into the host-owned idle fallback. Without this request metadata,
|
||||
// the Adapter preserves its legacy `startedNewTurn` behavior.
|
||||
_meta: { steering: { idleBehavior: "promptRequired" } },
|
||||
};
|
||||
const result = await agent.request<SteeringResponse>(STEERING_METHOD, steerRequest);
|
||||
log(`steer outcome: ${result.outcome}`);
|
||||
|
||||
if (result.outcome === "promptRequired") {
|
||||
// The target turn already settled, so steering did not consume the message.
|
||||
// Start a normal session/prompt on the same session to deliver it — that
|
||||
// request owns the continuation's updates and terminal response.
|
||||
log(`steer fallback: ${result.reason}; starting a normal session/prompt`);
|
||||
const continuationRequest: PromptRequest = {
|
||||
sessionId: steerRequest.sessionId,
|
||||
prompt: steerRequest.prompt,
|
||||
};
|
||||
const continuation = await agent.request<PromptResponse, PromptRequest>(
|
||||
methods.agent.session.prompt,
|
||||
continuationRequest,
|
||||
);
|
||||
log(`continuation stopReason: ${continuation.stopReason}`);
|
||||
}
|
||||
|
||||
// 5. Await the original turn. With outcome "injected" the steer already
|
||||
// reshaped the output above; the promptRequired branch owns its own turn.
|
||||
const response = await turn;
|
||||
log(`original turn stopReason: ${response.stopReason}`);
|
||||
process.stdout.write("\n----- end of agent output -----\n");
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log(`fatal: ${err?.stack ?? err}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
1062
package-lock.json
generated
1062
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.44.0",
|
||||
"version": "0.67.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -29,11 +29,13 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "npm run build && npm run start",
|
||||
"example:simple-client": "node --experimental-strip-types --disable-warning=ExperimentalWarning examples/simple-client.ts",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"lint:fix": "eslint src --ext .ts --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"check": "npm run lint && npm run format:check",
|
||||
"release:preflight": "bash scripts/release-preflight.sh",
|
||||
"test": "vitest",
|
||||
"test:integration": "RUN_INTEGRATION_TESTS=true vitest run",
|
||||
"test:run": "vitest run",
|
||||
@@ -59,23 +61,27 @@
|
||||
},
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.170",
|
||||
"@agentclientprotocol/sdk": "1.3.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.232",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.103.0",
|
||||
"@anthropic-ai/sdk": "0.117.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.9.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"eslint": "10.4.1",
|
||||
"@tsconfig/node22": "22.0.5",
|
||||
"@types/node": "26.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.67.0",
|
||||
"@typescript-eslint/parser": "8.67.0",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.6.0",
|
||||
"prettier": "3.8.4",
|
||||
"globals": "17.11.0",
|
||||
"prettier": "3.9.6",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.8"
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true,
|
||||
"include-component-in-tag": false,
|
||||
"packages": {
|
||||
".": {
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"release-type": "node",
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true
|
||||
"include-component-in-tag": false
|
||||
}
|
||||
},
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
||||
|
||||
124
scripts/release-preflight.sh
Executable file
124
scripts/release-preflight.sh
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Preflight checks for a release.
|
||||
#
|
||||
# release-please decides what the next version is; this script only verifies the
|
||||
# repository is in a state where merging the open release PR produces a correct
|
||||
# release. Merging main needs no review, so these checks are the only thing
|
||||
# standing between a mistake and a published package — keep them as code rather
|
||||
# than as a list someone has to remember. See docs/RELEASES.md.
|
||||
#
|
||||
# Requires an authenticated GitHub CLI. Nothing else: gh has a jq engine builtin.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PENDING_LABEL="autorelease: pending"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
pass() {
|
||||
printf 'ok %s\n' "$1"
|
||||
}
|
||||
|
||||
command -v gh >/dev/null 2>&1 ||
|
||||
fail "GitHub CLI (gh) is not installed."
|
||||
gh auth status >/dev/null 2>&1 ||
|
||||
fail "GitHub CLI is not authenticated. Run 'gh auth login'."
|
||||
|
||||
repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) ||
|
||||
fail "cannot work out which GitHub repository this is. Run from a checkout."
|
||||
|
||||
# A merged release PR that still carries the pending label means release-please
|
||||
# never tagged it. In that state it refuses to open any new release PR at all,
|
||||
# so every later release stalls silently until it is recovered by hand.
|
||||
stuck=$(gh pr list --repo "$repo" --state merged --label "$PENDING_LABEL" \
|
||||
--json number --jq 'map("#\(.number)") | join(", ")')
|
||||
[ -z "$stuck" ] ||
|
||||
fail "merged release PR still labelled '$PENDING_LABEL': $stuck.
|
||||
release-please is jammed and will not open new release PRs.
|
||||
See docs/RELEASES.md, 'Recovering a stalled release'."
|
||||
pass "no untagged merged release PR"
|
||||
|
||||
rows=$(gh pr list --repo "$repo" --state open --label "$PENDING_LABEL" \
|
||||
--json number,title,headRefName --jq '.[] | [.number, .headRefName, .title] | @tsv')
|
||||
count=$(printf '%s\n' "$rows" | grep -c . || true)
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
fail "no open release PR.
|
||||
Nothing has landed since the last tag that release-please puts in a
|
||||
changelog, so there is nothing to release. Land a feat/fix/perf/revert/docs
|
||||
change first, or check that the Publish and Release workflow is running."
|
||||
fi
|
||||
if [ "$count" -gt 1 ]; then
|
||||
fail "$count open release PRs, expected exactly one:
|
||||
$rows"
|
||||
fi
|
||||
IFS=$(printf '\t') read -r pr_number head_ref pr_title <<EOF
|
||||
$rows
|
||||
EOF
|
||||
pass "one open release PR: #$pr_number ($pr_title)"
|
||||
|
||||
# release-please reads the version to tag from the PR title, while npm publishes
|
||||
# whatever package.json says. If those disagree the tag and the published package
|
||||
# describe different releases, so check all three sources against each other.
|
||||
# The version is the last field, not everything after 'release': release-please
|
||||
# writes the component into the title as 'release <component> X.Y.Z' whenever it
|
||||
# is configured to tag with one.
|
||||
title_version=${pr_title##* }
|
||||
case $title_version in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) fail "cannot read a version from PR title '$pr_title'." ;;
|
||||
esac
|
||||
pkg_version=$(gh api "repos/$repo/contents/package.json?ref=$head_ref" \
|
||||
-H "Accept: application/vnd.github.raw" --jq .version)
|
||||
manifest_version=$(gh api "repos/$repo/contents/.release-please-manifest.json?ref=$head_ref" \
|
||||
-H "Accept: application/vnd.github.raw" --jq '."."')
|
||||
|
||||
if [ "$title_version" != "$pkg_version" ] || [ "$title_version" != "$manifest_version" ]; then
|
||||
fail "version mismatch on #$pr_number:
|
||||
PR title $title_version
|
||||
package.json $pkg_version
|
||||
.release-please-manifest.json $manifest_version"
|
||||
fi
|
||||
pass "version agrees across PR title, package.json and manifest: $title_version"
|
||||
|
||||
# The tag comes from the config, not from the PR title. With
|
||||
# include-component-in-tag left on, release-please tags <component>-vX.Y.Z, which
|
||||
# breaks the vX.Y.Z scheme the rest of this script and docs/RELEASES.md rely on,
|
||||
# and makes it rewrite the whole history into the changelog because no tag under
|
||||
# that scheme exists yet. The compare link it writes into the PR body names the
|
||||
# tags it is going to use, so check those rather than trusting the config.
|
||||
compare_tag=$(gh pr view "$pr_number" --repo "$repo" --json body --jq '
|
||||
.body | capture("/compare/[^)]*[.][.][.](?<to>[^)\\s]+)").to // ""' 2>/dev/null || true)
|
||||
case $compare_tag in
|
||||
"" | "v$title_version") ;;
|
||||
*) fail "release-please is going to tag '$compare_tag', not 'v$title_version'.
|
||||
Every previous release is tagged vX.Y.Z, and the changelog for #$pr_number
|
||||
covers the whole history rather than just this release, because no tag
|
||||
under that scheme exists. Set 'include-component-in-tag': false in
|
||||
release-please-config.json, then let it rewrite the release PR." ;;
|
||||
esac
|
||||
|
||||
if gh release view "v$title_version" --repo "$repo" >/dev/null 2>&1; then
|
||||
fail "tag v$title_version already exists. Merging would try to release it twice."
|
||||
fi
|
||||
pass "tag v$title_version does not exist yet"
|
||||
|
||||
build=$(gh pr view "$pr_number" --repo "$repo" --json statusCheckRollup --jq '
|
||||
[.statusCheckRollup[]? | select(.name == "Build")]
|
||||
| if length == 0 then "MISSING" else (.[0].conclusion // .[0].status // "PENDING") end')
|
||||
[ "$build" = "SUCCESS" ] ||
|
||||
fail "required check 'Build' is $build on #$pr_number, expected SUCCESS."
|
||||
pass "required check 'Build' passed"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Ready to release $title_version.
|
||||
|
||||
gh pr merge $pr_number --squash
|
||||
|
||||
Merging tags v$title_version, publishes to npm and updates the agent registry.
|
||||
EOF
|
||||
6364
src/acp-agent.ts
6364
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { CreateElicitationResponse } from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ElicitationAcceptAction,
|
||||
ElicitationPropertySchema,
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
@@ -68,6 +69,20 @@ export function mcpElicitationToCreateRequest(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of an accepted elicitation response.
|
||||
*
|
||||
* Uses the SDK's validating guard rather than an `action === "accept"` check:
|
||||
* the guard both narrows past the union's custom/future variant and validates
|
||||
* the payload, so a malformed accept (right tag, ill-typed content) yields
|
||||
* empty content — the same classification the SDK's wire validators apply.
|
||||
*/
|
||||
function acceptedElicitationContent(
|
||||
response: CreateElicitationResponse,
|
||||
): NonNullable<ElicitationAcceptAction["content"]> {
|
||||
return CreateElicitationResponse.isAccept(response) ? (response.content ?? {}) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an ACP elicitation response back to the MCP `ElicitResult` the SDK expects
|
||||
* to hand back to the requesting server.
|
||||
@@ -77,7 +92,7 @@ export function createElicitationResponseToElicitResult(
|
||||
): ElicitationResult {
|
||||
switch (response.action) {
|
||||
case "accept":
|
||||
return { action: "accept", content: response.content ?? {} };
|
||||
return { action: "accept", content: acceptedElicitationContent(response) };
|
||||
case "decline":
|
||||
return { action: "decline" };
|
||||
case "cancel":
|
||||
@@ -116,8 +131,30 @@ function questionFieldKey(index: number): string {
|
||||
return `question_${index}`;
|
||||
}
|
||||
|
||||
/** Form-field key for the optional free-text "custom answer" field. */
|
||||
const CUSTOM_ANSWER_FIELD = "customAnswer";
|
||||
/**
|
||||
* Form-field key for the per-question free-text "custom answer" field that sits
|
||||
* alongside `question_<n>`. Mirrors the first-party clients, where every
|
||||
* question carries its own "Other" box rather than one form-level field.
|
||||
*/
|
||||
function questionCustomFieldKey(index: number): string {
|
||||
return `question_${index}_custom`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_meta` key under which a bridged enum option carries its `preview`, the one
|
||||
* option field ACP's `EnumOption` still has no slot for (descriptions are
|
||||
* first-class as of schema 1.19). Namespaced like the agent's other `_meta`
|
||||
* extensions (`_claude/...`).
|
||||
*/
|
||||
const OPTION_META_KEY = "_claude/askUserQuestionOption";
|
||||
|
||||
/**
|
||||
* Shared `_meta` key for marking a per-question free-text field as the custom
|
||||
* answer companion for a select question. This intentionally has no
|
||||
* agent-specific namespace so ACP clients can recognize the same marker across
|
||||
* Codex, Claude, and other AskUserQuestion bridges.
|
||||
*/
|
||||
const CUSTOM_ANSWER_META_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
/**
|
||||
* Render the AskUserQuestion tool's questions as an ACP form elicitation.
|
||||
@@ -126,12 +163,14 @@ const CUSTOM_ANSWER_FIELD = "customAnswer";
|
||||
* question text, so the question text appears in exactly one place per field.
|
||||
* Single-select questions use a titled `oneOf` enum; multi-select questions use
|
||||
* an array with a titled `anyOf` item enum. The enum `const` is always the
|
||||
* option label, since that is what the tool records as the answer.
|
||||
* option label, since that is what the tool records as the answer; an option's
|
||||
* secondary text travels in the enum option's own `description` field.
|
||||
*
|
||||
* A trailing optional free-text field mirrors the CLI's custom-answer box: the
|
||||
* user can type their own answer instead of (or as well as) picking an option.
|
||||
* Nothing is marked required, so the user can also just skip — matching the
|
||||
* built-in tool, which always offers Skip + a free-text box.
|
||||
* Each question is followed by its own optional free-text "custom answer" field
|
||||
* (`question_<n>_custom`), mirroring the CLI's per-question "Other" box: the
|
||||
* user can type their own answer instead of picking an option, scoped to that
|
||||
* specific question. Nothing is marked required, so the user can also just skip
|
||||
* — matching the built-in tool, which always offers Skip + a free-text box.
|
||||
*/
|
||||
export function askUserQuestionsToCreateRequest(
|
||||
questions: AskUserQuestion[],
|
||||
@@ -142,10 +181,22 @@ export function askUserQuestionsToCreateRequest(
|
||||
const properties: Record<string, ElicitationPropertySchema> = {};
|
||||
|
||||
questions.forEach((question, index) => {
|
||||
const options: EnumOption[] = question.options.map((option) => ({
|
||||
const: option.label,
|
||||
title: option.description ? `${option.label} — ${option.description}` : option.label,
|
||||
}));
|
||||
const options: EnumOption[] = question.options.map((option) => {
|
||||
const enumOption: EnumOption = {
|
||||
const: option.label,
|
||||
title: option.label,
|
||||
};
|
||||
if (option.description) {
|
||||
enumOption.description = option.description;
|
||||
}
|
||||
// The SDK option's `preview` (mockups, code snippets, comparisons shown
|
||||
// on focus) still has no structural slot in `EnumOption`, so forward it
|
||||
// under ACP's reserved `_meta` extension point for clients that render it.
|
||||
if (option.preview) {
|
||||
enumOption._meta = { [OPTION_META_KEY]: { preview: option.preview } };
|
||||
}
|
||||
return enumOption;
|
||||
});
|
||||
|
||||
// For a single question the prompt is carried by `message`, so we don't
|
||||
// repeat it in the field description. With multiple questions each field
|
||||
@@ -156,13 +207,19 @@ export function askUserQuestionsToCreateRequest(
|
||||
properties[questionFieldKey(index)] = question.multiSelect
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
});
|
||||
|
||||
properties[CUSTOM_ANSWER_FIELD] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
description: "Type your own answer instead of choosing an option above (optional).",
|
||||
};
|
||||
properties[questionCustomFieldKey(index)] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
description: "Type your own answer instead of choosing an option above (optional).",
|
||||
_meta: {
|
||||
[CUSTOM_ANSWER_META_KEY]: {
|
||||
questionId: questionFieldKey(index),
|
||||
isCustomAnswer: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const requestedSchema: ElicitationSchema = {
|
||||
type: "object",
|
||||
@@ -182,37 +239,46 @@ export function askUserQuestionsToCreateRequest(
|
||||
|
||||
/** Outcome of an AskUserQuestion elicitation, decoupled from any transport. */
|
||||
export type AskUserQuestionOutcome =
|
||||
| { action: "answered"; updatedInput: Record<string, unknown> }
|
||||
| { action: "cancel" };
|
||||
{ action: "answered"; updatedInput: Record<string, unknown> } | { action: "cancel" };
|
||||
|
||||
/**
|
||||
* Fold an ACP elicitation response into the AskUserQuestion tool's input.
|
||||
*
|
||||
* Selected labels are read back from the indexed form fields and written into
|
||||
* `answers` as a `{ [questionText]: label }` map (comma-joining multi-selects)
|
||||
* — the key shape the tool's own `call()` reads. Free text from the custom-
|
||||
* answer field becomes the tool's top-level `response`. Decline yields empty
|
||||
* answers (the model is told the user skipped rather than the turn aborting);
|
||||
* cancel aborts the tool call.
|
||||
* — the key shape the tool's own `call()` reads. A non-empty per-question
|
||||
* custom-answer field (`question_<n>_custom`) takes precedence over that
|
||||
* question's selection, since the user typed their own answer instead of
|
||||
* picking one. Decline yields empty answers (the model is told the user skipped
|
||||
* rather than the turn aborting); cancel — and any custom/future action we
|
||||
* don't understand — aborts the tool call.
|
||||
*/
|
||||
export function applyAskElicitationResponse(
|
||||
response: CreateElicitationResponse,
|
||||
toolInput: Record<string, unknown>,
|
||||
questions: AskUserQuestion[],
|
||||
): AskUserQuestionOutcome {
|
||||
if (response.action === "cancel") {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
if (response.action === "decline") {
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
|
||||
}
|
||||
|
||||
const content = response.content ?? {};
|
||||
if (response.action !== "accept") {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
|
||||
const content = acceptedElicitationContent(response);
|
||||
// Typed against the tool's own output schema so the answer/response shapes
|
||||
// stay in sync with what the built-in tool's call() expects to read back.
|
||||
const answers: AskUserQuestionOutput["answers"] = {};
|
||||
questions.forEach((question, index) => {
|
||||
// A typed custom answer wins over the selection: the user chose to write
|
||||
// their own answer for this question instead of picking an option.
|
||||
const custom = content[questionCustomFieldKey(index)];
|
||||
if (typeof custom === "string" && custom.trim() !== "") {
|
||||
answers[question.question] = custom.trim();
|
||||
return;
|
||||
}
|
||||
|
||||
const value = content[questionFieldKey(index)];
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
@@ -224,14 +290,7 @@ export function applyAskElicitationResponse(
|
||||
answers[question.question] = text;
|
||||
});
|
||||
|
||||
const updatedInput: Record<string, unknown> = { ...toolInput, answers };
|
||||
const custom = content[CUSTOM_ANSWER_FIELD];
|
||||
if (typeof custom === "string" && custom.trim() !== "") {
|
||||
const response: AskUserQuestionOutput["response"] = custom;
|
||||
updatedInput.response = response;
|
||||
}
|
||||
|
||||
return { action: "answered", updatedInput };
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,3 +306,113 @@ function normalizeElicitationSchema(
|
||||
}
|
||||
return { ...(schema as ElicitationSchema), type: "object" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `request_user_dialog` kind the CLI emits when a model refusal has a
|
||||
* fallback available but needs user consent before retrying (e.g. Claude Fable
|
||||
* declining a request with Opus available as the fallback). Declaring this
|
||||
* kind in `supportedDialogKinds` is the opt-in: the CLI fails closed and never
|
||||
* emits an undeclared kind — the flow degrades to the classic refusal error
|
||||
* ending the turn.
|
||||
*/
|
||||
export const REFUSAL_FALLBACK_DIALOG_KIND = "refusal_fallback_prompt";
|
||||
|
||||
/**
|
||||
* Payload of the `refusal_fallback_prompt` dialog. The dialog protocol
|
||||
* transports payloads opaquely, so this shape is recovered from the CLI's own
|
||||
* schema (v2.1.177): `originalModel`/`fallbackModel` are required strings;
|
||||
* `apiRefusalCategory` (nullable), `guidanceText`, and
|
||||
* `retractedMessageUuids` are optional. We ignore `retractedMessageUuids` —
|
||||
* ACP has no way to retract already-streamed chunks.
|
||||
*/
|
||||
export type RefusalFallbackPrompt = {
|
||||
originalModel: string;
|
||||
fallbackModel: string;
|
||||
apiRefusalCategory: string | null;
|
||||
guidanceText?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the opaque dialog payload into a {@link RefusalFallbackPrompt}.
|
||||
* Returns `null` when the required fields are missing or mistyped (a newer CLI
|
||||
* may reshape the payload), so the caller can cancel the dialog and let the
|
||||
* CLI apply its default behavior instead of rendering something misleading.
|
||||
*/
|
||||
export function extractRefusalFallbackPrompt(
|
||||
payload: Record<string, unknown>,
|
||||
): RefusalFallbackPrompt | null {
|
||||
const { originalModel, fallbackModel, apiRefusalCategory, guidanceText } = payload;
|
||||
if (typeof originalModel !== "string" || typeof fallbackModel !== "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
originalModel,
|
||||
fallbackModel,
|
||||
apiRefusalCategory: typeof apiRefusalCategory === "string" ? apiRefusalCategory : null,
|
||||
...(typeof guidanceText === "string" && guidanceText ? { guidanceText } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Form-field key carrying the user's choice in the refusal-fallback form. */
|
||||
const REFUSAL_FALLBACK_CHOICE_KEY = "choice";
|
||||
|
||||
/** Wire values of the dialog's result enum (CLI schema). `edit_prompt` is
|
||||
* deliberately not offered: in the CLI it prefills the composer with the
|
||||
* refused prompt for edit-and-retry, and ACP has no composer-prefill surface
|
||||
* — the user can simply edit and resend on their own. */
|
||||
const RETRY_FALLBACK_RESULT = "retry_fallback";
|
||||
const KEEP_REFUSAL_RESULT = "cancelled";
|
||||
|
||||
/**
|
||||
* Render the refusal-fallback consent prompt as an ACP form elicitation: a
|
||||
* single-select between retrying on the fallback model and keeping the
|
||||
* refusal. The enum `const`s are the dialog's wire result values, so the
|
||||
* response maps back without a translation table.
|
||||
*/
|
||||
export function refusalFallbackToCreateRequest(
|
||||
prompt: RefusalFallbackPrompt,
|
||||
sessionId: string,
|
||||
): CreateElicitationRequest {
|
||||
const category = prompt.apiRefusalCategory ? ` (${prompt.apiRefusalCategory})` : "";
|
||||
const guidance = prompt.guidanceText ? `\n\n${prompt.guidanceText}` : "";
|
||||
return {
|
||||
mode: "form",
|
||||
sessionId,
|
||||
message:
|
||||
`${prompt.originalModel} declined this request${category}. ` +
|
||||
`Retry with ${prompt.fallbackModel}?` +
|
||||
guidance,
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
[REFUSAL_FALLBACK_CHOICE_KEY]: {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{
|
||||
const: RETRY_FALLBACK_RESULT,
|
||||
title: `Retry with ${prompt.fallbackModel}`,
|
||||
description: `The session continues on ${prompt.fallbackModel}.`,
|
||||
},
|
||||
{
|
||||
const: KEEP_REFUSAL_RESULT,
|
||||
title: "Keep the refusal",
|
||||
description: "You can send a new message.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the elicitation response back to the dialog's result enum. Only an
|
||||
* explicit accept-with-retry resolves to `retry_fallback`; decline, cancel, a
|
||||
* skipped field, or an unrecognized value all keep the refusal — the dialog's
|
||||
* own default — so a dismissed or half-filled form can never trigger a model
|
||||
* switch the user didn't ask for.
|
||||
*/
|
||||
export function refusalFallbackResultFromResponse(response: CreateElicitationResponse): string {
|
||||
const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY];
|
||||
return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
|
||||
}
|
||||
|
||||
83
src/goal-extension.ts
Normal file
83
src/goal-extension.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk";
|
||||
import type { SDKActiveGoalMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const GOAL_EXTENSION_VERSION = 1 as const;
|
||||
export const GOAL_CONTROL_METHOD = "_session/goal";
|
||||
|
||||
export const GOAL_ACTIONS = ["set", "clear"] as const;
|
||||
export type GoalAction = (typeof GOAL_ACTIONS)[number];
|
||||
|
||||
export type GoalCapability = {
|
||||
version: typeof GOAL_EXTENSION_VERSION;
|
||||
controlMethod: typeof GOAL_CONTROL_METHOD;
|
||||
actions: GoalAction[];
|
||||
};
|
||||
|
||||
export type GoalStatus = "active" | "paused" | "blocked" | "limited" | "complete";
|
||||
|
||||
export type GoalSnapshot = {
|
||||
objective: string;
|
||||
status: GoalStatus;
|
||||
iterations?: number;
|
||||
lastReason?: string | null;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
tokenBudget?: number | null;
|
||||
tokensUsed?: number;
|
||||
timeUsedSeconds?: number;
|
||||
controlMethod: typeof GOAL_CONTROL_METHOD;
|
||||
};
|
||||
|
||||
export type GoalRequest =
|
||||
{ sessionId: string; action: "clear" } | { sessionId: string; action: "set"; objective: string };
|
||||
|
||||
export type GoalControlResponse = Record<string, never>;
|
||||
|
||||
export function goalUpdateFromPrompt(prompt: string): GoalSnapshot | null | undefined {
|
||||
const match = /^\/goal(?:\s+([\s\S]*))?$/.exec(prompt);
|
||||
const argument = match?.[1]?.trim();
|
||||
if (!argument) {
|
||||
return undefined;
|
||||
}
|
||||
if (argument === "clear") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
objective: argument,
|
||||
status: "active",
|
||||
controlMethod: GOAL_CONTROL_METHOD,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGoalRequest(params: unknown): GoalRequest {
|
||||
if (!params || typeof params !== "object") {
|
||||
throw RequestError.invalidParams(undefined, "goal params must be an object");
|
||||
}
|
||||
const { sessionId, action, objective } = params as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "goal params require a non-empty sessionId");
|
||||
}
|
||||
if (!GOAL_ACTIONS.includes(action as GoalAction)) {
|
||||
throw RequestError.invalidParams(undefined, 'goal action must be "set" or "clear"');
|
||||
}
|
||||
if (action === "set" && (typeof objective !== "string" || objective.trim().length === 0)) {
|
||||
throw RequestError.invalidParams(undefined, 'goal action "set" requires a non-empty objective');
|
||||
}
|
||||
return action === "set"
|
||||
? { sessionId, action, objective: (objective as string).trim() }
|
||||
: { sessionId, action: "clear" };
|
||||
}
|
||||
|
||||
export function toGoalSnapshot(message: SDKActiveGoalMessage): GoalSnapshot | null {
|
||||
if (message.value === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
objective: message.value.condition.trim(),
|
||||
status: "active",
|
||||
iterations: message.value.iterations,
|
||||
lastReason: message.value.last_reason ?? null,
|
||||
createdAt: message.value.set_at,
|
||||
controlMethod: GOAL_CONTROL_METHOD,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
// `--cli` is checked first so that `--version`/`-v` (and any other flags) are
|
||||
// forwarded to the wrapped native CLI rather than swallowed by our own version
|
||||
// handler below. Our version flag only applies when not delegating.
|
||||
if (process.argv.includes("--cli")) {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const args = process.argv.slice(2).filter((arg) => arg !== "--cli");
|
||||
@@ -33,6 +37,9 @@ if (process.argv.includes("--cli")) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
} else if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
||||
console.log(packageJson.version);
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Apply env vars from the managed-policy tier before any SDK call so the
|
||||
// SDK subprocess inherits them. Going through resolveSettings (vs. a raw
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({
|
||||
querySpy: vi.fn(),
|
||||
@@ -21,13 +21,13 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
function createMockClient(): AcpClient {
|
||||
return {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
function mockQuery() {
|
||||
@@ -35,7 +35,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
@@ -46,8 +46,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
return { getCapturedOptions: () => capturedOptions, setModelSpy };
|
||||
}
|
||||
@@ -241,12 +240,11 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
const setPermissionModeSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
setPermissionMode: setPermissionModeSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
return {
|
||||
getCapturedOptions: () => capturedOptions,
|
||||
@@ -397,11 +395,10 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
} {
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
return { setModelSpy };
|
||||
}
|
||||
@@ -700,6 +697,167 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]");
|
||||
expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("preserves ANTHROPIC_CUSTOM_MODEL_OPTION even when absent from the allowlist", async () => {
|
||||
// Per the model-config docs, ANTHROPIC_CUSTOM_MODEL_OPTION adds an entry
|
||||
// "without replacing the built-in aliases" and "appears at the bottom of
|
||||
// the /model picker", so it is exempt from the availableModels allowlist
|
||||
// (the same way the Default option is "not affected by availableModels").
|
||||
// ACP must match: a slim alias allowlist must not hide the custom model
|
||||
// row, and it appears last, after the allowlisted entries.
|
||||
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
|
||||
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["sonnet", "opus", "haiku"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
{ value: "opus", displayName: "Opus", description: "Claude Opus 4.6" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Claude Haiku 4.5" },
|
||||
{
|
||||
value: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"sonnet",
|
||||
"opus",
|
||||
"haiku",
|
||||
"claude-opus-4-8[1m]",
|
||||
]);
|
||||
const custom = modelOption.options.find((o: any) => o.value === "claude-opus-4-8[1m]");
|
||||
expect(custom.name).toBe("Opus 4.8");
|
||||
expect(custom.description).toBe("Claude Opus 4.8");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
} else {
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate the custom model option when also in the allowlist", async () => {
|
||||
// If the user lists the custom model's exact ID in availableModels AND it
|
||||
// is set as ANTHROPIC_CUSTOM_MODEL_OPTION, it must appear exactly once.
|
||||
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
|
||||
try {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["sonnet", "claude-opus-4-8[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
{
|
||||
value: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus 4.8",
|
||||
description: "Claude Opus 4.8",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"sonnet",
|
||||
"claude-opus-4-8[1m]",
|
||||
]);
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
||||
} else {
|
||||
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("maps allowlist aliases through modelOverrides to provider IDs", async () => {
|
||||
// Bedrock/Vertex deployments enforce short aliases in availableModels
|
||||
// but require provider-specific IDs at the API. Without applying
|
||||
// modelOverrides during allowlist resolution, the alias is surfaced as
|
||||
// the model value and passed verbatim to setModel, which the provider
|
||||
// API rejects with a 400 "invalid model identifier".
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-opus-4-6", "claude-sonnet-4-6"],
|
||||
model: "claude-opus-4-6",
|
||||
modelOverrides: {
|
||||
"claude-opus-4-6": "global.anthropic.claude-opus-4-6-v1[1m]",
|
||||
"claude-sonnet-4-6": "global.anthropic.claude-sonnet-4-6[1m]",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { setModelSpy } = mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "opus", displayName: "Opus", description: "Claude Opus 4.6" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"global.anthropic.claude-opus-4-6-v1[1m]",
|
||||
"global.anthropic.claude-sonnet-4-6[1m]",
|
||||
]);
|
||||
// The allowlist synthesized the overridden ID (the SDK only surfaced the
|
||||
// `opus` alias), so the adapter syncs the provider ID via setModel — never
|
||||
// the raw alias that Bedrock would reject.
|
||||
expect(setModelSpy).toHaveBeenCalledWith("global.anthropic.claude-opus-4-6-v1[1m]");
|
||||
expect(modelOption.currentValue).toBe("global.anthropic.claude-opus-4-6-v1[1m]");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
@@ -715,7 +873,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options: _options }: any) => {
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
@@ -731,8 +889,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
@@ -763,7 +920,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
@@ -772,8 +929,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -817,7 +973,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
@@ -825,8 +981,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,36 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: ({ options }: { options: Options }) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
},
|
||||
}));
|
||||
);
|
||||
const { makeMockQuery } = await import("./helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
query: ({ options }: { options: Options }) => {
|
||||
capturedOptions = options;
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
@@ -52,7 +52,7 @@ describe("additionalRoots", () => {
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection);
|
||||
} as unknown as AcpClient);
|
||||
});
|
||||
|
||||
afterEach(
|
||||
|
||||
42
src/tests/agent-discovery.test.ts
Normal file
42
src/tests/agent-discovery.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../acp-agent.js";
|
||||
|
||||
// `discoverCustomAgents` distinguishes user/plugin-configured agents from
|
||||
// Claude Code's built-in subagents by name (BUILTIN_AGENT_NAMES), since the
|
||||
// SDK's `AgentInfo` carries no built-in/source flag. That hardcoded set drifts
|
||||
// silently if the SDK changes its default roster: a newly added built-in would
|
||||
// leak into the ACP "Agent" picker as if the user had configured it. This
|
||||
// integration test runs the real SDK so the set is flagged the moment it does.
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("agent discovery (SDK)", () => {
|
||||
it("BUILTIN_AGENT_NAMES exactly covers the SDK's default agent roster", async () => {
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId: randomUUID(),
|
||||
// No setting sources → no user/project/local custom agents are loaded,
|
||||
// so `supportedAgents()` reports exactly the intrinsic built-ins
|
||||
// regardless of what the developer has in ~/.claude/agents.
|
||||
settingSources: [],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const reported = (await q.supportedAgents()).map((a) => a.name).sort();
|
||||
expect(reported).toEqual([...BUILTIN_AGENT_NAMES].sort());
|
||||
|
||||
// With the built-ins being the only agents present, discovery surfaces
|
||||
// nothing — the ACP "Agent" picker is correctly omitted.
|
||||
expect(await discoverCustomAgents(q)).toEqual([]);
|
||||
} finally {
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -1,19 +1,8 @@
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
initializationResult: vi.fn().mockResolvedValue({
|
||||
models: [
|
||||
{ value: "id", displayName: "name", description: "description", supportsAutoMode: true },
|
||||
],
|
||||
}),
|
||||
setModel: vi.fn(),
|
||||
setPermissionMode: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
);
|
||||
const mockQuery = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
@@ -25,6 +14,22 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
describe("authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Set here (not in vi.fn(impl) at hoist time) so the helper import is
|
||||
// available; afterEach's resetAllMocks clears it, beforeEach re-sets it.
|
||||
mockQuery.mockImplementation(() =>
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "id",
|
||||
displayName: "name",
|
||||
description: "description",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -39,7 +44,7 @@ describe("authorization", () => {
|
||||
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
|
||||
const connectionMock = {
|
||||
sessionUpdate: async (_: any) => {},
|
||||
} as AgentSideConnection;
|
||||
} as AcpClient;
|
||||
|
||||
const agent = new ClaudeAcpAgent(connectionMock);
|
||||
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
let contextUsageResult: (() => Promise<{ rawMaxTokens: number; model?: string }>) | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
const { makeMockQuery, DEFAULT_CONTEXT_USAGE } = await import("./helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
query: (args: { prompt: unknown; options: Options }) => {
|
||||
capturedOptions = args.options;
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
@@ -26,11 +28,9 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
getContextUsage: () =>
|
||||
contextUsageResult ? contextUsageResult() : Promise.resolve(DEFAULT_CONTEXT_USAGE),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -47,17 +47,18 @@ describe("createSession options merging", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
function createMockClient(): AcpClient {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedOptions = undefined;
|
||||
contextUsageResult = undefined;
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
@@ -262,6 +263,38 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
});
|
||||
|
||||
describe("subagent transcript forwarding", () => {
|
||||
it("keeps the legacy default when neither the client nor caller opts in", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.forwardSubagentText).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the pre-existing caller-provided SDK option", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { forwardSubagentText: true } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.forwardSubagentText).toBe(true);
|
||||
});
|
||||
|
||||
it("enables SDK forwarding when the ACP client advertises support", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { "subagent-transcript": true } },
|
||||
});
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { forwardSubagentText: false } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.forwardSubagentText).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemPrompt via _meta", () => {
|
||||
it("defaults to the claude_code preset when not provided", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
@@ -514,8 +547,13 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("ignores a non-numeric value", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "lots";
|
||||
// The bad value is deliberate — capture the agent's warning instead of
|
||||
// letting it hit the console.
|
||||
const errorSpy = vi.fn();
|
||||
(agent as any).logger = { log: () => {}, error: errorSpy };
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("MAX_THINKING_TOKENS"));
|
||||
});
|
||||
|
||||
it("lets a user-provided thinking option override the env default", async () => {
|
||||
@@ -550,14 +588,15 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
it("rejects a cwd that points at a file with invalidParams", async () => {
|
||||
const file = path.join(os.tmpdir(), "claude-acp-cwd-is-a-file.txt");
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-cwd-is-a-file-"));
|
||||
const file = path.join(dir, "not-a-directory.txt");
|
||||
fs.writeFileSync(file, "not a directory");
|
||||
try {
|
||||
await expect(agent.newSession({ cwd: file, mcpServers: [] })).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(file, { force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -612,4 +651,240 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
|
||||
});
|
||||
});
|
||||
|
||||
describe("context window seeding", () => {
|
||||
function sessionFor(sessionId: string) {
|
||||
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];
|
||||
}
|
||||
|
||||
it("does not call getContextUsage during session creation", async () => {
|
||||
// getContextUsage stalls until the session's first prompt turn has run
|
||||
// (it is not serviced pre-turn), so session/new must never call it —
|
||||
// awaiting it inline is what regressed session/new latency in 0.59.0.
|
||||
const ctxSpy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
contextUsageResult = ctxSpy;
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(ctxSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeds contextWindowSize from text inference, falling back to the default when it misses", async () => {
|
||||
// The mock model ("claude-sonnet-4-6" / "Claude Sonnet" / "Fast") carries
|
||||
// no "1m" token anywhere, so inference misses and the window falls back to
|
||||
// the default; the authoritative value arrives later via result.modelUsage.
|
||||
contextUsageResult = async () => ({ rawMaxTokens: 967000 });
|
||||
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
|
||||
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
|
||||
});
|
||||
|
||||
it("session/load seeds the window from the resumed session's getContextUsage report", async () => {
|
||||
// Resumed sessions get getContextUsage serviced pre-turn (issue #845 uses
|
||||
// it to restore the live model), and the same response carries the
|
||||
// authoritative window (`rawMaxTokens`). After a process restart the
|
||||
// module cache is empty and text inference misses natively-1M aliases, so
|
||||
// discarding this in-hand value would replay the issue-#596 flicker on
|
||||
// every reload — the flagship scenario. 888_000 can only come from the
|
||||
// report: inference on the mock model yields null → 200_000 default.
|
||||
contextUsageResult = async () => ({ rawMaxTokens: 888_000, model: "claude-sonnet-4-6" });
|
||||
|
||||
await (
|
||||
agent as unknown as {
|
||||
createSession: (params: object, opts: { resume?: string }) => Promise<unknown>;
|
||||
}
|
||||
).createSession({ cwd: process.cwd(), mcpServers: [] }, { resume: "resumed-window-probe" });
|
||||
|
||||
const session = sessionFor("resumed-window-probe");
|
||||
expect(session.contextWindowSize).toBe(888_000);
|
||||
expect(session.contextWindowAuthoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes providerCacheKey by per-session env routing", async () => {
|
||||
// The context-window cache key must distinguish backends exactly as the
|
||||
// CLI will see them: a session routed to a proxy via _meta env shares a
|
||||
// model id spelling with default-routed sessions but not a context lane,
|
||||
// so it must land in its own cache bucket (and two default-routed
|
||||
// sessions must share one).
|
||||
const r1 = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const r2 = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: { env: { ANTHROPIC_BASE_URL: "https://window-probe-proxy.example" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const r3 = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(sessionFor(r2.sessionId).providerCacheKey).not.toBe(
|
||||
sessionFor(r1.sessionId).providerCacheKey,
|
||||
);
|
||||
expect(sessionFor(r3.sessionId).providerCacheKey).toBe(
|
||||
sessionFor(r1.sessionId).providerCacheKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes providerCacheKey by provider headers: same endpoint, different headers → different buckets", async () => {
|
||||
// Two providers/set configs sharing apiType+baseUrl but differing in
|
||||
// headers (e.g. an `anthropic-beta: context-1m-…` routing header) can
|
||||
// serve different context lanes for the same model id, so they must not
|
||||
// share a window-cache bucket.
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gw.example",
|
||||
headers: {},
|
||||
});
|
||||
const plain = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gw.example",
|
||||
headers: { "anthropic-beta": "context-1m-2025-08-07" },
|
||||
});
|
||||
const beta = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(sessionFor(beta.sessionId).providerCacheKey).not.toBe(
|
||||
sessionFor(plain.sessionId).providerCacheKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusal fallback dialog", () => {
|
||||
it("declares the dialog kind and callback when the client supports form elicitation", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.supportedDialogKinds).toEqual(["refusal_fallback_prompt"]);
|
||||
expect(typeof capturedOptions!.onUserDialog).toBe("function");
|
||||
});
|
||||
|
||||
it("omits the dialog wiring without form elicitation support (url-only included)", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { url: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
// The CLI fails closed on the undeclared kind, so the flow degrades to
|
||||
// the classic refusal error instead of parking a dialog we can't render.
|
||||
expect(capturedOptions!.supportedDialogKinds).toBeUndefined();
|
||||
expect(capturedOptions!.onUserDialog).toBeUndefined();
|
||||
});
|
||||
|
||||
/** Wire the session up with form support and return the captured dialog
|
||||
* callback plus a mock the test can point the elicitation response at. */
|
||||
async function setupDialog() {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { form: {} } },
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const createElicitation = vi.fn();
|
||||
(
|
||||
agent as unknown as { client: { unstable_createElicitation: unknown } }
|
||||
).client.unstable_createElicitation = createElicitation;
|
||||
return { onUserDialog: capturedOptions!.onUserDialog!, createElicitation };
|
||||
}
|
||||
|
||||
const signal = () => ({ signal: new AbortController().signal, requestId: "1" });
|
||||
|
||||
it("renders the prompt as a form elicitation and maps the retry choice", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
createElicitation.mockResolvedValue({
|
||||
action: "accept",
|
||||
content: { choice: "retry_fallback" },
|
||||
});
|
||||
|
||||
const result = await onUserDialog(
|
||||
{
|
||||
dialogKind: "refusal_fallback_prompt",
|
||||
payload: {
|
||||
originalModel: "claude-fable-5",
|
||||
fallbackModel: "claude-opus-4-8",
|
||||
apiRefusalCategory: "cyber",
|
||||
},
|
||||
},
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "completed", result: "retry_fallback" });
|
||||
const request = createElicitation.mock.calls[0][0];
|
||||
expect(request.mode).toBe("form");
|
||||
expect(request.message).toContain("claude-fable-5");
|
||||
expect(request.message).toContain("claude-opus-4-8");
|
||||
});
|
||||
|
||||
it("completes with cancelled when the user keeps the refusal", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
createElicitation.mockResolvedValue({ action: "cancel" });
|
||||
|
||||
const result = await onUserDialog(
|
||||
{
|
||||
dialogKind: "refusal_fallback_prompt",
|
||||
payload: { originalModel: "a", fallbackModel: "b" },
|
||||
},
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "completed", result: "cancelled" });
|
||||
});
|
||||
|
||||
it("cancels unrecognized dialog kinds without presenting anything", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
|
||||
const result = await onUserDialog(
|
||||
{ dialogKind: "some_future_dialog", payload: {} },
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "cancelled" });
|
||||
expect(createElicitation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels on a malformed payload without presenting anything", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
// The malformed payload is deliberate — capture the agent's warning
|
||||
// instead of letting it hit the console.
|
||||
const errorSpy = vi.fn();
|
||||
(agent as any).logger = { log: () => {}, error: errorSpy };
|
||||
|
||||
const result = await onUserDialog(
|
||||
{ dialogKind: "refusal_fallback_prompt", payload: { fallbackModel: 42 } },
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "cancelled" });
|
||||
expect(createElicitation).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("unexpected shape"));
|
||||
});
|
||||
|
||||
it("cancels when the elicitation request fails", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
createElicitation.mockRejectedValue(new Error("client exploded"));
|
||||
// The client failure is deliberate — capture the agent's warning
|
||||
// instead of letting it hit the console.
|
||||
const errorSpy = vi.fn();
|
||||
(agent as any).logger = { log: () => {}, error: errorSpy };
|
||||
|
||||
const result = await onUserDialog(
|
||||
{
|
||||
dialogKind: "refusal_fallback_prompt",
|
||||
payload: { originalModel: "a", fallbackModel: "b" },
|
||||
},
|
||||
signal(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "cancelled" });
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("client exploded"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CreateElicitationRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ElicitationRequest } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
applyAskElicitationResponse,
|
||||
@@ -7,7 +11,10 @@ import {
|
||||
AskUserQuestion,
|
||||
createElicitationResponseToElicitResult,
|
||||
extractAskUserQuestions,
|
||||
extractRefusalFallbackPrompt,
|
||||
mcpElicitationToCreateRequest,
|
||||
refusalFallbackResultFromResponse,
|
||||
refusalFallbackToCreateRequest,
|
||||
} from "../elicitation.js";
|
||||
|
||||
const SESSION_ID = "session-1";
|
||||
@@ -15,18 +22,22 @@ const SESSION_ID = "session-1";
|
||||
/**
|
||||
* Build a question matching the SDK's (strict) AskUserQuestion schema while
|
||||
* keeping test sites terse. Option descriptions default to "" (the tool always
|
||||
* provides one); pass a description to exercise the "label — description" title.
|
||||
* provides one); pass a description to exercise the enum option's description.
|
||||
*/
|
||||
function mkQuestion(
|
||||
question: string,
|
||||
options: Array<{ label: string; description?: string }>,
|
||||
options: Array<{ label: string; description?: string; preview?: string }>,
|
||||
opts: { header?: string; multiSelect?: boolean } = {},
|
||||
): AskUserQuestion {
|
||||
return {
|
||||
question,
|
||||
header: opts.header ?? "",
|
||||
multiSelect: opts.multiSelect ?? false,
|
||||
options: options.map((o) => ({ label: o.label, description: o.description ?? "" })),
|
||||
options: options.map((o) => ({
|
||||
label: o.label,
|
||||
description: o.description ?? "",
|
||||
...(o.preview ? { preview: o.preview } : {}),
|
||||
})),
|
||||
} as AskUserQuestion;
|
||||
}
|
||||
|
||||
@@ -116,6 +127,14 @@ describe("createElicitationResponseToElicitResult", () => {
|
||||
createElicitationResponseToElicitResult({ action: "cancel" } as CreateElicitationResponse),
|
||||
).toEqual({ action: "cancel" });
|
||||
});
|
||||
|
||||
it("maps a custom/future action to cancel", () => {
|
||||
expect(
|
||||
createElicitationResponseToElicitResult({
|
||||
action: "_zed/snooze",
|
||||
} as CreateElicitationResponse),
|
||||
).toEqual({ action: "cancel" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAskUserQuestions", () => {
|
||||
@@ -179,7 +198,10 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
title: "Library",
|
||||
description: undefined,
|
||||
oneOf: [
|
||||
{ const: "date-fns", title: "date-fns — Lightweight" },
|
||||
// The description travels in the enum option's first-class field; the
|
||||
// title stays the clean label (no "label — description" flattening).
|
||||
{ const: "date-fns", title: "date-fns", description: "Lightweight" },
|
||||
// No description → the field is omitted entirely.
|
||||
{ const: "luxon", title: "luxon" },
|
||||
],
|
||||
});
|
||||
@@ -187,8 +209,50 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
expect(schema.properties?.["Which library?"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes an optional free-text custom-answer field", () => {
|
||||
const questions = [mkQuestion("Which?", [{ label: "A" }, { label: "B" }])];
|
||||
it("carries descriptions structurally and forwards previews under _meta", () => {
|
||||
const questions = [
|
||||
mkQuestion("Which layout?", [
|
||||
{
|
||||
label: "Grid",
|
||||
description: "Cards in a responsive grid",
|
||||
preview: "```\n[ ] [ ] [ ]\n[ ] [ ] [ ]\n```",
|
||||
},
|
||||
{ label: "List", description: "Stacked rows" },
|
||||
{ label: "Plain" },
|
||||
]),
|
||||
];
|
||||
|
||||
const schema = (
|
||||
askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined) as Extract<
|
||||
CreateElicitationRequest,
|
||||
{ mode: "form" }
|
||||
>
|
||||
).requestedSchema;
|
||||
const oneOf = (schema.properties?.["question_0"] as { oneOf: EnumOption[] }).oneOf;
|
||||
|
||||
// The description uses the enum option's own field; only the preview —
|
||||
// which still has no structural slot — rides under `_meta`.
|
||||
expect(oneOf[0]).toEqual({
|
||||
const: "Grid",
|
||||
title: "Grid",
|
||||
description: "Cards in a responsive grid",
|
||||
_meta: {
|
||||
"_claude/askUserQuestionOption": {
|
||||
preview: "```\n[ ] [ ] [ ]\n[ ] [ ] [ ]\n```",
|
||||
},
|
||||
},
|
||||
});
|
||||
// Description only → no _meta needed.
|
||||
expect(oneOf[1]).toEqual({ const: "List", title: "List", description: "Stacked rows" });
|
||||
// Neither → just the label.
|
||||
expect(oneOf[2]).toEqual({ const: "Plain", title: "Plain" });
|
||||
});
|
||||
|
||||
it("includes a per-question optional free-text custom-answer field", () => {
|
||||
const questions = [
|
||||
mkQuestion("Which?", [{ label: "A" }, { label: "B" }]),
|
||||
mkQuestion("What else?", [{ label: "C" }, { label: "D" }]),
|
||||
];
|
||||
const schema = (
|
||||
askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined) as Extract<
|
||||
CreateElicitationRequest,
|
||||
@@ -196,7 +260,27 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
>
|
||||
).requestedSchema;
|
||||
|
||||
expect(schema.properties?.["customAnswer"]).toMatchObject({ type: "string", title: "Other" });
|
||||
// Each question gets its own "Other" box, not one shared form-level field.
|
||||
expect(schema.properties?.["question_0_custom"]).toMatchObject({
|
||||
type: "string",
|
||||
title: "Other",
|
||||
_meta: {
|
||||
_askUserQuestionCustomAnswer: {
|
||||
questionId: "question_0",
|
||||
isCustomAnswer: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(schema.properties?.["question_1_custom"]).toMatchObject({
|
||||
type: "string",
|
||||
title: "Other",
|
||||
_meta: {
|
||||
_askUserQuestionCustomAnswer: {
|
||||
questionId: "question_1",
|
||||
isCustomAnswer: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds an array property for multi-select questions and includes per-field question text", () => {
|
||||
@@ -250,10 +334,10 @@ describe("applyAskElicitationResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the free-text custom-answer field to the tool's response", () => {
|
||||
it("folds a per-question custom answer into that question's answer", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", customAnswer: "something else entirely" },
|
||||
content: { question_0: "A", question_1_custom: "something else entirely" },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
@@ -261,8 +345,23 @@ describe("applyAskElicitationResponse", () => {
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "A" },
|
||||
response: "something else entirely",
|
||||
answers: { "Single?": "A", "Multi?": "something else entirely" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a question's custom answer over its selection", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", question_0_custom: " my own take " },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "my own take" },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -270,7 +369,7 @@ describe("applyAskElicitationResponse", () => {
|
||||
it("ignores empty selections and blank custom answers", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_1: [], customAnswer: " " },
|
||||
content: { question_1: [], question_0_custom: " " },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
@@ -294,6 +393,13 @@ describe("applyAskElicitationResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancel for a custom/future action it does not understand", () => {
|
||||
const response = { action: "_zed/snooze" } as CreateElicitationResponse;
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "cancel",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits answers for questions the user left unanswered", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
@@ -307,3 +413,104 @@ describe("applyAskElicitationResponse", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractRefusalFallbackPrompt", () => {
|
||||
it("extracts required and optional fields", () => {
|
||||
expect(
|
||||
extractRefusalFallbackPrompt({
|
||||
originalModel: "claude-fable-5",
|
||||
fallbackModel: "claude-opus-4-8",
|
||||
apiRefusalCategory: "cyber",
|
||||
guidanceText: "You can retry with the fallback model.",
|
||||
retractedMessageUuids: ["u1"],
|
||||
}),
|
||||
).toEqual({
|
||||
originalModel: "claude-fable-5",
|
||||
fallbackModel: "claude-opus-4-8",
|
||||
apiRefusalCategory: "cyber",
|
||||
guidanceText: "You can retry with the fallback model.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when a required model field is missing or mistyped", () => {
|
||||
expect(extractRefusalFallbackPrompt({ fallbackModel: "claude-opus-4-8" })).toBeNull();
|
||||
expect(
|
||||
extractRefusalFallbackPrompt({ originalModel: 5, fallbackModel: "claude-opus-4-8" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes a non-string category to null and drops empty guidance", () => {
|
||||
expect(
|
||||
extractRefusalFallbackPrompt({
|
||||
originalModel: "a",
|
||||
fallbackModel: "b",
|
||||
apiRefusalCategory: null,
|
||||
guidanceText: "",
|
||||
}),
|
||||
).toEqual({ originalModel: "a", fallbackModel: "b", apiRefusalCategory: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusalFallbackToCreateRequest", () => {
|
||||
const prompt = {
|
||||
originalModel: "claude-fable-5",
|
||||
fallbackModel: "claude-opus-4-8",
|
||||
apiRefusalCategory: "cyber" as string | null,
|
||||
};
|
||||
|
||||
it("builds a single-choice form whose enum consts are the dialog's wire results", () => {
|
||||
const request = refusalFallbackToCreateRequest(prompt, SESSION_ID) as Extract<
|
||||
CreateElicitationRequest,
|
||||
{ mode: "form" }
|
||||
>;
|
||||
expect(request.mode).toBe("form");
|
||||
expect(request).toMatchObject({ sessionId: SESSION_ID });
|
||||
expect(request.message).toContain("claude-fable-5");
|
||||
expect(request.message).toContain("(cyber)");
|
||||
expect(request.message).toContain("claude-opus-4-8");
|
||||
|
||||
const choice = request.requestedSchema.properties?.choice as {
|
||||
type: string;
|
||||
oneOf: Array<{ const: string; title: string; description?: string }>;
|
||||
};
|
||||
expect(choice.type).toBe("string");
|
||||
expect(choice.oneOf.map((o) => o.const)).toEqual(["retry_fallback", "cancelled"]);
|
||||
expect(choice.oneOf[0].title).toContain("claude-opus-4-8");
|
||||
// Each option explains its consequence in the description field.
|
||||
expect(choice.oneOf[0].description).toContain("claude-opus-4-8");
|
||||
expect(choice.oneOf[1].description).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the category marker and appends guidance when provided", () => {
|
||||
const request = refusalFallbackToCreateRequest(
|
||||
{ ...prompt, apiRefusalCategory: null, guidanceText: "Consider rephrasing." },
|
||||
SESSION_ID,
|
||||
);
|
||||
expect(request.message).not.toContain("(");
|
||||
expect(request.message).toContain("Consider rephrasing.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusalFallbackResultFromResponse", () => {
|
||||
it("maps an accepted retry choice to retry_fallback", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { choice: "retry_fallback" },
|
||||
} as CreateElicitationResponse;
|
||||
expect(refusalFallbackResultFromResponse(response)).toBe("retry_fallback");
|
||||
});
|
||||
|
||||
it("keeps the refusal for every other outcome", () => {
|
||||
const cases = [
|
||||
{ action: "accept", content: { choice: "cancelled" } },
|
||||
{ action: "accept", content: {} },
|
||||
{ action: "accept", content: { choice: "something-new" } },
|
||||
{ action: "accept" },
|
||||
{ action: "decline" },
|
||||
{ action: "cancel" },
|
||||
] as CreateElicitationResponse[];
|
||||
for (const response of cases) {
|
||||
expect(refusalFallbackResultFromResponse(response)).toBe("cancelled");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
443
src/tests/fast-mode-config.test.ts
Normal file
443
src/tests/fast-mode-config.test.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { FastModeDisabledReason, ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
buildConfigOptions,
|
||||
clientSupportsBooleanConfigOptions,
|
||||
createFastModeConfigOption,
|
||||
fastModeStateEnabled,
|
||||
normalizeFastModeDisabledReason,
|
||||
resolveFastModeEnabled,
|
||||
FAST_MODE_CONFIG_ID,
|
||||
FAST_MODE_ON,
|
||||
FAST_MODE_OFF,
|
||||
ClaudeAcpAgent,
|
||||
type AcpClient,
|
||||
} from "../acp-agent.js";
|
||||
|
||||
const MODES = {
|
||||
currentModeId: "default",
|
||||
availableModes: [{ id: "default", name: "Default", description: "Standard behavior" }],
|
||||
};
|
||||
|
||||
const MODELS = {
|
||||
currentModelId: "claude-opus-4-8",
|
||||
availableModels: [
|
||||
{ modelId: "claude-opus-4-8", name: "Claude Opus", description: "Most capable" },
|
||||
],
|
||||
};
|
||||
|
||||
const MODEL_INFOS: ModelInfo[] = [
|
||||
{ value: "claude-opus-4-8", displayName: "Claude Opus", description: "Most capable" },
|
||||
];
|
||||
|
||||
describe("createFastModeConfigOption", () => {
|
||||
it("produces a native boolean toggle when the client opted in", () => {
|
||||
expect(createFastModeConfigOption(true, true)).toEqual({
|
||||
id: FAST_MODE_CONFIG_ID,
|
||||
name: "Fast mode",
|
||||
description: expect.any(String),
|
||||
category: "model_config",
|
||||
type: "boolean",
|
||||
currentValue: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains a blocking disabled reason while the toggle reads off", () => {
|
||||
const option = createFastModeConfigOption(false, true, "free");
|
||||
expect(option.description).toBe(
|
||||
"Faster responses on supported models — not available on the free plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores routine and on-state reasons", () => {
|
||||
// Every SDK session starts at `sdk_opt_in_required` — the toggle IS the
|
||||
// opt-in, so it must not read as a blocker.
|
||||
expect(createFastModeConfigOption(false, true, "sdk_opt_in_required").description).toBe(
|
||||
"Faster responses on supported models",
|
||||
);
|
||||
expect(createFastModeConfigOption(false, true, "preference").description).toBe(
|
||||
"Faster responses on supported models",
|
||||
);
|
||||
// A reason riding an `on` state isn't blocking anything right now.
|
||||
expect(createFastModeConfigOption(true, true, "free").description).toBe(
|
||||
"Faster responses on supported models",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to an on/off select when the client did not opt in", () => {
|
||||
const option = createFastModeConfigOption(false, false);
|
||||
expect(option).toMatchObject({
|
||||
id: FAST_MODE_CONFIG_ID,
|
||||
type: "select",
|
||||
category: "model_config",
|
||||
currentValue: FAST_MODE_OFF,
|
||||
options: [
|
||||
{ value: FAST_MODE_ON, name: "On" },
|
||||
{ value: FAST_MODE_OFF, name: "Off" },
|
||||
],
|
||||
});
|
||||
expect(option).not.toHaveProperty("currentValue", true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientSupportsBooleanConfigOptions", () => {
|
||||
it("is true only when session.configOptions.boolean is present", () => {
|
||||
expect(
|
||||
clientSupportsBooleanConfigOptions({ session: { configOptions: { boolean: {} } } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when the capability is omitted or null at any level", () => {
|
||||
expect(clientSupportsBooleanConfigOptions(undefined)).toBe(false);
|
||||
expect(clientSupportsBooleanConfigOptions(null)).toBe(false);
|
||||
expect(clientSupportsBooleanConfigOptions({})).toBe(false);
|
||||
expect(clientSupportsBooleanConfigOptions({ session: { configOptions: {} } })).toBe(false);
|
||||
expect(
|
||||
clientSupportsBooleanConfigOptions({ session: { configOptions: { boolean: null } } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFastModeEnabled", () => {
|
||||
const base = { sessionId: "s", configId: FAST_MODE_CONFIG_ID };
|
||||
|
||||
it("accepts native boolean values", () => {
|
||||
expect(resolveFastModeEnabled({ ...base, type: "boolean", value: true })).toBe(true);
|
||||
expect(resolveFastModeEnabled({ ...base, type: "boolean", value: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the on/off select fallback", () => {
|
||||
expect(resolveFastModeEnabled({ ...base, value: FAST_MODE_ON })).toBe(true);
|
||||
expect(resolveFastModeEnabled({ ...base, value: FAST_MODE_OFF })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects any other value", () => {
|
||||
expect(() => resolveFastModeEnabled({ ...base, value: "maybe" })).toThrow(
|
||||
/Invalid value for config option fast/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fastModeStateEnabled", () => {
|
||||
it("treats cooldown as on (the user's intent persists through rate-limit cooldown)", () => {
|
||||
expect(fastModeStateEnabled("on")).toBe(true);
|
||||
expect(fastModeStateEnabled("cooldown")).toBe(true);
|
||||
expect(fastModeStateEnabled("off")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFastModeDisabledReason", () => {
|
||||
it("retains only reasons we have an explanation for", () => {
|
||||
expect(normalizeFastModeDisabledReason("free")).toBe("free");
|
||||
expect(normalizeFastModeDisabledReason("extra_usage_disabled")).toBe("extra_usage_disabled");
|
||||
expect(normalizeFastModeDisabledReason("model_not_allowed")).toBe("model_not_allowed");
|
||||
expect(normalizeFastModeDisabledReason("not_first_party")).toBe("not_first_party");
|
||||
expect(normalizeFastModeDisabledReason("disabled_by_env")).toBe("disabled_by_env");
|
||||
expect(normalizeFastModeDisabledReason("network_error")).toBe("network_error");
|
||||
});
|
||||
|
||||
it("drops routine states and unknown values", () => {
|
||||
expect(normalizeFastModeDisabledReason("sdk_opt_in_required")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason("preference")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason("pending")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason("unknown")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason(undefined)).toBeUndefined();
|
||||
expect(
|
||||
normalizeFastModeDisabledReason("some_future_reason" as FastModeDisabledReason),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConfigOptions Fast mode", () => {
|
||||
it("omits the Fast mode option when the model does not support it", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
useBooleanOption: true,
|
||||
});
|
||||
expect(options.find((o) => o.id === FAST_MODE_CONFIG_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits the Fast mode option when no fast mode state is provided", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default");
|
||||
expect(options.find((o) => o.id === FAST_MODE_CONFIG_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces a boolean toggle when supported and the client opted in", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
supported: true,
|
||||
enabled: true,
|
||||
useBooleanOption: true,
|
||||
});
|
||||
expect(options).toContainEqual(createFastModeConfigOption(true, true));
|
||||
});
|
||||
|
||||
it("surfaces a select fallback when supported but the client did not opt in", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
supported: true,
|
||||
enabled: false,
|
||||
useBooleanOption: false,
|
||||
});
|
||||
expect(options).toContainEqual(createFastModeConfigOption(false, false));
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionConfigOption Fast mode toggle", () => {
|
||||
const SESSION_ID = "fast-session";
|
||||
|
||||
function setup(opts: { useBooleanOption: boolean }) {
|
||||
const sessionUpdates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (n: SessionNotification) => {
|
||||
sessionUpdates.push(n);
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
const clientCapabilities: ClientCapabilities = opts.useBooleanOption
|
||||
? { session: { configOptions: { boolean: {} } } }
|
||||
: {};
|
||||
(agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities =
|
||||
clientCapabilities;
|
||||
|
||||
const applyFlagSettings = vi.fn();
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
|
||||
query: { applyFlagSettings },
|
||||
fastModeEnabled: false,
|
||||
configOptions: [createFastModeConfigOption(false, opts.useBooleanOption)],
|
||||
};
|
||||
|
||||
return { agent, applyFlagSettings, sessionUpdates };
|
||||
}
|
||||
|
||||
it("toggles Fast mode on/off through a boolean value", async () => {
|
||||
const { agent, applyFlagSettings } = setup({ useBooleanOption: true });
|
||||
|
||||
const onResponse = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: FAST_MODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: true });
|
||||
expect(onResponse.configOptions).toContainEqual(createFastModeConfigOption(true, true));
|
||||
expect(
|
||||
(agent as unknown as { sessions: Record<string, { fastModeEnabled: boolean }> }).sessions[
|
||||
SESSION_ID
|
||||
].fastModeEnabled,
|
||||
).toBe(true);
|
||||
|
||||
const offResponse = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: FAST_MODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: false,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
|
||||
expect(offResponse.configOptions).toContainEqual(createFastModeConfigOption(false, true));
|
||||
});
|
||||
|
||||
it("toggles Fast mode through the on/off select fallback", async () => {
|
||||
const { agent, applyFlagSettings } = setup({ useBooleanOption: false });
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: FAST_MODE_CONFIG_ID,
|
||||
value: FAST_MODE_ON,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: true });
|
||||
expect(response.configOptions).toContainEqual(createFastModeConfigOption(true, false));
|
||||
});
|
||||
|
||||
it("does not change session state when the SDK rejects the flag", async () => {
|
||||
const { agent, applyFlagSettings } = setup({ useBooleanOption: true });
|
||||
applyFlagSettings.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: FAST_MODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
}),
|
||||
).rejects.toThrow("nope");
|
||||
|
||||
const session = (agent as unknown as { sessions: Record<string, { fastModeEnabled: boolean }> })
|
||||
.sessions[SESSION_ID];
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
const SESSION_ID = "fast-session";
|
||||
|
||||
function setup(opts: { fastModeEnabled: boolean; withOption: boolean }) {
|
||||
const sessionUpdates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (n: SessionNotification) => {
|
||||
sessionUpdates.push(n);
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
(agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities = {
|
||||
session: { configOptions: { boolean: {} } },
|
||||
};
|
||||
|
||||
const session = {
|
||||
query: {},
|
||||
fastModeEnabled: opts.fastModeEnabled,
|
||||
fastModeDisabledReason: undefined as FastModeDisabledReason | undefined,
|
||||
configOptions: opts.withOption
|
||||
? [createFastModeConfigOption(opts.fastModeEnabled, true)]
|
||||
: [],
|
||||
};
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = session;
|
||||
|
||||
const sync = (
|
||||
agent as unknown as {
|
||||
syncFastModeState: (
|
||||
sessionId: string,
|
||||
session: unknown,
|
||||
state: string | undefined,
|
||||
reason?: FastModeDisabledReason,
|
||||
) => Promise<void>;
|
||||
}
|
||||
).syncFastModeState.bind(agent);
|
||||
|
||||
return { sync, session, sessionUpdates };
|
||||
}
|
||||
|
||||
it("emits a config_option_update when the SDK reports a new state", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "on");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(true);
|
||||
expect(session.configOptions).toContainEqual(createFastModeConfigOption(true, true));
|
||||
expect(sessionUpdates).toHaveLength(1);
|
||||
expect(sessionUpdates[0].update).toMatchObject({
|
||||
sessionUpdate: "config_option_update",
|
||||
});
|
||||
const updated = (
|
||||
sessionUpdates[0].update as { configOptions: ReturnType<typeof createFastModeConfigOption>[] }
|
||||
).configOptions;
|
||||
expect(updated).toContainEqual(createFastModeConfigOption(true, true));
|
||||
});
|
||||
|
||||
it("leaves the toggle on and quiet during a rate-limit cooldown", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
|
||||
|
||||
// cooldown is a transient suspension of an already-enabled fast mode.
|
||||
await sync(SESSION_ID, session, "cooldown");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(true);
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never lets a stray cooldown spuriously enable a toggle the user has off", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "cooldown");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clears the toggle when the SDK reports off", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "off");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
expect(session.configOptions).toContainEqual(createFastModeConfigOption(false, true));
|
||||
expect(sessionUpdates).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is a no-op when the reported state is undefined or unchanged", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, undefined);
|
||||
await sync(SESSION_ID, session, "off");
|
||||
// A routine reason normalizes away, so it's still "unchanged" — an
|
||||
// `sdk_opt_in_required` on every turn's result must not churn the option.
|
||||
await sync(SESSION_ID, session, "off", "sdk_opt_in_required");
|
||||
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("explains a blocking reason once when the SDK refuses a toggle the user turned on", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
expect(session.fastModeDisabledReason).toBe("extra_usage_disabled");
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
expect(sessionUpdates[0].update).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "**Fast mode turned off:** requires extra usage to be enabled for this account.",
|
||||
},
|
||||
});
|
||||
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
|
||||
expect(session.configOptions).toContainEqual(
|
||||
createFastModeConfigOption(false, true, "extra_usage_disabled"),
|
||||
);
|
||||
|
||||
// The same report again changes nothing the user can see: no repeat notice.
|
||||
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("updates the description when only the reason changes", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "off", "not_first_party");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
// The toggle was already off, so the flip-time notice doesn't fire — only
|
||||
// the description carries the explanation.
|
||||
expect(sessionUpdates).toHaveLength(1);
|
||||
expect(sessionUpdates[0].update).toMatchObject({ sessionUpdate: "config_option_update" });
|
||||
expect(session.configOptions).toContainEqual(
|
||||
createFastModeConfigOption(false, true, "not_first_party"),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a retained reason once fast mode comes back on", async () => {
|
||||
const { sync, session } = setup({ fastModeEnabled: false, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "off", "network_error");
|
||||
expect(session.fastModeDisabledReason).toBe("network_error");
|
||||
|
||||
await sync(SESSION_ID, session, "on");
|
||||
expect(session.fastModeEnabled).toBe(true);
|
||||
expect(session.fastModeDisabledReason).toBeUndefined();
|
||||
expect(session.configOptions).toContainEqual(createFastModeConfigOption(true, true));
|
||||
});
|
||||
|
||||
it("preserves the retained setting (no clobber) when the model has no Fast mode option", async () => {
|
||||
// Model without fast support: the SDK reports a capability-driven state, not
|
||||
// the user's intent. We must leave session.fastModeEnabled untouched so it's
|
||||
// correct when a supporting model is reselected — reconciling here was the
|
||||
// original intent-clobber bug.
|
||||
const enabledCase = setup({ fastModeEnabled: true, withOption: false });
|
||||
await enabledCase.sync(SESSION_ID, enabledCase.session, "off");
|
||||
expect(enabledCase.session.fastModeEnabled).toBe(true);
|
||||
expect(enabledCase.sessionUpdates).toHaveLength(0);
|
||||
|
||||
const disabledCase = setup({ fastModeEnabled: false, withOption: false });
|
||||
await disabledCase.sync(SESSION_ID, disabledCase.session, "on");
|
||||
expect(disabledCase.session.fastModeEnabled).toBe(false);
|
||||
expect(disabledCase.sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
34
src/tests/helpers.ts
Normal file
34
src/tests/helpers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shared test doubles. Deliberately vitest-free so `vi.mock` async factories
|
||||
* can `await import("./helpers.js")` without ordering hazards; tests supply
|
||||
* their own vi.fn spies via `overrides`.
|
||||
*/
|
||||
|
||||
/** The context-usage report the base mock query returns. `rawMaxTokens`
|
||||
* matches the agent's DEFAULT_CONTEXT_WINDOW so window-related assertions
|
||||
* don't shift in tests that don't care about context usage. */
|
||||
export const DEFAULT_CONTEXT_USAGE = { totalTokens: 0, rawMaxTokens: 200000 };
|
||||
|
||||
/**
|
||||
* Base stub for the SDK `query()` return object, covering the surface
|
||||
* ClaudeAcpAgent touches unconditionally at session creation. Tests pass
|
||||
* `overrides` for the parts they assert on (spies, custom models, rejecting
|
||||
* getContextUsage, …).
|
||||
*
|
||||
* When the agent starts calling a new SDK method on every session, add it
|
||||
* here once — the getContextUsage adoption required hand-editing ~10 inline
|
||||
* mocks across five files, and any missed copy didn't fail: it silently
|
||||
* rerouted that test through the error-fallback branch and re-polluted test
|
||||
* output.
|
||||
*/
|
||||
export function makeMockQuery(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
initializationResult: async () => ({ models: [] }),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
272
src/tests/model-resolution.test.ts
Normal file
272
src/tests/model-resolution.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
matchResumedModel,
|
||||
} from "../acp-agent.js";
|
||||
|
||||
// Mirrors a real `supportedModels()` response: alias rows carry
|
||||
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
|
||||
// "claude-opus-4-6"-style ids.
|
||||
const LIVE_SHAPED_MODELS: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-opus-4-8[1m]",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Use the default model (currently Opus 4.8 (1M context))",
|
||||
},
|
||||
{
|
||||
value: "opus[1m]",
|
||||
resolvedModel: "claude-opus-4-8[1m]",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.8 with 1M context · Best for everyday, complex tasks",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 · Efficient for routine tasks",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-4-5-20251001",
|
||||
displayName: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
];
|
||||
|
||||
describe("resolveModelPreference - resolvedModel matching", () => {
|
||||
it("matches a preference that equals an alias's resolvedModel exactly", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-sonnet-5")?.value).toBe("sonnet");
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8[1m]")?.value).toBe(
|
||||
"opus[1m]",
|
||||
);
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-haiku-4-5-20251001")?.value).toBe(
|
||||
"haiku",
|
||||
);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "CLAUDE-SONNET-5")?.value).toBe("sonnet");
|
||||
});
|
||||
|
||||
it("still resolves plain aliases via the direct-match tier", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "sonnet")?.value).toBe("sonnet");
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "opus[1m]")?.value).toBe("opus[1m]");
|
||||
});
|
||||
|
||||
it("does not let a retired dated id drift onto a same-family alias of a different generation", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-sonnet-4-6")).toBeNull();
|
||||
});
|
||||
|
||||
// "opus[1m]"'s `value` has no dotted version, just the context-hint digit
|
||||
// in "[1m]" — unstripped, that reads as version "1" and blocks the match
|
||||
// before the matcher reaches the description's real "4.8".
|
||||
it("does not mistake an alias's context-hint suffix for its version", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8")?.value).toBe("opus[1m]");
|
||||
});
|
||||
|
||||
it("returns null for a preference that matches nothing", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches a '-1m' id-suffix preference against a '[1m]'-spelled resolvedModel", () => {
|
||||
// Without hint canonicalization the exact tier misses and the substring
|
||||
// tier lands on the bare 200k sibling, silently downgrading a 1M pin.
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 · Efficient for routine tasks",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
resolvedModel: "claude-sonnet-5[1m]",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 with 1M context",
|
||||
},
|
||||
];
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5-1m")?.value).toBe("sonnet[1m]");
|
||||
});
|
||||
|
||||
it("matches a '-1m'-suffix alias directly against a '[1m]'-spelled value", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "opus-1m")?.value).toBe("opus[1m]");
|
||||
});
|
||||
|
||||
// Rows without resolvedModel (older SDKs, allowlist-synthesized entries,
|
||||
// option-derived lists) can't use the exact resolved tier, so the hinted
|
||||
// preference must not be absorbed by the bare sibling in the substring
|
||||
// tier — the tokenized tier scores the hinted row correctly instead.
|
||||
it("does not let a bare sibling absorb a hinted preference when resolvedModel is absent", () => {
|
||||
const models: ModelInfo[] = [
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "" },
|
||||
{ value: "sonnet[1m]", displayName: "Sonnet", description: "" },
|
||||
];
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5[1m]")?.value).toBe("sonnet[1m]");
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5-1m")?.value).toBe("sonnet[1m]");
|
||||
// Bare preferences still land on the bare row via the substring tier.
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5")?.value).toBe("sonnet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchResumedModel", () => {
|
||||
// Environment shape from issue #845: the default resolves to Sonnet, the
|
||||
// session was explicitly switched to Opus before the process restart.
|
||||
const SONNET_DEFAULT_MODELS: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Use the default model (currently Sonnet 5)",
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
resolvedModel: "claude-opus-4-6[1m]",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 · Efficient for routine tasks",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
resolvedModel: "claude-sonnet-5[1m]",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 with 1M context",
|
||||
},
|
||||
];
|
||||
|
||||
it("maps an explicitly-switched session's live model to its picker alias", () => {
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-opus-4-6").value).toBe("opus");
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-opus-4-6[1m]").value).toBe("opus");
|
||||
});
|
||||
|
||||
it("keeps Default selected when the live model is what Default resolves to", () => {
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-sonnet-5").value).toBe("default");
|
||||
});
|
||||
|
||||
it("prefers an exact named-row resolvedModel match over the hint-stripped Default tier", () => {
|
||||
// "claude-sonnet-5[1m]" differs from Default's "claude-sonnet-5" only by
|
||||
// the context hint; stripping both would misreport the explicit 1M row as
|
||||
// Default (and infer the wrong context window downstream). Both hint
|
||||
// spellings must land on the row regardless of which one it uses.
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-sonnet-5[1m]").value).toBe(
|
||||
"sonnet[1m]",
|
||||
);
|
||||
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-sonnet-5-1m").value).toBe("sonnet[1m]");
|
||||
});
|
||||
|
||||
it("normalizes the '-1m' id-suffix hint form and casing against Default's resolution", () => {
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-opus-4-8-1m").value).toBe("default");
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "Claude-Opus-4-8[1M]").value).toBe("default");
|
||||
});
|
||||
|
||||
it("prefers Default over a named row sharing the identical resolvedModel", () => {
|
||||
// "opus[1m]" carries the same resolvedModel as Default; the live id can't
|
||||
// tell the two apart, so a never-customized session stays on Default.
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-opus-4-8[1m]").value).toBe("default");
|
||||
});
|
||||
|
||||
it("ignores '[1m]' context hints when comparing against Default's resolution", () => {
|
||||
// The transcript-restored model drops the "[1m]" context hint, so a
|
||||
// session that never left the 1M-context default resumes as the bare id.
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-opus-4-8").value).toBe("default");
|
||||
});
|
||||
|
||||
it("still resolves non-default live models against the picker entries", () => {
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-sonnet-5").value).toBe("sonnet");
|
||||
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-haiku-4-5-20251001").value).toBe("haiku");
|
||||
});
|
||||
|
||||
it("tracks a live model with no picker counterpart verbatim", () => {
|
||||
const result = matchResumedModel(LIVE_SHAPED_MODELS, "claude-gpt-99");
|
||||
expect(result.value).toBe("claude-gpt-99");
|
||||
expect(result.displayName).toBe("claude-gpt-99");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - resolvedModel matching", () => {
|
||||
it("resolves an allowlist entry pinned to a canonical resolvedModel id", () => {
|
||||
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);
|
||||
const entry = result.find((m) => m.value === "claude-sonnet-5");
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.displayName).toBe("Sonnet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - modelOverrides", () => {
|
||||
const SDK_MODELS: ModelInfo[] = [
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "Claude Opus 4.6",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "high"],
|
||||
},
|
||||
];
|
||||
|
||||
it("surfaces the override target as the value while keeping the alias's display metadata", () => {
|
||||
// The override target is an opaque Bedrock ARN with no "opus" substring,
|
||||
// so matching against it (instead of the "claude-opus-4-6" alias) would
|
||||
// find nothing and silently drop displayName/description/supportsEffort.
|
||||
const overrides = {
|
||||
"claude-opus-4-6": "arn:aws:bedrock:us-west-2:111122223333:inference-profile/custom-7f3a",
|
||||
};
|
||||
const result = applyAvailableModelsAllowlist(SDK_MODELS, ["claude-opus-4-6"], overrides);
|
||||
|
||||
const entry = result.find((m) => m.value === overrides["claude-opus-4-6"]);
|
||||
expect(entry).toEqual({
|
||||
value: overrides["claude-opus-4-6"],
|
||||
displayName: "Opus",
|
||||
description: "Claude Opus 4.6",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "high"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Runs against the real SDK (no mocks) so the fixture above is flagged the
|
||||
// moment the SDK's actual model list shape drifts from it. Requires a usable
|
||||
// ANTHROPIC_API_KEY, hence opt-in via RUN_INTEGRATION_TESTS.
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("model resolution (real SDK)", () => {
|
||||
it("every model's resolvedModel (when present) resolves back to that same row", async () => {
|
||||
const q = query({ prompt: "hi", options: { sessionId: randomUUID() } });
|
||||
try {
|
||||
const models = await q.supportedModels();
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
for (const model of models) {
|
||||
// "default" shares a resolvedModel with the CLI's recommended named
|
||||
// alias, and resolution intentionally prefers that alias on a tie.
|
||||
if (!model.resolvedModel || model.value === "default") continue;
|
||||
const resolved = resolveModelPreference(models, model.resolvedModel);
|
||||
expect(resolved?.value).toBe(model.value);
|
||||
}
|
||||
} finally {
|
||||
q.return(undefined);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("resolves the well-known 'sonnet' and 'opus' aliases to a model bearing their family name", async () => {
|
||||
const q = query({ prompt: "hi", options: { sessionId: randomUUID() } });
|
||||
try {
|
||||
const models = await q.supportedModels();
|
||||
|
||||
const sonnet = resolveModelPreference(models, "sonnet");
|
||||
expect(sonnet?.resolvedModel?.toLowerCase()).toContain("sonnet");
|
||||
|
||||
const opus = resolveModelPreference(models, "opus");
|
||||
expect(opus?.resolvedModel?.toLowerCase()).toContain("opus");
|
||||
} finally {
|
||||
q.return(undefined);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
303
src/tests/providers.test.ts
Normal file
303
src/tests/providers.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
initializationResult: vi.fn().mockResolvedValue({
|
||||
models: [
|
||||
{ value: "id", displayName: "name", description: "description", supportsAutoMode: true },
|
||||
],
|
||||
}),
|
||||
setModel: vi.fn(),
|
||||
setPermissionMode: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
// `logout` shells out to `claude auth logout`; make execFile succeed so the
|
||||
// real logout body runs (and clears provider config) without a live CLI.
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return {
|
||||
...actual,
|
||||
execFile: vi.fn((_file: string, _args: string[], cb: (...a: unknown[]) => void) =>
|
||||
cb(null, { stdout: "", stderr: "" }),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe("providers", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
|
||||
const connectionMock = {
|
||||
sessionUpdate: async (_: any) => {},
|
||||
} as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(connectionMock);
|
||||
return [agent, mockQuery];
|
||||
}
|
||||
|
||||
it("advertises the providers capability in initialize", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
expect(response.agentCapabilities?.providers).toEqual({});
|
||||
});
|
||||
|
||||
it("lists a single optional 'main' provider, unconfigured by default", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
const response = await agent.unstable_listProviders({});
|
||||
expect(response.providers).toEqual([
|
||||
{
|
||||
providerId: "main",
|
||||
supported: ["anthropic", "bedrock", "vertex"],
|
||||
required: false,
|
||||
current: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reflects apiType/baseUrl after set, and never echoes headers", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example/v1",
|
||||
headers: { authorization: "Bearer secret" },
|
||||
});
|
||||
|
||||
const response = await agent.unstable_listProviders({});
|
||||
expect(response.providers[0].current).toEqual({
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example/v1",
|
||||
});
|
||||
// headers must not leak through list
|
||||
expect(JSON.stringify(response.providers)).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("rejects set for an unknown providerId", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await expect(
|
||||
agent.unstable_setProvider({
|
||||
providerId: "openai",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
});
|
||||
|
||||
it("rejects set for an unsupported apiType", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await expect(
|
||||
agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "openai",
|
||||
baseUrl: "https://gateway.example",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
});
|
||||
|
||||
it("rejects set for an empty or non-http baseUrl", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await expect(
|
||||
agent.unstable_setProvider({ providerId: "main", apiType: "anthropic", baseUrl: "" }),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
await expect(
|
||||
agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "ftp://nope.example",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
});
|
||||
|
||||
it("disables the 'main' provider by clearing config and reporting current: null", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example/v1",
|
||||
});
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).not.toBeNull();
|
||||
|
||||
await expect(agent.unstable_disableProvider({ providerId: "main" })).resolves.toEqual({});
|
||||
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull();
|
||||
});
|
||||
|
||||
it("treats disabling an unknown provider as an idempotent no-op", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await expect(agent.unstable_disableProvider({ providerId: "openai" })).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it("routes anthropic provider config into session env", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
headers: { "x-api-key": "test" },
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: " ",
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes bedrock provider config into session env", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "bedrock",
|
||||
baseUrl: "https://gateway.example",
|
||||
headers: { "custom-header": "test" },
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
AWS_BEARER_TOKEN_BEDROCK: " ",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts vertex config via _meta and routes it into session env", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "vertex",
|
||||
baseUrl: "https://vertex.example",
|
||||
headers: { "custom-header": "test" },
|
||||
_meta: { claudeCode: { vertex: { projectId: "my-project", region: "us-east5" } } },
|
||||
});
|
||||
|
||||
// list surfaces apiType/baseUrl but not the _meta extras
|
||||
const listed = await agent.unstable_listProviders({});
|
||||
expect(listed.providers[0].current).toEqual({
|
||||
apiType: "vertex",
|
||||
baseUrl: "https://vertex.example",
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CLAUDE_CODE_USE_VERTEX: "1",
|
||||
ANTHROPIC_VERTEX_BASE_URL: "https://vertex.example",
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: "my-project",
|
||||
CLOUD_ML_REGION: "us-east5",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects vertex set without _meta project/region", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await expect(
|
||||
agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "vertex",
|
||||
baseUrl: "https://vertex.example",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
|
||||
// partial _meta (missing region) is also rejected
|
||||
await expect(
|
||||
agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "vertex",
|
||||
baseUrl: "https://vertex.example",
|
||||
_meta: { claudeCode: { vertex: { projectId: "my-project" } } } as any,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
});
|
||||
|
||||
it("providers/set takes precedence over gateway auth", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true, _meta: { gateway: true } } } as any,
|
||||
});
|
||||
|
||||
// Gateway auth first...
|
||||
await agent.authenticate({
|
||||
methodId: "gateway",
|
||||
_meta: { gateway: { baseUrl: "https://gateway.example", headers: {} } },
|
||||
});
|
||||
// ...then providers/set wins.
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://provider.example",
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_BASE_URL: "https://provider.example",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears provider config on logout", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
// Avoids resolving the native CLI binary in claudeCliPath().
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/bin/true");
|
||||
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://provider.example",
|
||||
});
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).not.toBeNull();
|
||||
|
||||
await agent.logout({});
|
||||
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,13 @@ describe("resolvePermissionMode", () => {
|
||||
expect(resolvePermissionMode("bypass")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("resolves 'manual' as an alias for 'default'", () => {
|
||||
const { logger, error } = mockLogger();
|
||||
expect(resolvePermissionMode("manual", logger)).toBe("default");
|
||||
expect(resolvePermissionMode("Manual", logger)).toBe("default");
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const { registerHookCallbackSpy } = vi.hoisted(() => ({
|
||||
registerHookCallbackSpy: vi.fn(),
|
||||
@@ -84,7 +85,7 @@ describe("session config options", () => {
|
||||
let setModelSpy: ReturnType<typeof vi.fn>;
|
||||
let applyFlagSettingsSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
function createMockClient(): AcpClient {
|
||||
return {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
sessionUpdates.push(notification);
|
||||
@@ -92,7 +93,7 @@ describe("session config options", () => {
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
function populateSession() {
|
||||
@@ -101,29 +102,28 @@ describe("session config options", () => {
|
||||
applyFlagSettingsSpy = vi.fn();
|
||||
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
|
||||
query: {
|
||||
query: makeMockQuery({
|
||||
setPermissionMode: setPermissionModeSpy,
|
||||
setModel: setModelSpy,
|
||||
applyFlagSettings: applyFlagSettingsSpy,
|
||||
supportedCommands: async () => [],
|
||||
},
|
||||
}),
|
||||
input: null,
|
||||
cancelled: false,
|
||||
permissionMode: "default",
|
||||
settingsManager: {},
|
||||
modes: structuredClone(MOCK_MODES),
|
||||
models: structuredClone(MOCK_MODELS),
|
||||
modelInfos: MOCK_MODELS.availableModels.map(
|
||||
(m): ModelInfo => ({
|
||||
value: m.modelId,
|
||||
displayName: m.name,
|
||||
description: m.description,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
}),
|
||||
),
|
||||
modelInfos: MOCK_MODELS.availableModels.map((m): ModelInfo => ({
|
||||
value: m.modelId,
|
||||
displayName: m.name,
|
||||
description: m.description,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
})),
|
||||
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
|
||||
contextWindowSize: 200000,
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,6 +216,31 @@ describe("session config options", () => {
|
||||
).rejects.toThrow("Invalid value for config option mode: invalid-mode");
|
||||
});
|
||||
|
||||
it("rejects mode and config changes once the query stream has closed (husk session)", async () => {
|
||||
// After an unexpected stream death the session lingers as a husk
|
||||
// (queryClosed=true) so prompt() can answer with a clear error. The
|
||||
// config/mode handlers must do the same rather than calling setModel/
|
||||
// setPermissionMode on the closed query.
|
||||
const session = (agent as unknown as { sessions: Record<string, { queryClosed?: boolean }> })
|
||||
.sessions[SESSION_ID];
|
||||
session.queryClosed = true;
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
}),
|
||||
).rejects.toThrow(/start a new session/);
|
||||
await expect(agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" })).rejects.toThrow(
|
||||
/start a new session/,
|
||||
);
|
||||
|
||||
// Short-circuited before touching the (closed) query.
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
expect(setPermissionModeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("changes mode, sends current_mode_update but not config_option_update", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
@@ -794,6 +819,85 @@ describe("session config options", () => {
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// Option entries carry no resolvedModel, so alias resolution must consult
|
||||
// session.modelInfos — otherwise a full model id in either hint spelling
|
||||
// ("[1m]"/"-1m") falls to the substring tier and lands on the bare 200k
|
||||
// sibling, silently downgrading the session's context lane.
|
||||
it("resolves a full model id onto its hinted row via session.modelInfos", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.models = {
|
||||
currentModelId: "sonnet",
|
||||
availableModels: [
|
||||
{ modelId: "sonnet", name: "Sonnet", description: "" },
|
||||
{ modelId: "sonnet[1m]", name: "Sonnet", description: "" },
|
||||
],
|
||||
};
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
resolvedModel: "claude-sonnet-5[1m]",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
session.configOptions = session.configOptions.map((o: { id: string }) =>
|
||||
o.id === "model"
|
||||
? {
|
||||
...o,
|
||||
currentValue: "sonnet",
|
||||
options: [
|
||||
{ value: "sonnet", name: "Sonnet" },
|
||||
{ value: "sonnet[1m]", name: "Sonnet" },
|
||||
],
|
||||
}
|
||||
: o,
|
||||
);
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-5[1m]",
|
||||
});
|
||||
expect(setModelSpy).toHaveBeenCalledWith("sonnet[1m]");
|
||||
|
||||
setModelSpy.mockClear();
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-5-1m",
|
||||
});
|
||||
expect(setModelSpy).toHaveBeenCalledWith("sonnet[1m]");
|
||||
});
|
||||
|
||||
// A session can be running a model with no picker entry (resumed onto a
|
||||
// model excluded by the availableModels allowlist, or a refusal
|
||||
// fallback); its verbatim id is then the option's currentValue. A client
|
||||
// round-tripping that reported value must not get "Invalid value".
|
||||
it("accepts the reported currentValue even when it has no options entry", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.models = { ...session.models, currentModelId: "claude-offlist-9" };
|
||||
session.configOptions = session.configOptions.map((o: { id: string }) =>
|
||||
o.id === "model" ? { ...o, currentValue: "claude-offlist-9" } : o,
|
||||
);
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-offlist-9",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-offlist-9");
|
||||
expect(response.configOptions?.find((o) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-offlist-9",
|
||||
);
|
||||
});
|
||||
|
||||
it("setSessionMode also syncs configOptions", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
|
||||
|
||||
@@ -823,6 +927,84 @@ describe("session config options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("context window on model change", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
function getSession() {
|
||||
return (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
}
|
||||
|
||||
it("sets the window from text inference on model switch, without any getContextUsage IPC", async () => {
|
||||
// getContextUsage stalls until the session's first prompt turn, so the
|
||||
// switch path must never call it; the window is seeded from the text
|
||||
// heuristic (here via the new model's resolvedModel) and later confirmed
|
||||
// by result.modelUsage.
|
||||
const session = getSession();
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
session.modelInfos = session.modelInfos.map((m: ModelInfo) =>
|
||||
m.value === "claude-sonnet-4-6" ? { ...m, resolvedModel: "claude-sonnet-5[1m]" } : m,
|
||||
);
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
expect(session.contextWindowSize).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("falls back to the default window when inference misses, without any getContextUsage IPC", async () => {
|
||||
const session = getSession();
|
||||
session.contextWindowSize = 1_000_000;
|
||||
// Present but must NOT be called; the switch never consults it.
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
// claude-sonnet-4-6 carries no "1m" token in its id, resolvedModel,
|
||||
// displayName, or description, so inference misses → default window.
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
expect(session.contextWindowSize).toBe(200000);
|
||||
});
|
||||
|
||||
it("does not call getContextUsage even when switching to a fresh model", async () => {
|
||||
const session = getSession();
|
||||
const spy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
session.query.getContextUsage = spy;
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the learned window when re-asserting the current model", async () => {
|
||||
const session = getSession();
|
||||
session.contextWindowSize = 1_000_000;
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 200000 }));
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
expect(session.contextWindowSize).toBe(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto mode availability per model", () => {
|
||||
/**
|
||||
* Augment the session populated by `populateSession()` with a Haiku entry
|
||||
@@ -1088,6 +1270,10 @@ describe("session config options", () => {
|
||||
],
|
||||
};
|
||||
|
||||
// The tool_call was already surfaced (by the streamed tool_use chunk), so
|
||||
// the permission request won't re-emit one — keep this focused on options.
|
||||
session.emittedToolCalls.add("toolu_1");
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const signal = new AbortController().signal;
|
||||
try {
|
||||
@@ -1119,6 +1305,10 @@ describe("session config options", () => {
|
||||
],
|
||||
};
|
||||
permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } };
|
||||
// The tool_call was already surfaced (by the streamed tool_use chunk), so
|
||||
// the permission request won't re-emit one — the deny path below should
|
||||
// produce no session updates at all.
|
||||
session.emittedToolCalls.add("toolu_2");
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const result = await canUseTool(
|
||||
@@ -1147,6 +1337,10 @@ describe("session config options", () => {
|
||||
],
|
||||
};
|
||||
|
||||
// The tool_call was already surfaced (by the streamed tool_use chunk), so
|
||||
// the permission request won't re-emit one — keep this focused on options.
|
||||
session.emittedToolCalls.add("toolu_3");
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const signal = new AbortController().signal;
|
||||
try {
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
function createMockClient(
|
||||
sessionUpdate: (notification: SessionNotification) => Promise<void> = async () => {},
|
||||
): AcpClient {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
sessionUpdate,
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecycle", () => {
|
||||
@@ -165,7 +167,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
const commandName =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>";
|
||||
@@ -274,7 +276,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
} as unknown as AcpClient;
|
||||
|
||||
// Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579.
|
||||
const concatenated =
|
||||
@@ -330,4 +332,52 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
}
|
||||
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
// Regression test for https://github.com/agentclientprotocol/claude-agent-acp/issues/845
|
||||
// On resume the CLI restores the model the session's transcript was running,
|
||||
// so loadSession must report that live model instead of recomputing the
|
||||
// env/settings/default choice from scratch. Haiku is used because no account
|
||||
// resolves its default there, so the restored model provably differs from
|
||||
// the freshly-computed default. Requires no ANTHROPIC_MODEL env var and no
|
||||
// "model" key in settings.json (either would pin the model on resume and
|
||||
// mask the reconciliation this test covers).
|
||||
it("ACP: loadSession reports the model the resumed session is actually running", async () => {
|
||||
const agentChunks: string[] = [];
|
||||
const client = createMockClient(async (notification) => {
|
||||
if (notification.update.sessionUpdate === "agent_message_chunk") {
|
||||
const content = notification.update.content;
|
||||
if (content.type === "text") agentChunks.push(content.text);
|
||||
}
|
||||
});
|
||||
|
||||
const modelOf = (opts: { id: string; currentValue?: unknown }[] | null | undefined) =>
|
||||
opts?.find((o) => o.id === "model")?.currentValue;
|
||||
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
const { sessionId, configOptions } = await agent.newSession({ cwd, mcpServers: [] });
|
||||
expect(modelOf(configOptions)).not.toBe("haiku");
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "haiku" });
|
||||
// A real turn on Haiku, so the transcript's last assistant message —
|
||||
// which is what the CLI restores the model from — records it.
|
||||
await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "Reply with only the word OK" }],
|
||||
});
|
||||
await agent.closeSession({ sessionId });
|
||||
|
||||
const loadResp = await agent.loadSession({ sessionId, cwd, mcpServers: [] });
|
||||
expect(modelOf(loadResp.configOptions)).toBe("haiku");
|
||||
|
||||
// And the reported option agrees with the live session (`/context`).
|
||||
agentChunks.length = 0;
|
||||
await agent.prompt({ sessionId, prompt: [{ type: "text", text: "/context" }] });
|
||||
const liveModel = agentChunks.join("\n").match(/\*\*Model:\*\*\s*(\S+)/)?.[1];
|
||||
expect(liveModel).toContain("haiku");
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
@@ -7,13 +7,26 @@ import * as os from "node:os";
|
||||
describe("SettingsManager", () => {
|
||||
let tempDir: string;
|
||||
let settingsManager: SettingsManager;
|
||||
let originalConfigDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "settings-test-"));
|
||||
// Isolate the user tier. resolveSettings reads the user settings.json from
|
||||
// CLAUDE_CONFIG_DIR at call time; without this it falls back to the real
|
||||
// ~/.claude and the developer's own settings (e.g. availableModels) leak
|
||||
// into the merge, making union-based assertions fail on their machine.
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, "user-config");
|
||||
await fs.promises.mkdir(process.env.CLAUDE_CONFIG_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
settingsManager?.dispose();
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
|
||||
}
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
542
src/tools.ts
542
src/tools.ts
@@ -8,19 +8,26 @@ import {
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
AskUserQuestionInput,
|
||||
BashInput,
|
||||
BashOutput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileReadOutput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
ReportFindingsInput,
|
||||
TaskCreateInput,
|
||||
TaskCreateOutput,
|
||||
TaskListOutput,
|
||||
TaskUpdateInput,
|
||||
TaskUpdateOutput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
WebSearchOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
ImageBlockParam,
|
||||
@@ -55,7 +62,6 @@ import {
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import path from "node:path";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
|
||||
@@ -212,7 +218,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: displayPath ? `Write ${displayPath}` : "Write",
|
||||
title: displayPath ? `Write ${displayPath}` : "Preparing file…",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
@@ -371,6 +377,25 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "ReportFindings": {
|
||||
const input = toolUse.input as ReportFindingsInput | undefined;
|
||||
const findings = input?.findings ?? [];
|
||||
return {
|
||||
title:
|
||||
findings.length === 0
|
||||
? "Report findings: none found"
|
||||
: `Report ${findings.length} finding${findings.length === 1 ? "" : "s"}`,
|
||||
kind: "think",
|
||||
content: findings.map((finding) => ({
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `**${finding.file}${finding.line ? `:${finding.line}` : ""}** — ${finding.summary}`,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskCreate": {
|
||||
const input = toolUse.input as TaskCreateInput | undefined;
|
||||
return {
|
||||
@@ -416,6 +441,16 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "Skill": {
|
||||
const input = toolUse.input as { skill?: string; args?: string } | undefined;
|
||||
const skillName = input?.skill;
|
||||
return {
|
||||
title: skillName ? `Load skill: ${skillName}` : "Load skill",
|
||||
kind: "other",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "AskUserQuestion": {
|
||||
const input = toolUse.input as Partial<AskUserQuestionInput> | undefined;
|
||||
const questions = Array.isArray(input?.questions) ? input.questions : [];
|
||||
@@ -466,6 +501,88 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow the untyped message-level `tool_use_result` toward a per-tool Output
|
||||
* shape: rejects everything but a plain non-null object (arrays pass a bare
|
||||
* `typeof === "object"` check, so they're excluded here). The returned value
|
||||
* is only *nominally* typed — it arrives over the wire from arbitrary CLI
|
||||
* versions, so each caller must still guard the specific fields it reads
|
||||
* before trusting them.
|
||||
*/
|
||||
function structuredResult<T extends object>(toolUseResult: unknown): T | undefined {
|
||||
return toolUseResult !== null &&
|
||||
typeof toolUseResult === "object" &&
|
||||
!Array.isArray(toolUseResult)
|
||||
? (toolUseResult as T)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the model-directed trailer from a raw Agent/Task tool_result text:
|
||||
* a `<usage>…</usage>` totals block and/or an
|
||||
* `agentId: <id> (use SendMessage …)` continuation line at the end of the
|
||||
* text. Both patterns are tail-anchored and independent (older CLIs emit
|
||||
* variants with only one of them), so a format change makes them stop
|
||||
* matching rather than mangle the report.
|
||||
*/
|
||||
function stripAgentTrailer(text: string): string {
|
||||
return stripAgentIdLine(stripUsageBlock(text));
|
||||
}
|
||||
|
||||
const USAGE_OPEN = "<usage>";
|
||||
const USAGE_CLOSE = "</usage>";
|
||||
|
||||
/** Remove a trailing `<usage>…</usage>` block, plus trailing whitespace and
|
||||
* one preceding newline. Matches from the *last* `<usage>` so a report that
|
||||
* merely mentions the marker earlier isn't truncated at the mention. */
|
||||
function stripUsageBlock(text: string): string {
|
||||
const body = text.trimEnd();
|
||||
if (!body.endsWith(USAGE_CLOSE)) {
|
||||
return text;
|
||||
}
|
||||
const open = body.lastIndexOf(USAGE_OPEN, body.length - USAGE_CLOSE.length - USAGE_OPEN.length);
|
||||
if (open === -1) {
|
||||
return text;
|
||||
}
|
||||
return body.slice(0, open > 0 && body[open - 1] === "\n" ? open - 1 : open);
|
||||
}
|
||||
|
||||
/** The continuation line, anchored to a whole line so the regex has a single
|
||||
* start position and no ambiguous repetition (`[\w-]+` can't consume the
|
||||
* following space, `[^)]*` can't consume the closing paren) — it runs in
|
||||
* linear time on any input. */
|
||||
const AGENT_ID_LINE = /^agentId: [\w-]+ \([^)]*\)$/;
|
||||
|
||||
/** Remove a final `agentId: <id> (…)` line, plus trailing whitespace and the
|
||||
* newline that preceded the line. */
|
||||
function stripAgentIdLine(text: string): string {
|
||||
const body = text.trimEnd();
|
||||
const lineStart = body.lastIndexOf("\n") + 1;
|
||||
if (!AGENT_ID_LINE.test(body.slice(lineStart))) {
|
||||
return text;
|
||||
}
|
||||
return body.slice(0, Math.max(lineStart - 1, 0));
|
||||
}
|
||||
|
||||
/** Apply {@link stripAgentTrailer} across a raw tool_result `content` (plain
|
||||
* string or block array), leaving non-text blocks untouched. */
|
||||
function stripAgentTrailerFromContent(content: unknown): unknown {
|
||||
if (typeof content === "string") {
|
||||
return stripAgentTrailer(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((block) =>
|
||||
block !== null &&
|
||||
typeof block === "object" &&
|
||||
block.type === "text" &&
|
||||
typeof block.text === "string"
|
||||
? { ...block, text: stripAgentTrailer(block.text) }
|
||||
: block,
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult:
|
||||
| ToolResultBlockParam
|
||||
@@ -480,19 +597,76 @@ export function toolUpdateFromToolResult(
|
||||
| BetaToolSearchToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
toolUseResult?: unknown,
|
||||
): ToolUpdate {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0
|
||||
toolResult.content.length > 0 &&
|
||||
!(toolUse?.name === "Bash" && supportsTerminalOutput)
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
|
||||
// Shared raw-text fallback: renders the tool_result content the model saw.
|
||||
// The structured cases below fall back to this when `tool_use_result` is
|
||||
// absent or fails its shape guard (older CLIs, replayed sessions).
|
||||
const rawContentUpdate = () =>
|
||||
toAcpContentUpdate(toolResult.content, "is_error" in toolResult ? toolResult.is_error : false);
|
||||
|
||||
switch (toolUse?.name) {
|
||||
case "Read":
|
||||
case "Read": {
|
||||
// The raw tool_result text is the model-facing view: line-numbered
|
||||
// content plus any appended <system-reminder> blocks (malicious-code
|
||||
// checks, memory staleness notes, …) that clients shouldn't see. The
|
||||
// structured FileReadOutput carries the clean content — rebuild the
|
||||
// line-numbered view from it. Non-text variants (image/notebook/pdf)
|
||||
// fall back to the raw content blocks, which already render fine.
|
||||
const structuredRead = structuredResult<FileReadOutput>(toolUseResult);
|
||||
if (
|
||||
structuredRead?.type === "text" &&
|
||||
typeof structuredRead.file?.content === "string" &&
|
||||
// An empty file has nothing to line-number; keep the raw view (the
|
||||
// model-facing "file is empty" note) rather than a phantom blank line.
|
||||
structuredRead.file.content.length > 0
|
||||
) {
|
||||
// startLine is typed non-optional but defended anyway; a Read's
|
||||
// `offset` input is the same 1-based starting line, so it beats a
|
||||
// blind 1 when an emitter omits the field.
|
||||
const startLine =
|
||||
structuredRead.file.startLine ??
|
||||
(toolUse.input as FileReadInput | undefined)?.offset ??
|
||||
1;
|
||||
// A trailing newline is a line terminator, not an extra line — don't
|
||||
// number a phantom empty line after it.
|
||||
let numbered = structuredRead.file.content
|
||||
.replace(/\n$/, "")
|
||||
.split("\n")
|
||||
.map((line, i) => `${startLine + i}\t${line}`)
|
||||
.join("\n");
|
||||
// The model-facing truncation banner doesn't survive reconstruction
|
||||
// from file.content (the SDK flag exists for exactly this case) —
|
||||
// re-establish it so a partial first page doesn't read as the whole
|
||||
// file.
|
||||
if (structuredRead.file.truncatedByTokenCap) {
|
||||
const { numLines, totalLines } = structuredRead.file;
|
||||
const detail =
|
||||
typeof numLines === "number" && typeof totalLines === "number"
|
||||
? `: showing ${numLines} of ${totalLines} lines`
|
||||
: "";
|
||||
numbered += `\n[File truncated${detail}]`;
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: markdownEscape(numbered) },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
@@ -520,20 +694,77 @@ export function toolUpdateFromToolResult(
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Bash": {
|
||||
const result = toolResult.content;
|
||||
const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
|
||||
// The terminal was announced under the tool_use's own id (see
|
||||
// `toolInfoFromToolUse`), so key the output/exit metas off that: it is the
|
||||
// id the client actually created a terminal for. `toolResult.tool_use_id`
|
||||
// is the same value whenever present — the caller looks the tool_use up by
|
||||
// it — so preferring `toolUse.id` only adds a source for the case where the
|
||||
// result block carries no id at all. Anything that isn't a non-empty
|
||||
// string is no id at all: `""` matches no terminal, and stringifying a
|
||||
// present-but-undefined field would invent the literal `"undefined"`.
|
||||
const terminalIdOf = (id: unknown): string | undefined =>
|
||||
typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
const terminalId: string | undefined =
|
||||
terminalIdOf(toolUse?.id) ??
|
||||
terminalIdOf("tool_use_id" in toolResult ? toolResult.tool_use_id : undefined);
|
||||
const isError = "is_error" in toolResult && toolResult.is_error;
|
||||
|
||||
// Extract output and exit code from either format:
|
||||
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
|
||||
// 2. Plain string content from a regular tool_result
|
||||
// 3. Array content (e.g. [{ type: "text", text: "..." }])
|
||||
// 1. The structured BashOutput (message-level tool_use_result): its
|
||||
// stdout/stderr exclude the model-directed suffixes the raw text
|
||||
// carries (stale-read hints, gh rate-limit hints, the
|
||||
// persisted-output wrapper for too-large outputs — the interruption
|
||||
// and truncation facts those carried are re-established from the
|
||||
// structured flags below). Skipped for image output (the raw content
|
||||
// array carries the actual image blocks) and backgrounded commands
|
||||
// (the raw text carries the background-task notice; structured
|
||||
// stdout may be empty).
|
||||
// 2. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
|
||||
// 3. Plain string content from a regular tool_result
|
||||
// 4. Array content (e.g. [{ type: "text", text: "..." }] for stdout,
|
||||
// or [{ type: "image", source: {...} }] when the local Bash tool
|
||||
// produces an image, e.g. piping a base64 data URI)
|
||||
let output = "";
|
||||
let exitCode = isError ? 1 : 0;
|
||||
|
||||
const structuredBash = structuredResult<BashOutput>(toolUseResult);
|
||||
if (
|
||||
structuredBash &&
|
||||
typeof structuredBash.stdout === "string" &&
|
||||
typeof structuredBash.stderr === "string" &&
|
||||
!structuredBash.isImage &&
|
||||
structuredBash.backgroundTaskId === undefined
|
||||
) {
|
||||
output = [structuredBash.stdout, structuredBash.stderr].filter(Boolean).join("\n");
|
||||
// Two raw-text notices don't survive the structured stdout/stderr —
|
||||
// re-establish them so the client isn't shown a clean-looking result:
|
||||
// the CLI appends its abort marker only to the model-facing text, and
|
||||
// an aborted command isn't a success, so synthesize a failing exit
|
||||
// code when the result wasn't already an error.
|
||||
if (structuredBash.interrupted) {
|
||||
output = [output, "[Command was aborted before completion]"].filter(Boolean).join("\n");
|
||||
exitCode = 1;
|
||||
}
|
||||
// Structured stdout is clipped (~30k chars) when the full output was
|
||||
// persisted to disk; without this note the clip is silent and the
|
||||
// path to the full output is lost.
|
||||
if (typeof structuredBash.persistedOutputPath === "string") {
|
||||
const size =
|
||||
typeof structuredBash.persistedOutputSize === "number"
|
||||
? ` (${structuredBash.persistedOutputSize} bytes total)`
|
||||
: "";
|
||||
output = [
|
||||
output,
|
||||
`[Output truncated${size}: full output saved to ${structuredBash.persistedOutputPath}]`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
} else if (
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"type" in result &&
|
||||
@@ -544,16 +775,30 @@ export function toolUpdateFromToolResult(
|
||||
exitCode = bashResult.return_code;
|
||||
} else if (typeof result === "string") {
|
||||
output = result;
|
||||
} else if (
|
||||
Array.isArray(result) &&
|
||||
result.length > 0 &&
|
||||
"text" in result[0] &&
|
||||
typeof result[0].text === "string"
|
||||
) {
|
||||
output = result.map((c: any) => c.text).join("\n");
|
||||
} else if (Array.isArray(result) && result.length > 0) {
|
||||
const textOnly = result.every(
|
||||
(c: any) => c && typeof c === "object" && typeof c.text === "string",
|
||||
);
|
||||
if (textOnly) {
|
||||
output = result.map((c: any) => c.text).join("\n");
|
||||
} else {
|
||||
// Image (or mixed non-text) content. Binary payloads can't be
|
||||
// streamed through the terminal-output _meta channel, so bypass
|
||||
// it and surface the blocks as ACP content. This handles the
|
||||
// local Bash tool's image output, which previously failed the
|
||||
// text-only guard and was silently dropped.
|
||||
return toAcpContentUpdate(result, isError);
|
||||
}
|
||||
}
|
||||
|
||||
if (supportsTerminalOutput) {
|
||||
// Without a terminal id there is nothing the client can reconcile these
|
||||
// metas against, and emitting them anyway strands the output: a client that
|
||||
// buffers output/exit for terminals it has not been told about (Zed keeps
|
||||
// them in `pending_terminal_output`/`pending_terminal_exit`, drained only on
|
||||
// a matching create) would hold them forever behind an id that never
|
||||
// arrives, showing an empty terminal. Fall through to the code-block
|
||||
// rendering below instead.
|
||||
if (supportsTerminalOutput && terminalId !== undefined) {
|
||||
return {
|
||||
content: [{ type: "terminal" as const, terminalId }],
|
||||
_meta: {
|
||||
@@ -589,6 +834,49 @@ export function toolUpdateFromToolResult(
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
// The raw tool_result text ends with a model-directed trailer (an
|
||||
// `agentId: … (use SendMessage …)` line plus a `<usage>` totals block)
|
||||
// that ACP clients shouldn't see. The message-level `tool_use_result`
|
||||
// carries the structured AgentOutput whose `content` is the subagent's
|
||||
// report without the trailer — render from it when present (per the SDK
|
||||
// 0.3.207 guidance) and fall back to the raw text otherwise (older CLIs,
|
||||
// replayed sessions).
|
||||
// Narrowed to the full union, not the completed variant — the status
|
||||
// check below is what discriminates it, and pre-narrowing would let
|
||||
// future field reads typecheck against a variant the runtime value may
|
||||
// not be.
|
||||
const structured = structuredResult<AgentOutput>(toolUseResult);
|
||||
if (
|
||||
structured?.status === "completed" &&
|
||||
Array.isArray(structured.content) &&
|
||||
// A completed subagent can end with zero text blocks; an empty
|
||||
// structured render would beat the raw fallback for no benefit.
|
||||
structured.content.length > 0
|
||||
) {
|
||||
return toAcpContentUpdate(
|
||||
structured.content,
|
||||
"is_error" in toolResult ? toolResult.is_error : false,
|
||||
);
|
||||
}
|
||||
// No structured report to render from (replayed sessions —
|
||||
// getSessionMessages doesn't expose the transcript's toolUseResult —
|
||||
// and older CLIs). The SDK advises rendering from tool_use_result
|
||||
// instead of parsing the text, but with no structured value the
|
||||
// tail-anchored strip is the only cleanup available; if the trailer
|
||||
// format changes it simply stops matching and the full raw text
|
||||
// renders, no worse than before.
|
||||
return toAcpContentUpdate(
|
||||
stripAgentTrailerFromContent(toolResult.content),
|
||||
"is_error" in toolResult ? toolResult.is_error : false,
|
||||
);
|
||||
}
|
||||
|
||||
case "Skill": {
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Edit": // Edit is handled in hooks
|
||||
case "Write": {
|
||||
return {};
|
||||
@@ -598,15 +886,54 @@ export function toolUpdateFromToolResult(
|
||||
return { title: "Exited Plan Mode" };
|
||||
}
|
||||
|
||||
case "WebSearch": {
|
||||
// The raw tool_result text is a model-directed dump ("Web search
|
||||
// results for query: …\n\nLinks: [{…json…}]"). The structured
|
||||
// WebSearchOutput carries the hits — render them the way server-side
|
||||
// web_search_result blocks render ("Title (url)").
|
||||
const structuredSearch = structuredResult<WebSearchOutput>(toolUseResult);
|
||||
if (structuredSearch && Array.isArray(structuredSearch.results)) {
|
||||
const lines = structuredSearch.results.flatMap((entry) =>
|
||||
typeof entry === "string"
|
||||
? [entry]
|
||||
: Array.isArray(entry?.content)
|
||||
? // tool_use_result arrives untyped across CLI version skew —
|
||||
// skip off-spec hits rather than rendering
|
||||
// "undefined (undefined)" lines.
|
||||
entry.content.flatMap((hit) =>
|
||||
typeof hit?.title === "string" && typeof hit?.url === "string"
|
||||
? [formatWebSearchHit(hit)]
|
||||
: [],
|
||||
)
|
||||
: [],
|
||||
);
|
||||
if (lines.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: lines.join("\n") },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
return rawContentUpdate();
|
||||
}
|
||||
|
||||
default: {
|
||||
return toAcpContentUpdate(
|
||||
toolResult.content,
|
||||
"is_error" in toolResult ? toolResult.is_error : false,
|
||||
);
|
||||
return rawContentUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One display format for a web-search hit, shared by the structured
|
||||
* WebSearchOutput render and the server-side `web_search_result` block so
|
||||
* the two paths can't drift. */
|
||||
function formatWebSearchHit(hit: { title: string; url: string }): string {
|
||||
return `${hit.title} (${hit.url})`;
|
||||
}
|
||||
|
||||
function toAcpContentUpdate(
|
||||
content: any,
|
||||
isError: boolean = false,
|
||||
@@ -681,7 +1008,7 @@ function toAcpContentBlock(content: ToolResultContent, isError: boolean): Conten
|
||||
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
|
||||
);
|
||||
case "web_search_result":
|
||||
return wrapText(`${content.title} (${content.url})`);
|
||||
return wrapText(formatWebSearchHit(content));
|
||||
case "web_search_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "web_fetch_result":
|
||||
@@ -719,7 +1046,7 @@ export type ClaudePlanEntry = {
|
||||
|
||||
export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): PlanEntry[] {
|
||||
return (input?.todos ?? []).map((todo) => ({
|
||||
content: todo.content,
|
||||
content: todo.status === "in_progress" && todo.activeForm ? todo.activeForm : todo.content,
|
||||
status: todo.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
@@ -741,31 +1068,29 @@ export type TaskEntry = {
|
||||
export type TaskState = Map<string, TaskEntry>;
|
||||
|
||||
/**
|
||||
* Best-effort parse of a TaskCreate tool_result content into the structured
|
||||
* TaskCreateOutput. The SDK delivers tool outputs either as a string or as
|
||||
* an array of TextBlockParam-like blocks containing JSON text; try both.
|
||||
* Best-effort parse of a structured Task* tool_result. The SDK delivers tool
|
||||
* outputs either as a string or as an array of TextBlockParam-like blocks
|
||||
* containing JSON text; try both.
|
||||
*/
|
||||
export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | undefined {
|
||||
const tryParse = (text: string): TaskCreateOutput | undefined => {
|
||||
function parseJsonToolOutput<T>(
|
||||
content: unknown,
|
||||
isExpectedOutput: (value: unknown) => value is T,
|
||||
): T | undefined {
|
||||
const tryParse = (text: string): T | undefined => {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.task &&
|
||||
typeof parsed.task.id === "string"
|
||||
) {
|
||||
return parsed as TaskCreateOutput;
|
||||
}
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return isExpectedOutput(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
// ignore
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (typeof content === "string") {
|
||||
return tryParse(content);
|
||||
}
|
||||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||
return isExpectedOutput(content) ? content : undefined;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && "type" in block && block.type === "text") {
|
||||
@@ -780,6 +1105,119 @@ export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toolOutputTexts(content: unknown): string[] {
|
||||
if (typeof content === "string") return [content];
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.flatMap((block) =>
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
block.type === "text" &&
|
||||
"text" in block &&
|
||||
typeof block.text === "string"
|
||||
? [block.text]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | undefined {
|
||||
const structured = parseJsonToolOutput(content, (parsed): parsed is TaskCreateOutput =>
|
||||
Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"task" in parsed &&
|
||||
parsed.task &&
|
||||
typeof parsed.task === "object" &&
|
||||
"id" in parsed.task &&
|
||||
typeof parsed.task.id === "string",
|
||||
),
|
||||
);
|
||||
if (structured) return structured;
|
||||
|
||||
for (const text of toolOutputTexts(content)) {
|
||||
const match = /^Task #(\S+) created successfully: (.+)$/.exec(text.trim());
|
||||
if (match) return { task: { id: match[1], subject: match[2] } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseTaskListOutput(content: unknown): TaskListOutput | undefined {
|
||||
const validStatuses = new Set(["pending", "in_progress", "completed"]);
|
||||
const structured = parseJsonToolOutput(content, (parsed): parsed is TaskListOutput =>
|
||||
Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"tasks" in parsed &&
|
||||
Array.isArray(parsed.tasks) &&
|
||||
parsed.tasks.every(
|
||||
(task) =>
|
||||
task &&
|
||||
typeof task === "object" &&
|
||||
typeof task.id === "string" &&
|
||||
typeof task.subject === "string" &&
|
||||
typeof task.status === "string" &&
|
||||
validStatuses.has(task.status),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (structured) return structured;
|
||||
|
||||
for (const text of toolOutputTexts(content)) {
|
||||
if (text.trim() === "No tasks found") return { tasks: [] };
|
||||
|
||||
const tasks: TaskListOutput["tasks"] = [];
|
||||
const lines = text.trim().split("\n");
|
||||
for (const line of lines) {
|
||||
const match =
|
||||
/^#(\S+) \[(pending|in_progress|completed)\] (.+?)(?: \(([^()]*)\))?(?: \[blocked by ((?:#[^,\]]+(?:, )?)+)\])?$/.exec(
|
||||
line,
|
||||
);
|
||||
if (!match) {
|
||||
tasks.length = 0;
|
||||
break;
|
||||
}
|
||||
tasks.push({
|
||||
id: match[1],
|
||||
subject: match[3],
|
||||
status: match[2] as TaskListOutput["tasks"][number]["status"],
|
||||
...(match[4] ? { owner: match[4] } : {}),
|
||||
blockedBy: match[5] ? match[5].split(", ").map((id) => id.slice(1)) : [],
|
||||
});
|
||||
}
|
||||
if (tasks.length > 0) return { tasks };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseTaskUpdateOutput(
|
||||
content: unknown,
|
||||
expectedTaskId?: string,
|
||||
): TaskUpdateOutput | undefined {
|
||||
const structured = parseJsonToolOutput(content, (parsed): parsed is TaskUpdateOutput =>
|
||||
Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"success" in parsed &&
|
||||
typeof parsed.success === "boolean" &&
|
||||
"taskId" in parsed &&
|
||||
typeof parsed.taskId === "string" &&
|
||||
"updatedFields" in parsed &&
|
||||
Array.isArray(parsed.updatedFields) &&
|
||||
parsed.updatedFields.every((field) => typeof field === "string"),
|
||||
),
|
||||
);
|
||||
if (structured) return structured;
|
||||
|
||||
for (const text of toolOutputTexts(content)) {
|
||||
const notFound = /^Task #(\S+) not found$/.exec(text.trim());
|
||||
const taskId = notFound?.[1] ?? expectedTaskId;
|
||||
if (taskId && (notFound || text.trim() === "Failed to delete task")) {
|
||||
return { success: false, taskId, updatedFields: [], error: text.trim() };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyTaskCreate(
|
||||
state: TaskState,
|
||||
input: TaskCreateInput | undefined,
|
||||
@@ -814,9 +1252,23 @@ export function applyTaskUpdate(state: TaskState, input: TaskUpdateInput | undef
|
||||
});
|
||||
}
|
||||
|
||||
export function applyTaskList(state: TaskState, output: TaskListOutput): void {
|
||||
const previous = new Map(state);
|
||||
state.clear();
|
||||
for (const task of output.tasks) {
|
||||
const existing = previous.get(task.id);
|
||||
state.set(task.id, {
|
||||
subject: task.subject,
|
||||
status: task.status,
|
||||
activeForm: existing?.activeForm,
|
||||
description: existing?.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function taskStateToPlanEntries(state: TaskState): PlanEntry[] {
|
||||
return Array.from(state.values()).map((task) => ({
|
||||
content: task.subject,
|
||||
content: task.status === "in_progress" && task.activeForm ? task.activeForm : task.subject,
|
||||
status: task.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
@@ -924,12 +1376,7 @@ export const registerHookCallback = (
|
||||
|
||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
||||
export const createPostToolUseHook =
|
||||
(
|
||||
logger: Logger = console,
|
||||
options?: {
|
||||
onEnterPlanMode?: () => Promise<void>;
|
||||
},
|
||||
): HookCallback =>
|
||||
(options?: { onEnterPlanMode?: () => Promise<void> }): HookCallback =>
|
||||
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse") {
|
||||
// Handle EnterPlanMode tool - notify client of mode change after successful execution
|
||||
@@ -941,11 +1388,8 @@ export const createPostToolUseHook =
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
}
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2021"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
|
||||
Reference in New Issue
Block a user