Compare commits

..

3 Commits

Author SHA1 Message Date
Ben Brandt
affc731e3a docs: clarify stream-id tracking and liveStreamed dedup assumptions
Add comments explaining (1) why the streamed-id tracking runs for every
stream_event rather than only parent_tool_use_id === null, and that the
single currentStreamingMessageId field is safe given serialized events,
and (2) that the liveStreamed drop is all-or-nothing per message id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:03:50 +02:00
cuiyiming.leo95
63b708176e fix: dedup fallback text/thinking per content block, not per message id
The fallback added in dc640e2 keyed its dedup on the assistant message
id: once a message id emitted any text/thinking block, the id was added
to `textStreamedMessageIds` and every later block under that id was
filtered out. That breaks when an OpenAI-compatible gateway (observed
with gpt-5.4) splits a single assistant response into several `assistant`
messages that share one id but each carry a different block — typically
an empty `thinking` block first, then the real `text` block. The empty
thinking block flagged the id, so the actual answer arriving in a later
same-id message was dropped: the client saw the fallback log fire but
received an empty turn.

Track the assembled blocks the fallback already emitted this turn
(`fallbackEmittedBlockKeys`, keyed on id + block type + block content)
instead of flagging the whole message id. Distinct blocks that share an
id are all forwarded, while identical re-deliveries (an incremental block
followed by a consolidated message, or the same message delivered twice)
still dedup. `textStreamedMessageIds` returns to its original meaning —
"was this streamed live via deltas" — and is no longer written by the
fallback. The new set is turn-scoped and cleared alongside it.

Tests cover split-block delivery (empty thinking then text under one id),
multiple distinct text blocks sharing an id, and the
incremental-then-consolidated dedup case.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 23:57:26 -07:00
cuiyiming.leo95
dc640e2dcf fix: fall back to assembled assistant text when no stream deltas arrive
The `assistant` case in the prompt loop unconditionally filtered out
`text` and `thinking` content blocks on the assumption that those had
already been pushed to the client via `stream_event` →
`content_block_delta` notifications. That assumption holds when the
underlying SDK / model proxy emits Anthropic-style streaming deltas, but
it breaks when the model is fronted by a proxy that returns the second
model call (the one that consumes a tool_result) as a single non-
streamed assistant message. In that case `content_block_delta` for the
final text is never produced, the assembled `text` block is filtered
out, and the ACP client receives `stopReason: "end_turn"` with no
`agent_message_chunk` notifications — so the user sees the tool's
progress but an empty final answer.

Track which assistant message ids have actually produced streamed
text/thinking deltas (`textStreamedMessageIds` on `Session`, populated
from `message_start` + `content_block_delta` in the `stream_event`
case). In the `assistant` case, keep the existing filter when the id is
in that set and fall back to forwarding the assembled `text` / `thinking`
blocks as `agent_message_chunk` / `agent_thought_chunk` when it isn't.
The fallback emits exactly once per assistant message id, mirroring the
dedupe pattern already used by `toolUseCache` for tool_use blocks.

Tests cover both branches: a session whose assistant message arrives
with no preceding `content_block_delta` now produces exactly one text
chunk, and a session that did stream the same text via deltas does not
re-emit it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 18:51:35 -07:00
41 changed files with 2297 additions and 21684 deletions

View File

@@ -21,10 +21,10 @@ jobs:
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: "lts/*"
cache: "npm"

View File

@@ -1,48 +0,0 @@
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

View File

@@ -3,21 +3,9 @@ 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:
@@ -39,11 +27,7 @@ jobs:
id: release
with:
token: ${{ steps.generate-token.outputs.token }}
# 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.
release-type: node
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
outputs:
@@ -54,46 +38,20 @@ jobs:
name: Publish to npm
runs-on: ubuntu-latest
needs: [release-please]
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }}
if: ${{ needs.release-please.outputs.release_created }}
environment: release # Optional: for enhanced security
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
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
View File

@@ -3,4 +3,3 @@ dist
.DS_Store
.claude/*.local.*
CLAUDE.local.md
.idea/

View File

@@ -1,3 +1,3 @@
{
".": "0.66.0"
".": "0.37.0"
}

View File

@@ -1,42 +0,0 @@
# 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).

View File

@@ -1,395 +1,5 @@
# Changelog
## [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)
### Features
* **deps:** bump the minor group with 14 updates ([#758](https://github.com/agentclientprotocol/claude-agent-acp/issues/758)) ([7a70162](https://github.com/agentclientprotocol/claude-agent-acp/commit/7a701623df2e05aec8e552bdfd1cf573413c3471))
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.3.170 ([#761](https://github.com/agentclientprotocol/claude-agent-acp/issues/761)) ([d8af943](https://github.com/agentclientprotocol/claude-agent-acp/commit/d8af943a1efef9be27d771cec089d2f1cb56c523))
## [0.43.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.42.0...v0.43.0) (2026-06-09)
### Features
* Add experimental elicitation support ([#756](https://github.com/agentclientprotocol/claude-agent-acp/issues/756)) ([12bd276](https://github.com/agentclientprotocol/claude-agent-acp/commit/12bd2762d9ba0ccb7497a3848a5928b8bb2ce820))
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.3.169 ([#754](https://github.com/agentclientprotocol/claude-agent-acp/issues/754)) ([bd0ae4d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bd0ae4dde160f59559ab4ac1bf703fcf151eff17))
* Update ACP SDK to 0.25.0 ([#753](https://github.com/agentclientprotocol/claude-agent-acp/issues/753)) ([0dbccf5](https://github.com/agentclientprotocol/claude-agent-acp/commit/0dbccf588ff702015494dd29969d47f0b7402feb))
### Bug Fixes
* Forward unstreamed assistant text blocks ([#757](https://github.com/agentclientprotocol/claude-agent-acp/issues/757)) ([7ff6b7f](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ff6b7fd6b157aedbce0c636087f51ae7d1df3a7))
* Validate cwd before creating sessions ([#751](https://github.com/agentclientprotocol/claude-agent-acp/issues/751)) ([9854b0c](https://github.com/agentclientprotocol/claude-agent-acp/commit/9854b0c6790ebafff514e3c4c8e03a131f065a4a)), closes [#749](https://github.com/agentclientprotocol/claude-agent-acp/issues/749)
## [0.42.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.41.0...v0.42.0) (2026-06-05)
### Features
* **deps-dev:** bump obug from 2.1.1 to 2.1.2 in the minor group ([#743](https://github.com/agentclientprotocol/claude-agent-acp/issues/743)) ([7ffb78c](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ffb78c57280fc32af77862896c0035ce0e690b7))
* Send message ids for assistant and user messages ([#750](https://github.com/agentclientprotocol/claude-agent-acp/issues/750)) ([18516a3](https://github.com/agentclientprotocol/claude-agent-acp/commit/18516a3088f847a38f5eee0fe81ad5ca3e2a751c))
* Update Claude Agent SDK to 0.3.165 ([#746](https://github.com/agentclientprotocol/claude-agent-acp/issues/746)) ([23cbe6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/23cbe6f58fa9e10ad736f664c4cf94dbd1c6ad65))
### Bug Fixes
* Prevent cross-family model matching in resolveModelPreference ([#731](https://github.com/agentclientprotocol/claude-agent-acp/issues/731)) ([f4704c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/f4704c168f917e6710f2b3cd49e1fef49613a469))
* Prune tool cache per session after results ([#748](https://github.com/agentclientprotocol/claude-agent-acp/issues/748)) ([ec14211](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec142110561f680330c6976783805685c5c069ec))
* Update to better utilize existing SDK data ([#747](https://github.com/agentclientprotocol/claude-agent-acp/issues/747)) ([398f763](https://github.com/agentclientprotocol/claude-agent-acp/commit/398f763f71bd2fe2ca9e8269de120d26a744b7a8))
## [0.41.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.40.0...v0.41.0) (2026-06-04)
### Features
* **deps:** upgrade to @anthropic-ai/claude-agent-sdk@0.3.162 ([#740](https://github.com/agentclientprotocol/claude-agent-acp/issues/740)) ([add7e31](https://github.com/agentclientprotocol/claude-agent-acp/commit/add7e31704581aa70febb6341ca0e8cee5d93b43))
### Bug Fixes
* Force cancellation when SDK query hangs ([#742](https://github.com/agentclientprotocol/claude-agent-acp/issues/742)) ([cffea4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/cffea4be28e6def91027110433a99e80b1be8a0c)), closes [#680](https://github.com/agentclientprotocol/claude-agent-acp/issues/680)
## [0.40.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.39.0...v0.40.0) (2026-06-02)
### Features
* **deps-dev:** bump the minor group with 2 updates ([#726](https://github.com/agentclientprotocol/claude-agent-acp/issues/726)) ([51a370e](https://github.com/agentclientprotocol/claude-agent-acp/commit/51a370e4feb8a74279698b156e35b96631a74d74))
* **deps:** bump actions/checkout from 6.0.2 to 6.0.3 ([#736](https://github.com/agentclientprotocol/claude-agent-acp/issues/736)) ([9cda07b](https://github.com/agentclientprotocol/claude-agent-acp/commit/9cda07ba9df27b48bd94332e365eed982e3f5613))
* **deps:** bump the minor group with 50 updates ([#737](https://github.com/agentclientprotocol/claude-agent-acp/issues/737)) ([32175b8](https://github.com/agentclientprotocol/claude-agent-acp/commit/32175b83a0227d0baa1f7d7eedabe743b4b870e8))
### Bug Fixes
* Optimize local command marker stripping ([#738](https://github.com/agentclientprotocol/claude-agent-acp/issues/738)) ([895422c](https://github.com/agentclientprotocol/claude-agent-acp/commit/895422c72d5f955903f227dc6887df823d2730e6)), closes [#727](https://github.com/agentclientprotocol/claude-agent-acp/issues/727)
## [0.39.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.38.0...v0.39.0) (2026-05-29)
### Features
* **deps:** update Claude Agent SDK to 0.3.156 ([#722](https://github.com/agentclientprotocol/claude-agent-acp/issues/722)) ([3fb6db4](https://github.com/agentclientprotocol/claude-agent-acp/commit/3fb6db4e5ee5bd1f431cf5d2ce5331ce983f9597))
## [0.38.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.37.0...v0.38.0) (2026-05-28)
### Features
* **deps:** bump to @anthropic-ai/claude-agent-sdk 0.3.154 (Opus 4.8) ([#716](https://github.com/agentclientprotocol/claude-agent-acp/issues/716)) ([a172885](https://github.com/agentclientprotocol/claude-agent-acp/commit/a172885c80fc32c108a8501268a543641088f543))
* Support Opus 4.8 ([#718](https://github.com/agentclientprotocol/claude-agent-acp/issues/718)) ([98b54a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/98b54a02c267cdef619becceccf6ef519fbf8f6f))
### Bug Fixes
* Remove hide Claude auth flag handling ([#707](https://github.com/agentclientprotocol/claude-agent-acp/issues/707)) ([7ed1daf](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ed1daf7b42088ad149e9bf742f764752b82d093))
## [0.37.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.36.1...v0.37.0) (2026-05-21)

View File

@@ -1 +0,0 @@
AGENTS.md

View File

@@ -12,25 +12,12 @@ 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:

View File

@@ -1,119 +1,24 @@
# Releases
## How to publish a new release
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
1. Create a new branch prep-vX.X.X
```sh
npm run release:preflight
git checkout -b prep-vX.X.X
```
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:
2. Bump the version in package.json
```sh
gh pr merge <pr-number> --squash
gh run watch "$(gh run list --workflow=publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
npm version vX.X.X
```
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:
3. Add a new entry in CHANGELOG.md
4. Create a PR for `prev-vX.X.X` & merge it
5. Create tag and push it
```sh
gh release view "v<version>"
npm view "@agentclientprotocol/claude-agent-acp@<version>"
git tag vX.X.X
git push origin vX.X.X
```
## 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.
6. Paste CHANGELOG into release notes and publish the version on GitHub

View File

@@ -1,57 +0,0 @@
# 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.

View File

@@ -40,7 +40,7 @@ export default [
"default-case": "error",
"prefer-const": "error",
"no-var": "error",
eqeqeq: ["error", "smart"],
eqeqeq: ["error", "always"],
curly: ["error", "all"],
},
},

View File

@@ -1,706 +0,0 @@
/**
* 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);
});

View File

@@ -1,221 +0,0 @@
/**
* 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;
});

995
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.66.0",
"version": "0.37.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -29,13 +29,11 @@
"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",
@@ -61,27 +59,23 @@
},
"author": "Zed Industries",
"license": "Apache-2.0",
"engines": {
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.3.0",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@agentclientprotocol/sdk": "0.22.1",
"@anthropic-ai/claude-agent-sdk": "0.3.146",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.115.0",
"@anthropic-ai/sdk": "0.97.1",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.5",
"@types/node": "26.1.2",
"@typescript-eslint/eslint-plugin": "8.65.0",
"@typescript-eslint/parser": "8.65.0",
"eslint": "10.8.0",
"@types/node": "25.9.1",
"@typescript-eslint/eslint-plugin": "8.59.4",
"@typescript-eslint/parser": "8.59.4",
"eslint": "10.4.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.9.0",
"prettier": "3.9.6",
"globals": "17.6.0",
"prettier": "3.8.3",
"ts-node": "10.9.2",
"typescript": "6.0.3",
"vitest": "4.1.10"
"vitest": "4.1.7"
}
}

View File

@@ -1,12 +1,12 @@
{
"bump-minor-pre-major": true,
"include-component-in-tag": false,
"bump-patch-for-minor-pre-major": true,
"packages": {
".": {
"changelog-path": "CHANGELOG.md",
"release-type": "node",
"bump-minor-pre-major": true,
"include-component-in-tag": false
"bump-patch-for-minor-pre-major": true
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"

View File

@@ -1,124 +0,0 @@
#!/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

File diff suppressed because it is too large Load Diff

View File

@@ -1,418 +0,0 @@
import { randomUUID } from "node:crypto";
import { CreateElicitationResponse } from "@agentclientprotocol/sdk";
import type {
CreateElicitationRequest,
ElicitationAcceptAction,
ElicitationPropertySchema,
ElicitationSchema,
EnumOption,
} from "@agentclientprotocol/sdk";
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
import type {
AskUserQuestionInput,
AskUserQuestionOutput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
/**
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
* (unstable) elicitation protocol.
*
* Two distinct SDK surfaces flow through here:
*
* 1. `onElicitation` — fired when an MCP server requests user input. These map
* directly onto ACP `session/create_elicitation` (form or url mode).
* 2. The built-in AskUserQuestion tool — when a `canUseTool` callback is
* registered the SDK routes its permission check through `canUseTool`
* (not the interactive `permission_ask_user_question` dialog). We render
* its questions as an ACP form elicitation and feed the user's selections
* back as the tool's `updatedInput`, which the tool's own `call()` reads.
*/
/** Modes the connected client advertised support for. */
export type ElicitationSupport = {
form: boolean;
url: boolean;
};
/**
* Convert an MCP elicitation request (from the SDK's `onElicitation` callback)
* into an ACP `CreateElicitationRequest`. Returns `null` when the request can't
* be represented (e.g. a url-mode request with no url).
*/
export function mcpElicitationToCreateRequest(
request: ElicitationRequest,
sessionId: string,
): CreateElicitationRequest | null {
if (request.mode === "url") {
if (!request.url) {
return null;
}
return {
mode: "url",
sessionId,
message: request.message,
url: request.url,
// URL elicitations need a stable id so the client can correlate the
// later `session/complete_elicitation` notification. MCP servers usually
// provide one; fall back to a generated id if not.
elicitationId: request.elicitationId ?? randomUUID(),
};
}
// Form mode (the default). The MCP `requestedSchema` is already a JSON Schema
// with primitive-typed properties, which is structurally what ACP expects.
return {
mode: "form",
sessionId,
message: request.message,
requestedSchema: normalizeElicitationSchema(request.requestedSchema),
};
}
/**
* 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.
*/
export function createElicitationResponseToElicitResult(
response: CreateElicitationResponse,
): ElicitationResult {
switch (response.action) {
case "accept":
return { action: "accept", content: acceptedElicitationContent(response) };
case "decline":
return { action: "decline" };
case "cancel":
default:
return { action: "cancel" };
}
}
/**
* A single question as supplied by the AskUserQuestion tool. Derived from the
* SDK's input type so the shape stays in sync; the SDK validates the model's
* tool call against this schema before it reaches us.
*/
export type AskUserQuestion = AskUserQuestionInput["questions"][number];
/**
* Pull the well-formed questions out of an AskUserQuestion tool input. Returns
* `null` when there are no usable questions — including the case where every
* entry is malformed and filtering leaves an empty list — so callers can treat
* "nothing to ask" uniformly.
*/
export function extractAskUserQuestions(input: Record<string, unknown>): AskUserQuestion[] | null {
const questions = (input as { questions?: unknown }).questions;
if (!Array.isArray(questions)) {
return null;
}
const valid = questions.filter(
(q): q is AskUserQuestion =>
!!q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length > 0,
);
return valid.length > 0 ? valid : null;
}
/** Stable form-field key for the question at the given index. */
function questionFieldKey(index: number): string {
return `question_${index}`;
}
/**
* Form-field key for the per-question free-text "custom answer" field that sits
* alongside `question_<n>`. Mirrors the first-party clients, where every
* question carries its own "Other" box rather than one form-level field.
*/
function questionCustomFieldKey(index: number): string {
return `question_${index}_custom`;
}
/**
* `_meta` key under which a bridged enum option carries its `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.
*
* Fields are keyed by a short stable id (`question_<n>`) rather than the full
* question text, so the question text appears in exactly one place per field.
* Single-select questions use a titled `oneOf` enum; multi-select questions use
* an array with a titled `anyOf` item enum. The enum `const` is always the
* option label, since that is what the tool records as the answer; an option's
* secondary text travels in the enum option's own `description` field.
*
* Each question is followed by its own optional free-text "custom answer" field
* (`question_<n>_custom`), mirroring the CLI's per-question "Other" box: the
* user can type their own answer instead of picking an option, scoped to that
* specific question. Nothing is marked required, so the user can also just skip
* — matching the built-in tool, which always offers Skip + a free-text box.
*/
export function askUserQuestionsToCreateRequest(
questions: AskUserQuestion[],
sessionId: string,
toolCallId: string | undefined,
): CreateElicitationRequest {
const single = questions.length === 1;
const properties: Record<string, ElicitationPropertySchema> = {};
questions.forEach((question, index) => {
const options: EnumOption[] = question.options.map((option) => {
const enumOption: EnumOption = {
const: option.label,
title: 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
// needs its own question text.
const description = single ? undefined : question.question;
const title = question.header || undefined;
properties[questionFieldKey(index)] = question.multiSelect
? { type: "array", title, description, items: { anyOf: options } }
: { type: "string", title, description, oneOf: options };
properties[questionCustomFieldKey(index)] = {
type: "string",
title: "Other",
description: "Type your own answer instead of choosing an option above (optional).",
_meta: {
[CUSTOM_ANSWER_META_KEY]: {
questionId: questionFieldKey(index),
isCustomAnswer: true,
},
},
};
});
const requestedSchema: ElicitationSchema = {
type: "object",
properties,
};
const message = single ? questions[0].question : "Please answer the following questions.";
return {
mode: "form",
sessionId,
...(toolCallId ? { toolCallId } : {}),
message,
requestedSchema,
};
}
/** Outcome of an AskUserQuestion elicitation, decoupled from any transport. */
export type AskUserQuestionOutcome =
{ action: "answered"; updatedInput: Record<string, unknown> } | { action: "cancel" };
/**
* Fold an ACP elicitation response into the AskUserQuestion tool's input.
*
* Selected labels are read back from the indexed form fields and written into
* `answers` as a `{ [questionText]: label }` map (comma-joining multi-selects)
* — the key shape the tool's own `call()` reads. A non-empty per-question
* custom-answer field (`question_<n>_custom`) takes precedence over that
* question's selection, since the user typed their own answer instead of
* picking one. Decline yields empty answers (the model is told the user skipped
* rather than the turn aborting); cancel — 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 === "decline") {
return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
}
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;
}
const text = Array.isArray(value) ? value.join(", ") : String(value);
if (text === "") {
return;
}
answers[question.question] = text;
});
return { action: "answered", updatedInput: { ...toolInput, answers } };
}
/**
* Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
* The two are structurally compatible JSON Schemas; we just guarantee the
* `type: "object"` discriminator is present.
*/
function normalizeElicitationSchema(
schema: Record<string, unknown> | undefined,
): ElicitationSchema {
if (!schema || typeof schema !== "object") {
return { type: "object", properties: {} };
}
return { ...(schema as ElicitationSchema), type: "object" };
}
/**
* 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;
}

View File

@@ -1,83 +0,0 @@
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,
};
}

View File

@@ -2,11 +2,7 @@
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");
@@ -37,9 +33,6 @@ 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

View File

@@ -3,4 +3,4 @@ description: Say hello
argument-hint: name
---
Respond with "Hello $ARGUMENTS" and nothing else.
Respond with "Hello $1" and nothing else.

View File

@@ -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 { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { makeMockQuery } from "./helpers.js";
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
const { querySpy } = vi.hoisted(() => ({
querySpy: vi.fn(),
@@ -21,13 +21,13 @@ describe("ClaudeAcpAgent settings", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
function createMockClient(): AcpClient {
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient;
} as unknown as AgentSideConnection;
}
function mockQuery() {
@@ -35,7 +35,7 @@ describe("ClaudeAcpAgent settings", () => {
const setModelSpy = vi.fn();
querySpy.mockImplementation(({ options }: any) => {
capturedOptions = options;
return makeMockQuery({
return {
initializationResult: async () => ({
models: [
{
@@ -46,7 +46,8 @@ describe("ClaudeAcpAgent settings", () => {
],
}),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
return { getCapturedOptions: () => capturedOptions, setModelSpy };
}
@@ -224,9 +225,7 @@ describe("ClaudeAcpAgent settings", () => {
// No setModel call is needed because no override was applied — the SDK is
// already on its own default.
expect(setModelSpy).not.toHaveBeenCalled();
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
"claude-sonnet-4-6",
);
expect(response.models.currentModelId).toBe("claude-sonnet-4-6");
});
describe("auto mode availability per model", () => {
@@ -240,11 +239,12 @@ describe("ClaudeAcpAgent settings", () => {
const setPermissionModeSpy = vi.fn();
querySpy.mockImplementation(({ options }: any) => {
capturedOptions = options;
return makeMockQuery({
return {
initializationResult: async () => ({ models }),
setModel: setModelSpy,
setPermissionMode: setPermissionModeSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
return {
getCapturedOptions: () => capturedOptions,
@@ -395,10 +395,11 @@ describe("ClaudeAcpAgent settings", () => {
} {
const setModelSpy = vi.fn();
querySpy.mockImplementation(() => {
return makeMockQuery({
return {
initializationResult: async () => ({ models }),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
return { setModelSpy };
}
@@ -577,12 +578,10 @@ describe("ClaudeAcpAgent settings", () => {
});
expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5");
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
"claude-haiku-4-5",
);
expect(response.models.currentModelId).toBe("claude-haiku-4-5");
});
it("does not inherit display info across mismatched model versions", async () => {
it("does not inherit display info across mismatched model family versions", async () => {
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
// when the SDK's `opus` alias resolves to Opus 4.7, an allowlist entry
// of `claude-opus-4-6` (or `claude-opus-4-6[1m]`) used to substring-match
@@ -643,221 +642,6 @@ describe("ClaudeAcpAgent settings", () => {
expect(byValue["claude-opus-4-7"].name).toBe("Opus 4.7");
expect(byValue["claude-opus-4-7[1m]"].name).toMatch(/Opus 4\.7/);
});
it("does not inherit display info across mismatched model families", async () => {
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
// when the SDK's `opus` alias resolves to Opus 4.8, `claude-opus-4-6[1m]`
// fails the version check against `opus` but the tokenized matcher
// falls through to `sonnet[1m]` because the `1m` context hint alone
// is enough to score a match.
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
availableModels: ["claude-opus-4-6", "claude-opus-4-6[1m]"],
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQueryWithModels([
{ value: "default", displayName: "Default", description: "Default model" },
{
value: "sonnet",
displayName: "Sonnet",
description: "Sonnet 4.6 · Best for everyday tasks",
},
{
value: "sonnet[1m]",
displayName: "Sonnet (1M context)",
description: "Sonnet 4.6 for long sessions",
},
{
value: "opus",
displayName: "Opus 4.8",
description: "Claude Opus 4.8",
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const modelOption = response.configOptions.find((o: any) => o.id === "model");
const byValue: Record<string, { name: string; description?: string }> = {};
for (const opt of modelOption.options) {
byValue[opt.value] = { name: opt.name, description: opt.description };
}
expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]");
expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6");
});
it("preserves ANTHROPIC_CUSTOM_MODEL_OPTION even when absent from the allowlist", async () => {
// Per the model-config docs, ANTHROPIC_CUSTOM_MODEL_OPTION adds an entry
// "without replacing the built-in aliases" and "appears at the bottom of
// the /model picker", so it is exempt from the availableModels allowlist
// (the same way the Default option is "not affected by availableModels").
// ACP must match: a slim alias allowlist must not hide the custom model
// row, and it appears last, after the allowlisted entries.
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
try {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
availableModels: ["sonnet", "opus", "haiku"],
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQueryWithModels([
{ value: "default", displayName: "Default", description: "Default model" },
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
{ value: "opus", displayName: "Opus", description: "Claude Opus 4.6" },
{ value: "haiku", displayName: "Haiku", description: "Claude Haiku 4.5" },
{
value: "claude-opus-4-8[1m]",
displayName: "Opus 4.8",
description: "Claude Opus 4.8",
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const modelOption = response.configOptions.find((o: any) => o.id === "model");
expect(modelOption.options.map((o: any) => o.value)).toEqual([
"default",
"sonnet",
"opus",
"haiku",
"claude-opus-4-8[1m]",
]);
const custom = modelOption.options.find((o: any) => o.value === "claude-opus-4-8[1m]");
expect(custom.name).toBe("Opus 4.8");
expect(custom.description).toBe("Claude Opus 4.8");
} finally {
if (originalEnv === undefined) {
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
} else {
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
}
}
});
it("does not duplicate the custom model option when also in the allowlist", async () => {
// If the user lists the custom model's exact ID in availableModels AND it
// is set as ANTHROPIC_CUSTOM_MODEL_OPTION, it must appear exactly once.
const originalEnv = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = "claude-opus-4-8[1m]";
try {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
availableModels: ["sonnet", "claude-opus-4-8[1m]"],
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQueryWithModels([
{ value: "default", displayName: "Default", description: "Default model" },
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
{
value: "claude-opus-4-8[1m]",
displayName: "Opus 4.8",
description: "Claude Opus 4.8",
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const modelOption = response.configOptions.find((o: any) => o.id === "model");
expect(modelOption.options.map((o: any) => o.value)).toEqual([
"default",
"sonnet",
"claude-opus-4-8[1m]",
]);
} finally {
if (originalEnv === undefined) {
delete process.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
} else {
process.env.ANTHROPIC_CUSTOM_MODEL_OPTION = originalEnv;
}
}
});
it("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 () => {
@@ -873,7 +657,7 @@ describe("ClaudeAcpAgent settings", () => {
const setModelSpy = vi.fn();
querySpy.mockImplementation(({ options: _options }: any) => {
return makeMockQuery({
return {
initializationResult: async () => ({
models: [
{
@@ -889,7 +673,8 @@ describe("ClaudeAcpAgent settings", () => {
],
}),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
const { ClaudeAcpAgent } = await import("../acp-agent.js");
@@ -902,9 +687,7 @@ describe("ClaudeAcpAgent settings", () => {
});
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-6-1m");
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
"claude-opus-4-6-1m",
);
expect(response.models.currentModelId).toBe("claude-opus-4-6-1m");
});
it("skips the initial setModel when the resolved value matches the SDK's model list verbatim", async () => {
@@ -920,7 +703,7 @@ describe("ClaudeAcpAgent settings", () => {
const setModelSpy = vi.fn();
querySpy.mockImplementation(() => {
return makeMockQuery({
return {
initializationResult: async () => ({
models: [
{ value: "default", displayName: "Default", description: "" },
@@ -929,7 +712,8 @@ describe("ClaudeAcpAgent settings", () => {
],
}),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
try {
@@ -943,9 +727,7 @@ describe("ClaudeAcpAgent settings", () => {
});
expect(setModelSpy).not.toHaveBeenCalled();
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
"claude-opus-4-6",
);
expect(response.models.currentModelId).toBe("claude-opus-4-6");
} finally {
if (originalEnv === undefined) {
delete process.env.ANTHROPIC_MODEL;
@@ -973,7 +755,7 @@ describe("ClaudeAcpAgent settings", () => {
const setModelSpy = vi.fn();
querySpy.mockImplementation(() => {
return makeMockQuery({
return {
initializationResult: async () => ({
models: [
{ value: "default", displayName: "Default", description: "" },
@@ -981,7 +763,8 @@ describe("ClaudeAcpAgent settings", () => {
],
}),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
const { ClaudeAcpAgent } = await import("../acp-agent.js");
@@ -994,8 +777,6 @@ describe("ClaudeAcpAgent settings", () => {
});
expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5");
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
"claude-haiku-4-5",
);
expect(response.models.currentModelId).toBe("claude-haiku-4-5");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,36 @@
import { mkdtemp, rm } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { SessionNotification } from "@agentclientprotocol/sdk";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let capturedOptions: Options | undefined;
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
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,
},
],
}),
});
},
};
});
)),
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* () {},
};
},
}));
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
registerHookCallback: vi.fn(),
@@ -39,7 +39,7 @@ vi.mock("../tools.js", async () => ({
describe("additionalRoots", () => {
let agent: ClaudeAcpAgentType;
const tempDirs: string[] = [];
const newSession = (meta: Record<string, unknown>, cwd = process.cwd()) =>
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
agent.newSession({ cwd, mcpServers: [], _meta: meta });
beforeEach(async () => {
@@ -52,7 +52,7 @@ describe("additionalRoots", () => {
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient);
} as unknown as AgentSideConnection);
});
afterEach(
@@ -79,7 +79,7 @@ describe("additionalRoots", () => {
it("prefers the official ACP additionalDirectories field over _meta.additionalRoots", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
additionalDirectories: ["/from/official"],
_meta: { additionalRoots: ["/from/meta"] },
@@ -89,7 +89,7 @@ describe("additionalRoots", () => {
it("merges official ACP additionalDirectories with claudeCode SDK additionalDirectories", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
additionalDirectories: ["/from/official"],
_meta: { claudeCode: { options: { additionalDirectories: ["/from/sdk"] } } },
@@ -99,7 +99,7 @@ describe("additionalRoots", () => {
it("falls back to _meta.additionalRoots when the official field is omitted", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: { additionalRoots: ["/from/meta"] },
});

View File

@@ -1,42 +0,0 @@
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);
});

View File

@@ -1,35 +1,27 @@
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { makeMockQuery } from "./helpers.js";
import { ClaudeAcpAgent } from "../acp-agent.js";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
const mockQuery = vi.hoisted(() => vi.fn());
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",
)),
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: mockQuery,
}));
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(() => {
@@ -44,7 +36,7 @@ describe("authorization", () => {
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
const connectionMock = {
sessionUpdate: async (_: any) => {},
} as AcpClient;
} as AgentSideConnection;
const agent = new ClaudeAcpAgent(connectionMock);
@@ -108,7 +100,7 @@ describe("authorization", () => {
});
await agent.newSession({
cwd: process.cwd(),
cwd: "testRoot",
mcpServers: [],
_meta: {
claudeCode: {
@@ -153,7 +145,7 @@ describe("authorization", () => {
});
await agent.newSession({
cwd: process.cwd(),
cwd: "testRoot",
mcpServers: [],
});

View File

@@ -1,23 +1,18 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
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";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
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 makeMockQuery({
return {
initializationResult: async () => ({
models: [
{
@@ -28,9 +23,11 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
},
],
}),
getContextUsage: () =>
contextUsageResult ? contextUsageResult() : Promise.resolve(DEFAULT_CONTEXT_USAGE),
});
setModel: async () => {},
setPermissionMode: async () => {},
supportedCommands: async () => [],
[Symbol.asyncIterator]: async function* () {},
};
},
};
});
@@ -47,18 +44,17 @@ describe("createSession options merging", () => {
let agent: ClaudeAcpAgentType;
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
function createMockClient(): AcpClient {
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient;
} as unknown as AgentSideConnection;
}
beforeEach(async () => {
capturedOptions = undefined;
contextUsageResult = undefined;
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
@@ -69,7 +65,7 @@ describe("createSession options merging", () => {
it("merges user-provided disallowedTools with ACP internal list", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
@@ -89,7 +85,7 @@ describe("createSession options merging", () => {
it("works when user provides no disallowedTools", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
});
@@ -98,7 +94,7 @@ describe("createSession options merging", () => {
it("works when user provides empty disallowedTools", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
@@ -114,7 +110,7 @@ describe("createSession options merging", () => {
it("sets tools to empty array when disableBuiltInTools is true", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
disableBuiltInTools: true,
@@ -137,7 +133,7 @@ describe("createSession options merging", () => {
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
@@ -159,7 +155,7 @@ describe("createSession options merging", () => {
it("inherits HOME and PATH from process.env when no env is provided", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
});
@@ -169,7 +165,7 @@ describe("createSession options merging", () => {
it("merges user-provided env vars on top of process.env", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
@@ -189,7 +185,7 @@ describe("createSession options merging", () => {
it("allows user-provided env vars to override process.env entries", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
@@ -207,7 +203,7 @@ describe("createSession options merging", () => {
it("defaults tools to claude_code preset when not provided", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
});
@@ -216,7 +212,7 @@ describe("createSession options merging", () => {
it("passes through user-provided tools string array", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
@@ -232,7 +228,7 @@ describe("createSession options merging", () => {
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
disableBuiltInTools: true,
@@ -249,7 +245,7 @@ describe("createSession options merging", () => {
it("passes through empty tools array to disable all built-in tools", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
@@ -263,41 +259,9 @@ 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: [] });
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.systemPrompt).toEqual({
type: "preset",
@@ -307,7 +271,7 @@ describe("createSession options merging", () => {
it("replaces the preset when a string is provided", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: { systemPrompt: "custom prompt" },
});
@@ -317,7 +281,7 @@ describe("createSession options merging", () => {
it("forwards append", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: { systemPrompt: { append: "extra instructions" } },
});
@@ -331,7 +295,7 @@ describe("createSession options merging", () => {
it("forwards excludeDynamicSections", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: { systemPrompt: { excludeDynamicSections: true } },
});
@@ -345,7 +309,7 @@ describe("createSession options merging", () => {
it("forwards append and excludeDynamicSections together", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
systemPrompt: {
@@ -365,7 +329,7 @@ describe("createSession options merging", () => {
it("ignores caller-provided type/preset overrides", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
systemPrompt: {
@@ -405,7 +369,7 @@ describe("createSession options merging", () => {
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.settings).toEqual({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
@@ -417,7 +381,7 @@ describe("createSession options merging", () => {
availableModels: ["opus", "sonnet"],
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.settings).toEqual({
availableModels: ["opus", "sonnet"],
@@ -430,7 +394,7 @@ describe("createSession options merging", () => {
availableModels: ["opus"],
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.settings).toEqual({
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
@@ -439,7 +403,7 @@ describe("createSession options merging", () => {
});
it("does not add settings when env var is not set", async () => {
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(capturedOptions!.settings).toBeUndefined();
});
@@ -450,7 +414,7 @@ describe("createSession options merging", () => {
});
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [],
_meta: {
claudeCode: {
@@ -474,13 +438,13 @@ describe("createSession options merging", () => {
it("throws on invalid JSON", async () => {
process.env.CLAUDE_MODEL_CONFIG = "not-json";
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).rejects.toThrow();
await expect(agent.newSession({ cwd: "/test", mcpServers: [] })).rejects.toThrow();
});
});
it("merges user-provided mcpServers with ACP mcpServers", async () => {
await agent.newSession({
cwd: process.cwd(),
cwd: "/test",
mcpServers: [
{
name: "acp-server",
@@ -509,382 +473,4 @@ describe("createSession options merging", () => {
// ACP-provided MCP server should also be present
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
});
describe("thinking config from MAX_THINKING_TOKENS", () => {
let originalMaxThinking: string | undefined;
beforeEach(() => {
originalMaxThinking = process.env.MAX_THINKING_TOKENS;
delete process.env.MAX_THINKING_TOKENS;
});
afterEach(() => {
if (originalMaxThinking !== undefined) {
process.env.MAX_THINKING_TOKENS = originalMaxThinking;
} else {
delete process.env.MAX_THINKING_TOKENS;
}
});
it("leaves thinking unset (SDK default) when env var is absent", async () => {
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.thinking).toBeUndefined();
// The deprecated option must not be set either.
expect(capturedOptions!.maxThinkingTokens).toBeUndefined();
});
it("maps 0 to disabled thinking", async () => {
process.env.MAX_THINKING_TOKENS = "0";
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.thinking).toEqual({ type: "disabled" });
});
it("maps a positive value to a fixed thinking budget", async () => {
process.env.MAX_THINKING_TOKENS = "12000";
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.thinking).toEqual({ type: "enabled", budgetTokens: 12000 });
});
it("ignores a non-numeric value", async () => {
process.env.MAX_THINKING_TOKENS = "lots";
// 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 () => {
process.env.MAX_THINKING_TOKENS = "12000";
await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: {
claudeCode: {
options: {
thinking: { type: "adaptive" },
},
},
},
});
expect(capturedOptions!.thinking).toEqual({ type: "adaptive" });
});
});
describe("cwd validation", () => {
it("rejects a relative cwd with invalidParams", async () => {
await expect(
agent.newSession({ cwd: "relative/path", mcpServers: [] }),
).rejects.toMatchObject({ code: RequestError.invalidParams().code });
});
it("rejects a non-existent cwd with invalidParams", async () => {
const missing = path.join(os.tmpdir(), "claude-acp-does-not-exist-xyz");
await expect(agent.newSession({ cwd: missing, mcpServers: [] })).rejects.toMatchObject({
code: RequestError.invalidParams().code,
});
});
it("rejects a cwd that points at a file with invalidParams", async () => {
const 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(dir, { recursive: true, force: true });
}
});
it("accepts an existing absolute directory", async () => {
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).resolves.toBeDefined();
});
});
describe("elicitation", () => {
it("keeps AskUserQuestion disabled and omits callbacks without elicitation capability", async () => {
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
expect(capturedOptions!.onElicitation).toBeUndefined();
});
it("enables AskUserQuestion and wires the elicitation callback when form is supported", async () => {
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { elicitation: { form: {} } },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
expect(typeof capturedOptions!.onElicitation).toBe("function");
});
it("wires callbacks for url-only elicitation but keeps AskUserQuestion disabled", async () => {
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { elicitation: { url: {} } },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
expect(typeof capturedOptions!.onElicitation).toBe("function");
});
it("still merges user-provided disallowedTools when AskUserQuestion is enabled", async () => {
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { elicitation: { form: {} } },
});
await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: { claudeCode: { options: { disallowedTools: ["WebSearch"] } } },
});
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
});
});
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 });
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"));
});
});
});

View File

@@ -1,516 +0,0 @@
import { describe, it, expect } from "vitest";
import type {
CreateElicitationRequest,
CreateElicitationResponse,
EnumOption,
} from "@agentclientprotocol/sdk";
import type { ElicitationRequest } from "@anthropic-ai/claude-agent-sdk";
import {
applyAskElicitationResponse,
askUserQuestionsToCreateRequest,
AskUserQuestion,
createElicitationResponseToElicitResult,
extractAskUserQuestions,
extractRefusalFallbackPrompt,
mcpElicitationToCreateRequest,
refusalFallbackResultFromResponse,
refusalFallbackToCreateRequest,
} from "../elicitation.js";
const SESSION_ID = "session-1";
/**
* Build a question matching the SDK's (strict) AskUserQuestion schema while
* keeping test sites terse. Option descriptions default to "" (the tool always
* provides one); pass a description to exercise the enum option's description.
*/
function mkQuestion(
question: string,
options: Array<{ label: string; description?: string; preview?: string }>,
opts: { header?: string; multiSelect?: boolean } = {},
): AskUserQuestion {
return {
question,
header: opts.header ?? "",
multiSelect: opts.multiSelect ?? false,
options: options.map((o) => ({
label: o.label,
description: o.description ?? "",
...(o.preview ? { preview: o.preview } : {}),
})),
} as AskUserQuestion;
}
describe("mcpElicitationToCreateRequest", () => {
it("maps a form-mode request, defaulting the schema type to object", () => {
const request: ElicitationRequest = {
serverName: "server",
message: "Need your name",
mode: "form",
requestedSchema: { properties: { name: { type: "string" } } },
};
const result = mcpElicitationToCreateRequest(request, SESSION_ID);
expect(result).toEqual({
mode: "form",
sessionId: SESSION_ID,
message: "Need your name",
requestedSchema: { type: "object", properties: { name: { type: "string" } } },
});
});
it("maps a url-mode request and preserves the elicitation id", () => {
const request: ElicitationRequest = {
serverName: "server",
message: "Authorize",
mode: "url",
url: "https://example.com/auth",
elicitationId: "el-123",
};
expect(mcpElicitationToCreateRequest(request, SESSION_ID)).toEqual({
mode: "url",
sessionId: SESSION_ID,
message: "Authorize",
url: "https://example.com/auth",
elicitationId: "el-123",
});
});
it("generates an elicitation id when a url request omits one", () => {
const request: ElicitationRequest = {
serverName: "server",
message: "Authorize",
mode: "url",
url: "https://example.com/auth",
};
const result = mcpElicitationToCreateRequest(request, SESSION_ID);
expect(result?.mode).toBe("url");
expect((result as { elicitationId: string }).elicitationId).toMatch(/.+/);
});
it("returns null for a url request with no url", () => {
const request: ElicitationRequest = {
serverName: "server",
message: "Authorize",
mode: "url",
};
expect(mcpElicitationToCreateRequest(request, SESSION_ID)).toBeNull();
});
});
describe("createElicitationResponseToElicitResult", () => {
it("maps accept with content", () => {
const response = { action: "accept", content: { name: "Ada" } } as CreateElicitationResponse;
expect(createElicitationResponseToElicitResult(response)).toEqual({
action: "accept",
content: { name: "Ada" },
});
});
it("defaults accept content to an empty object", () => {
const response = { action: "accept" } as CreateElicitationResponse;
expect(createElicitationResponseToElicitResult(response)).toEqual({
action: "accept",
content: {},
});
});
it("maps decline and cancel", () => {
expect(
createElicitationResponseToElicitResult({ action: "decline" } as CreateElicitationResponse),
).toEqual({ action: "decline" });
expect(
createElicitationResponseToElicitResult({ action: "cancel" } as CreateElicitationResponse),
).toEqual({ action: "cancel" });
});
it("maps a custom/future action to cancel", () => {
expect(
createElicitationResponseToElicitResult({
action: "_zed/snooze",
} as CreateElicitationResponse),
).toEqual({ action: "cancel" });
});
});
describe("extractAskUserQuestions", () => {
const question = mkQuestion("Which?", [{ label: "A" }, { label: "B" }]);
it("reads questions off the tool input", () => {
expect(extractAskUserQuestions({ questions: [question] })).toEqual([question]);
});
it("returns null when there are no questions", () => {
expect(extractAskUserQuestions({})).toBeNull();
expect(extractAskUserQuestions({ questions: [] })).toBeNull();
});
it("filters out malformed questions (missing options, empty options, bad question)", () => {
const input = {
questions: [
question,
{ question: "missing options" },
{ options: [] },
{ options: [], question: "empty" },
],
} as unknown as Record<string, unknown>;
expect(extractAskUserQuestions(input)).toEqual([question]);
});
it("returns null when every question is filtered out", () => {
const input = {
questions: [{ question: "no options" }, { options: [{ label: "A" }] }],
} as unknown as Record<string, unknown>;
expect(extractAskUserQuestions(input)).toBeNull();
});
});
describe("askUserQuestionsToCreateRequest", () => {
it("keys a single-select question by a stable id and carries the prompt in message only", () => {
const questions = [
mkQuestion(
"Which library?",
[{ label: "date-fns", description: "Lightweight" }, { label: "luxon" }],
{
header: "Library",
},
),
];
const result = askUserQuestionsToCreateRequest(questions, SESSION_ID, "tool-1");
expect(result).toMatchObject({
mode: "form",
sessionId: SESSION_ID,
toolCallId: "tool-1",
message: "Which library?",
});
const schema = (result as Extract<CreateElicitationRequest, { mode: "form" }>).requestedSchema;
// Nothing is required; the user can pick, type a custom answer, or skip.
expect(schema.required).toBeUndefined();
// Single question → no duplicated description (prompt is in `message`).
expect(schema.properties?.["question_0"]).toEqual({
type: "string",
title: "Library",
description: undefined,
oneOf: [
// 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" },
],
});
// The full question text is not used as a property key.
expect(schema.properties?.["Which library?"]).toBeUndefined();
});
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,
{ mode: "form" }
>
).requestedSchema;
// Each question gets its own "Other" box, not one shared form-level field.
expect(schema.properties?.["question_0_custom"]).toMatchObject({
type: "string",
title: "Other",
_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", () => {
const questions = [
mkQuestion("Pick one?", [{ label: "A" }, { label: "B" }]),
mkQuestion("Which features?", [{ label: "auth" }, { label: "logging" }], {
multiSelect: true,
}),
];
const result = askUserQuestionsToCreateRequest(questions, SESSION_ID, undefined);
const schema = (result as Extract<CreateElicitationRequest, { mode: "form" }>).requestedSchema;
expect(result).not.toHaveProperty("toolCallId");
// Multiple questions → generic message, each field carries its own question.
expect(result.message).toBe("Please answer the following questions.");
expect(schema.properties?.["question_1"]).toEqual({
type: "array",
title: undefined,
description: "Which features?",
items: {
anyOf: [
{ const: "auth", title: "auth" },
{ const: "logging", title: "logging" },
],
},
});
});
});
describe("applyAskElicitationResponse", () => {
const questions = [
mkQuestion("Single?", [{ label: "A" }, { label: "B" }]),
mkQuestion("Multi?", [{ label: "X" }, { label: "Y" }], { multiSelect: true }),
];
const toolInput = { questions, metadata: { source: "test" } };
it("writes selected labels (keyed by indexed fields) into updatedInput on accept", () => {
const response = {
action: "accept",
content: { question_0: "A", question_1: ["X", "Y"] },
} as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "answered",
updatedInput: {
questions,
metadata: { source: "test" },
answers: { "Single?": "A", "Multi?": "X, Y" },
},
});
});
it("folds a per-question custom answer into that question's answer", () => {
const response = {
action: "accept",
content: { question_0: "A", question_1_custom: "something else entirely" },
} as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "answered",
updatedInput: {
questions,
metadata: { source: "test" },
answers: { "Single?": "A", "Multi?": "something else entirely" },
},
});
});
it("prefers a question's custom answer over its selection", () => {
const response = {
action: "accept",
content: { question_0: "A", question_0_custom: " my own take " },
} as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "answered",
updatedInput: {
questions,
metadata: { source: "test" },
answers: { "Single?": "my own take" },
},
});
});
it("ignores empty selections and blank custom answers", () => {
const response = {
action: "accept",
content: { question_1: [], question_0_custom: " " },
} as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "answered",
updatedInput: { questions, metadata: { source: "test" }, answers: {} },
});
});
it("treats decline as answered with no answers", () => {
const response = { action: "decline" } as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "answered",
updatedInput: { questions, metadata: { source: "test" }, answers: {} },
});
});
it("returns cancel on cancel", () => {
const response = { action: "cancel" } as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "cancel",
});
});
it("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",
content: { question_0: "B" },
} as CreateElicitationResponse;
const result = applyAskElicitationResponse(response, toolInput, questions);
expect(result.action).toBe("answered");
expect((result as Extract<typeof result, { action: "answered" }>).updatedInput.answers).toEqual(
{ "Single?": "B" },
);
});
});
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");
}
});
});

View File

@@ -1,443 +0,0 @@
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);
});
});

View File

@@ -1,34 +0,0 @@
/**
* 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,
};
}

View File

@@ -1,272 +0,0 @@
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);
});

View File

@@ -1,303 +0,0 @@
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();
});
});

View File

@@ -29,13 +29,6 @@ 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");
});

View File

@@ -1,8 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { SessionNotification } from "@agentclientprotocol/sdk";
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { makeMockQuery } from "./helpers.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
const { registerHookCallbackSpy } = vi.hoisted(() => ({
registerHookCallbackSpy: vi.fn(),
@@ -85,7 +84,7 @@ describe("session config options", () => {
let setModelSpy: ReturnType<typeof vi.fn>;
let applyFlagSettingsSpy: ReturnType<typeof vi.fn>;
function createMockClient(): AcpClient {
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async (notification: SessionNotification) => {
sessionUpdates.push(notification);
@@ -93,7 +92,7 @@ describe("session config options", () => {
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient;
} as unknown as AgentSideConnection;
}
function populateSession() {
@@ -102,28 +101,29 @@ describe("session config options", () => {
applyFlagSettingsSpy = vi.fn();
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
query: makeMockQuery({
query: {
setPermissionMode: setPermissionModeSpy,
setModel: setModelSpy,
applyFlagSettings: applyFlagSettingsSpy,
}),
supportedCommands: async () => [],
},
input: null,
cancelled: false,
permissionMode: "default",
settingsManager: {},
modes: structuredClone(MOCK_MODES),
models: structuredClone(MOCK_MODELS),
modelInfos: MOCK_MODELS.availableModels.map((m): ModelInfo => ({
value: m.modelId,
displayName: m.name,
description: m.description,
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
})),
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(),
};
}
@@ -148,13 +148,13 @@ describe("session config options", () => {
describe("newSession returns configOptions", () => {
it("includes configOptions in the response", async () => {
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
expect(response.configOptions).toBeDefined();
expect(response.configOptions).toEqual(MOCK_CONFIG_OPTIONS);
});
it("includes mode and model config options", async () => {
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const response = await agent.newSession({ cwd: "/test", mcpServers: [] });
const modeOption = response.configOptions?.find((o) => o.id === "mode");
const modelOption = response.configOptions?.find((o) => o.id === "model");
expect(modeOption).toBeDefined();
@@ -173,7 +173,7 @@ describe("session config options", () => {
(agent as unknown as { loadSession: typeof loadSessionSpy }).loadSession = loadSessionSpy;
const response = await agent.loadSession({
cwd: process.cwd(),
cwd: "/test",
sessionId: SESSION_ID,
mcpServers: [],
});
@@ -216,31 +216,6 @@ 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,
@@ -402,30 +377,33 @@ describe("session config options", () => {
});
});
describe("setSessionConfigOption(model) returns updated configOptions", () => {
describe("unstable_setSessionModel sends config_option_update", () => {
beforeEach(() => {
populateSession();
});
it("returns configOptions with the new model when changed", async () => {
const response = await agent.setSessionConfigOption({
it("sends config_option_update when model is changed via setSessionModel", async () => {
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
modelId: "claude-sonnet-4-6",
});
expect(response.configOptions).toEqual(
expect.arrayContaining([
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
expect(configUpdate?.update).toMatchObject({
sessionUpdate: "config_option_update",
configOptions: expect.arrayContaining([
expect.objectContaining({ id: "model", currentValue: "claude-sonnet-4-6" }),
]),
);
});
});
it("updates stored configOptions currentValue when model changes", async () => {
await agent.setSessionConfigOption({
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
modelId: "claude-sonnet-4-6",
});
const session = (
@@ -437,7 +415,7 @@ describe("session config options", () => {
expect(modelOption?.currentValue).toBe("claude-sonnet-4-6");
});
it("drops effort from returned configOptions when model drops effort support", async () => {
it("includes updated effort in config_option_update when model drops effort support", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.modelInfos = [
{
@@ -455,18 +433,23 @@ describe("session config options", () => {
},
];
const response = await agent.setSessionConfigOption({
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
modelId: "claude-sonnet-4-6",
});
const effortOption = response.configOptions.find((o) => o.id === "effort");
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
const effortOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "effort",
);
expect(effortOption).toBeUndefined();
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
});
it("clamps effort in returned configOptions when new model has different supported levels", async () => {
it("clamps effort in config_option_update when new model has different supported levels", async () => {
// Set current effort to "max" which the new model won't support
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
@@ -489,31 +472,39 @@ describe("session config options", () => {
},
];
const response = await agent.setSessionConfigOption({
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
modelId: "claude-sonnet-4-6",
});
const effortOption = response.configOptions.find((o) => o.id === "effort");
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
const effortOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "effort",
);
expect(effortOption).toBeDefined();
expect(effortOption?.currentValue).toBe("default");
expect(effortOption.currentValue).toBe("default");
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
});
it("preserves effort in returned configOptions when new model supports same level", async () => {
it("preserves effort in config_option_update when new model supports same level", async () => {
// Set effort to "low" first
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
if (effortOpt) effortOpt.currentValue = "low";
const response = await agent.setSessionConfigOption({
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
modelId: "claude-sonnet-4-6",
});
const effortOption = response.configOptions.find((o) => o.id === "effort");
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
const effortOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "effort",
);
expect(effortOption?.currentValue).toBe("low");
// Effort didn't change, so applyFlagSettings should NOT be called
expect(applyFlagSettingsSpy).not.toHaveBeenCalled();
@@ -819,85 +810,6 @@ 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" });
@@ -909,11 +821,10 @@ describe("session config options", () => {
expect(session.configOptions.find((o) => o.id === "mode")?.currentValue).toBe("plan");
});
it("setSessionConfigOption(model) also syncs configOptions", async () => {
await agent.setSessionConfigOption({
it("setSessionModel also syncs configOptions", async () => {
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
modelId: "claude-sonnet-4-6",
});
const session = (
@@ -927,84 +838,6 @@ 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
@@ -1114,21 +947,58 @@ describe("session config options", () => {
it("drops `auto` from available modes when switching to Haiku", async () => {
setupHaikuOpusSession("default");
const response = await agent.setSessionConfigOption({
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
configId: "model",
value: "claude-haiku-4-5",
modelId: "claude-haiku-4-5",
});
const modeOption = response.configOptions.find((o) => o.id === "mode");
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
const modeOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "mode",
);
expect(modeOption).toBeDefined();
const modeValues = (modeOption as any).options.map((o: any) => o.value);
const modeValues = modeOption.options.map((o: any) => o.value);
expect(modeValues).not.toContain("auto");
expect(modeValues).toEqual(
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
);
});
it("clamps to `default` and emits current_mode_update when Opus(auto) → Haiku", async () => {
setupHaikuOpusSession("auto");
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
modelId: "claude-haiku-4-5",
});
// SDK was synced to "default".
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
// current_mode_update was emitted before config_option_update so a
// client applying notifications in order observes the mode change
// before re-rendering the config-option list.
const modeUpdateIdx = sessionUpdates.findIndex(
(n) => n.update.sessionUpdate === "current_mode_update",
);
const configUpdateIdx = sessionUpdates.findIndex(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(modeUpdateIdx).toBeGreaterThanOrEqual(0);
expect(configUpdateIdx).toBeGreaterThanOrEqual(0);
expect(modeUpdateIdx).toBeLessThan(configUpdateIdx);
expect((sessionUpdates[modeUpdateIdx].update as any).currentModeId).toBe("default");
// configOptions reflect the clamped mode.
const modeOption = (sessionUpdates[configUpdateIdx].update as any).configOptions.find(
(o: any) => o.id === "mode",
);
expect(modeOption.currentValue).toBe("default");
});
it("re-adds `auto` when switching from Haiku back to Opus", async () => {
const session = setupHaikuOpusSession("default");
// Pretend Haiku is the current model with no `auto`.
@@ -1143,15 +1013,19 @@ describe("session config options", () => {
description: m.description,
}));
const response = await agent.setSessionConfigOption({
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
configId: "model",
value: "claude-opus-4-5",
modelId: "claude-opus-4-5",
});
const modeOption = response.configOptions.find((o) => o.id === "mode");
expect(modeOption).toBeDefined();
const modeValues = (modeOption as any).options.map((o: any) => o.value);
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
expect(configUpdate).toBeDefined();
const modeOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "mode",
);
const modeValues = modeOption.options.map((o: any) => o.value);
expect(modeValues).toContain("auto");
// The current mode ("default") is still valid on Opus, so no
@@ -1165,10 +1039,9 @@ describe("session config options", () => {
it("preserves the current mode when it remains valid after a model switch", async () => {
setupHaikuOpusSession("plan");
const response = await agent.setSessionConfigOption({
await agent.unstable_setSessionModel({
sessionId: SESSION_ID,
configId: "model",
value: "claude-haiku-4-5",
modelId: "claude-haiku-4-5",
});
// `plan` is in availableModes for both Opus and Haiku, so no clamp.
@@ -1179,15 +1052,20 @@ describe("session config options", () => {
);
expect(modeUpdates).toHaveLength(0);
const modeOption = response.configOptions.find((o) => o.id === "mode");
expect((modeOption as any).currentValue).toBe("plan");
const configUpdate = sessionUpdates.find(
(n) => n.update.sessionUpdate === "config_option_update",
);
const modeOption = (configUpdate?.update as any).configOptions.find(
(o: any) => o.id === "mode",
);
expect(modeOption.currentValue).toBe("plan");
});
it("clamps mode and emits current_mode_update via setSessionConfigOption(model)", async () => {
// Switching Opus(auto) → Haiku clamps the mode to "default". The
// `current_mode_update` side effect must fire so clients learn about the
// clamp even though the request/response API returns the new
// configOptions rather than emitting a config_option_update.
// Mirrors the unstable_setSessionModel(auto → Haiku) test, but goes
// through the request/response API. The `current_mode_update` side
// effect must still fire so clients learn about the clamp regardless of
// which entry point triggered the model switch.
setupHaikuOpusSession("auto");
const response = await agent.setSessionConfigOption({
@@ -1270,10 +1148,6 @@ 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 {
@@ -1305,10 +1179,6 @@ 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(
@@ -1337,10 +1207,6 @@ 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 {

View File

@@ -1,20 +1,18 @@
import { describe, it, expect } from "vitest";
import { randomUUID } from "crypto";
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk";
import { AgentSideConnection, RequestError, SessionNotification } from "@agentclientprotocol/sdk";
import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
function createMockClient(
sessionUpdate: (notification: SessionNotification) => Promise<void> = async () => {},
): AcpClient {
function createMockClient(): AgentSideConnection {
return {
sessionUpdate,
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient;
} as unknown as AgentSideConnection;
}
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecycle", () => {
@@ -167,7 +165,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 AcpClient;
} as unknown as AgentSideConnection;
const commandName =
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>";
@@ -276,7 +274,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 AcpClient;
} as unknown as AgentSideConnection;
// Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579.
const concatenated =
@@ -332,52 +330,4 @@ 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);
});

View File

@@ -7,26 +7,13 @@ 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

View File

@@ -8,24 +8,18 @@ import {
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import {
AgentInput,
AgentOutput,
AskUserQuestionInput,
BashInput,
BashOutput,
FileEditInput,
FileReadInput,
FileReadOutput,
FileWriteInput,
GlobInput,
GrepInput,
ReportFindingsInput,
TaskCreateInput,
TaskCreateOutput,
TaskUpdateInput,
TodoWriteInput,
WebFetchInput,
WebSearchInput,
WebSearchOutput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import {
ImageBlockParam,
@@ -60,6 +54,7 @@ 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.
@@ -375,25 +370,6 @@ 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 {
@@ -439,24 +415,6 @@ export function toolInfoFromToolUse(
};
}
case "AskUserQuestion": {
const input = toolUse.input as Partial<AskUserQuestionInput> | undefined;
const questions = Array.isArray(input?.questions) ? input.questions : [];
return {
title:
questions.length === 1 && questions[0]?.question
? questions[0].question
: "Asking for your input",
kind: "other",
content: questions
.filter((q) => typeof q?.question === "string")
.map((q) => ({
type: "content" as const,
content: { type: "text" as const, text: q.question },
})),
};
}
case "Other": {
const input = toolUse.input;
let output;
@@ -489,88 +447,6 @@ 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
@@ -585,76 +461,19 @@ 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 &&
!(toolUse?.name === "Bash" && supportsTerminalOutput)
toolResult.content.length > 0
) {
// 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": {
// 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) },
},
],
};
}
case "Read":
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
@@ -682,77 +501,20 @@ export function toolUpdateFromToolResult(
};
}
return {};
}
case "Bash": {
const result = toolResult.content;
// 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 terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
const isError = "is_error" in toolResult && toolResult.is_error;
// Extract output and exit code from either format:
// 1. 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)
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
// 2. Plain string content from a regular tool_result
// 3. Array content (e.g. [{ type: "text", text: "..." }])
let output = "";
let exitCode = isError ? 1 : 0;
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 &&
@@ -763,30 +525,16 @@ export function toolUpdateFromToolResult(
exitCode = bashResult.return_code;
} else if (typeof result === "string") {
output = result;
} 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);
}
} 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");
}
// 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) {
if (supportsTerminalOutput) {
return {
content: [{ type: "terminal" as const, terminalId }],
_meta: {
@@ -822,45 +570,6 @@ 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 "Edit": // Edit is handled in hooks
case "Write": {
return {};
@@ -870,54 +579,15 @@ 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 rawContentUpdate();
return toAcpContentUpdate(
toolResult.content,
"is_error" in toolResult ? toolResult.is_error : false,
);
}
}
}
/** 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,
@@ -992,7 +662,7 @@ function toAcpContentBlock(content: ToolResultContent, isError: boolean): Conten
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
);
case "web_search_result":
return wrapText(formatWebSearchHit(content));
return wrapText(`${content.title} (${content.url})`);
case "web_search_tool_result_error":
return wrapText(`Error: ${content.error_code}`);
case "web_fetch_result":
@@ -1235,7 +905,12 @@ export const registerHookCallback = (
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
export const createPostToolUseHook =
(options?: { onEnterPlanMode?: () => Promise<void> }): HookCallback =>
(
logger: Logger = console,
options?: {
onEnterPlanMode?: () => Promise<void>;
},
): HookCallback =>
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
if (input.hook_event_name === "PostToolUse") {
// Handle EnterPlanMode tool - notify client of mode change after successful execution
@@ -1247,8 +922,11 @@ 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 };

View File

@@ -1,7 +1,13 @@
{
"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,