mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d421f56a6c | ||
|
|
d0fa8e0385 | ||
|
|
bbae5c2c09 | ||
|
|
becd854dd8 | ||
|
|
91f1699366 | ||
|
|
735950a83e | ||
|
|
543a9a2f97 | ||
|
|
8823ea6fcb | ||
|
|
dfe823b958 | ||
|
|
3d99cafe4a | ||
|
|
1a4512c711 | ||
|
|
b274923ef8 | ||
|
|
15ba608a79 | ||
|
|
d3205ab11f | ||
|
|
67c7c9b117 | ||
|
|
4deace4037 | ||
|
|
6b7473b1af | ||
|
|
c2e4815029 | ||
|
|
2b666c3d35 | ||
|
|
45f8b34020 | ||
|
|
cc528fb5e9 | ||
|
|
0dd58685b1 | ||
|
|
d870ebd2fd | ||
|
|
194e195b05 | ||
|
|
3e23c5b960 | ||
|
|
f5e79f55f8 | ||
|
|
a0122dae4c | ||
|
|
20817678f6 | ||
|
|
f74a51758d | ||
|
|
eb5ca9fd17 | ||
|
|
847a3b6429 | ||
|
|
da9f7e5695 | ||
|
|
ea7076c0bc | ||
|
|
b4e3aaa727 | ||
|
|
d3eff19157 | ||
|
|
a04d35496c | ||
|
|
9aaf0663bf | ||
|
|
889346fcf5 | ||
|
|
0cbbaf33d9 | ||
|
|
7c6610835f | ||
|
|
c3ff343884 | ||
|
|
14d192d108 | ||
|
|
8710ce1cbc | ||
|
|
fad4d10e46 | ||
|
|
caf609b56c | ||
|
|
996d488589 | ||
|
|
ee9f300db4 | ||
|
|
d334766ef9 | ||
|
|
d0aafb1ca2 | ||
|
|
50a95434e9 | ||
|
|
59a7e9367b | ||
|
|
450d6b19dc | ||
|
|
5de5d4a2c8 | ||
|
|
0581b9cf39 | ||
|
|
11dd73b871 | ||
|
|
e4dba808ea | ||
|
|
de0d0e2b7d | ||
|
|
35aaddb3d5 | ||
|
|
1f09e9a3ca | ||
|
|
ec73cd8560 | ||
|
|
8157ee113e | ||
|
|
3df1ede89f | ||
|
|
1afa940a2c | ||
|
|
6b405138fc | ||
|
|
8aaf608b4e | ||
|
|
f8fd3ab822 | ||
|
|
133337ffe5 | ||
|
|
8b31dea11b | ||
|
|
bba912728f | ||
|
|
7f27c47c5c | ||
|
|
6d608cb399 | ||
|
|
a84b81080a | ||
|
|
b965dd2191 | ||
|
|
0988438428 | ||
|
|
15b4eb46f3 | ||
|
|
fc95717b38 | ||
|
|
f9123f3e18 | ||
|
|
88da9d800f | ||
|
|
08e0e7f14e | ||
|
|
11a3193574 | ||
|
|
c98141201e | ||
|
|
76009f1ede | ||
|
|
fd60fc2b31 | ||
|
|
4302a4b0b6 | ||
|
|
faa04bd0ca | ||
|
|
0936ec281e | ||
|
|
9e50dfadee | ||
|
|
08a62ed537 | ||
|
|
e56f344691 | ||
|
|
43af4ec29e | ||
|
|
d7a65ce1d0 | ||
|
|
3f60921959 | ||
|
|
8e099e8442 | ||
|
|
15979bba79 | ||
|
|
8f67b6a92b | ||
|
|
5559ba890c | ||
|
|
d0604140f9 | ||
|
|
4c7b897183 | ||
|
|
8663170f18 | ||
|
|
53a0c36ce3 | ||
|
|
c91f943f86 | ||
|
|
5d35001563 | ||
|
|
51936049e1 | ||
|
|
14d06273c0 | ||
|
|
8cbaf97254 | ||
|
|
809d41c6b7 | ||
|
|
c19bddcf79 | ||
|
|
edf3af043b | ||
|
|
980ac8dc56 | ||
|
|
d9bd36d8b0 | ||
|
|
0a88de2a83 | ||
|
|
ff9b96d462 | ||
|
|
1887ada215 | ||
|
|
06c3d7bdbd | ||
|
|
92548f0435 | ||
|
|
cc5d5e8b80 | ||
|
|
82cd692e50 | ||
|
|
30b7c06f76 | ||
|
|
c7f5b8fe76 | ||
|
|
7a70f82739 | ||
|
|
e67dacdcac | ||
|
|
61ae8609d4 | ||
|
|
2c19974d5b | ||
|
|
c7dff3cf7e | ||
|
|
3ba2d367a2 | ||
|
|
f4e750decf | ||
|
|
9cd48c597a |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "npm"
|
||||
|
||||
48
.github/workflows/conventional-prs.yml
vendored
Normal file
48
.github/workflows/conventional-prs.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Conventional PRs
|
||||
|
||||
# Squash merges use the PR title as the commit subject, which is what
|
||||
# release-please parses to compute the next version and CHANGELOG entry.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# labeled/unlabeled so validation re-runs when `dependencies` is applied.
|
||||
types: [opened, reopened, edited, synchronize, labeled, unlabeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: "conventional-prs-${{ github.event.pull_request.number }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
# Config reference: https://github.com/amannn/action-semantic-pull-request#configuration
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
# Exactly the types release-please understands. feat/fix/perf/revert/docs
|
||||
# appear in the changelog; the rest are valid but hidden. Any other type
|
||||
# would be dropped silently, so it is rejected here instead.
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
perf
|
||||
revert
|
||||
docs
|
||||
style
|
||||
chore
|
||||
refactor
|
||||
test
|
||||
build
|
||||
ci
|
||||
# Dependabot titles itself conventionally already; don't block bot PRs.
|
||||
ignoreLabels: |
|
||||
dependencies
|
||||
228
.github/workflows/publish.yml
vendored
228
.github/workflows/publish.yml
vendored
@@ -3,9 +3,29 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Which channel to publish
|
||||
required: true
|
||||
default: stable
|
||||
type: choice
|
||||
options:
|
||||
- stable
|
||||
- preview
|
||||
ref:
|
||||
description: Tag or commit to publish
|
||||
required: true
|
||||
type: string
|
||||
publish_npm:
|
||||
description: Publish the package before updating the registry (stable only)
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
@@ -27,7 +47,11 @@ jobs:
|
||||
id: release
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
release-type: node
|
||||
# Do not add release-type here. The action ignores config-file whenever
|
||||
# release-type is set, which silently disables the pre-1.0 versioning
|
||||
# rules in release-please-config.json and lets a breaking change jump
|
||||
# straight to 1.0.0. The node release type is declared in that config
|
||||
# instead, so nothing is lost by leaving this out.
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
outputs:
|
||||
@@ -38,18 +62,212 @@ jobs:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }}
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
version: ${{ steps.publish.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
- name: Publish
|
||||
id: publish
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
npm publish
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Same shape as publish-npm. Tagging is a separate job, so nothing here needs
|
||||
# write access to the repository.
|
||||
publish-npm-preview:
|
||||
name: Publish preview to npm
|
||||
runs-on: ubuntu-latest
|
||||
# Every push to main gets a preview without waiting for CI or release-please,
|
||||
# except release-please's own release merge, which publishes a stable version.
|
||||
# Check the commit author and the generated release subject to avoid publishing
|
||||
# the same tree twice; either match is enough to skip the preview.
|
||||
if: >-
|
||||
${{
|
||||
(github.event_name == 'workflow_dispatch' && inputs.channel == 'preview') ||
|
||||
(github.event_name == 'push'
|
||||
&& github.event.head_commit.author.name != 'acp-release-bot[bot]'
|
||||
&& !startsWith(github.event.head_commit.message, 'chore(main): release '))
|
||||
}}
|
||||
# Same environment as the stable job, so the npm trusted-publisher binding holds.
|
||||
# It carries no required reviewers, so previews never wait for an approval.
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm OIDC trusted publishing
|
||||
timeout-minutes: 15
|
||||
# Serialise previews so two pushes cannot read the same registry state and compute
|
||||
# the same -preview.N. Job-level rather than workflow-level: a workflow-level group
|
||||
# would also serialise release-please, and cancelling a queued release-please run
|
||||
# means a release PR that silently stops updating. The trade-off is that GitHub
|
||||
# keeps only one run pending per group, so a third push landing while one preview
|
||||
# runs and another waits drops the waiting one — that commit gets no preview, but a
|
||||
# version is never reused.
|
||||
concurrency:
|
||||
group: publish-npm-preview
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
# Publish the exact pushed commit, or the explicitly requested manual ref.
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
# Brings the tags the version calculation reads as its floor. The repo is a
|
||||
# few megabytes packed, so a full fetch is cheap and sidesteps every
|
||||
# shallow-clone tag caveat.
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
# Ahead of the version bump, so this still validates the committed lockfile.
|
||||
- run: npm ci
|
||||
- name: Compute the preview version
|
||||
id: preview
|
||||
run: |
|
||||
version="$(node scripts/next-preview-version.mjs)"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Preview \`$version\` from \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Apply the version to the working tree only
|
||||
# Never committed: package.json and .release-please-manifest.json on main stay
|
||||
# release-please's to own, and this only changes what goes into the tarball.
|
||||
# `npm version` keeps package-lock.json's version fields in step, and
|
||||
# --no-git-tag-version skips every git operation.
|
||||
run: npm version "${{ steps.preview.outputs.version }}" --no-git-tag-version
|
||||
- run: npm run build
|
||||
- name: Publish
|
||||
id: publish
|
||||
# --tag is mandatory: npm publish defaults to `latest` even for a semver
|
||||
# prerelease, which would point every plain `npm install` at a preview.
|
||||
run: |
|
||||
npm publish --tag preview
|
||||
echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
outputs:
|
||||
published: ${{ steps.publish.outputs.published }}
|
||||
version: ${{ steps.preview.outputs.version }}
|
||||
sha: ${{ steps.preview.outputs.sha }}
|
||||
|
||||
publish-tag-preview:
|
||||
name: Tag the published preview
|
||||
runs-on: ubuntu-latest
|
||||
needs: publish-npm-preview
|
||||
if: ${{ needs.publish-npm-preview.outputs.published == 'true' }}
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write # create refs/tags/v<version>
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# A re-run carries over the outputs of jobs it did not re-run. If that ever stops
|
||||
# holding, fail with the manual recipe rather than tagging the wrong commit.
|
||||
- name: Check the publish handed over a version and a commit
|
||||
env:
|
||||
VERSION: ${{ needs.publish-npm-preview.outputs.version }}
|
||||
SHA: ${{ needs.publish-npm-preview.outputs.sha }}
|
||||
run: |
|
||||
if [ -z "$VERSION" ] || [ -z "$SHA" ]; then
|
||||
echo "::error::publish-npm-preview reported version='$VERSION' sha='$SHA'." \
|
||||
"Tag it by hand — see docs/RELEASES.md, 'A preview published but the" \
|
||||
"commit was not tagged'."
|
||||
exit 1
|
||||
fi
|
||||
- name: Create the tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.publish-npm-preview.outputs.version }}
|
||||
SHA: ${{ needs.publish-npm-preview.outputs.sha }}
|
||||
run: |
|
||||
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
||||
-f ref="refs/tags/v$VERSION" \
|
||||
-f sha="$SHA"
|
||||
echo "Tagged \`v$VERSION\` at \`$SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
trigger-registry-update:
|
||||
needs: [publish-npm, publish-npm-preview]
|
||||
# Both channels dispatch this: the registry has its own handling for preview
|
||||
# versions. The two never fire together — a release merge publishes stable and
|
||||
# skips the preview, and every other push does the reverse — so the registry sees
|
||||
# exactly one dispatch per published version. The payload is deliberately
|
||||
# unchanged: it names no version, and the registry resolves what it needs itself.
|
||||
if: >-
|
||||
${{
|
||||
always() && (
|
||||
needs.publish-npm.result == 'success'
|
||||
|| needs.publish-npm-preview.outputs.published == 'true'
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.publish_npm)
|
||||
)
|
||||
}}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions: {}
|
||||
# npm metadata is normally visible within about five minutes. Leave 2.5x that
|
||||
# window for propagation, plus enough time to mint the token and dispatch.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Wait for package to become available
|
||||
if: ${{ needs.publish-npm.result == 'success' || needs.publish-npm-preview.outputs.published == 'true' }}
|
||||
env:
|
||||
PACKAGE: "@agentclientprotocol/claude-agent-acp"
|
||||
VERSION: ${{ needs.publish-npm-preview.outputs.version || needs.publish-npm.outputs.version }}
|
||||
#language=bash
|
||||
run: |
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::The publish job did not report a package version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
deadline=$((SECONDS + 750))
|
||||
while (( SECONDS < deadline )); do
|
||||
if timeout 10s npm pack "$PACKAGE@$VERSION" \
|
||||
--dry-run \
|
||||
--ignore-scripts \
|
||||
--silent \
|
||||
--registry=https://registry.npmjs.org \
|
||||
--prefer-online \
|
||||
--fetch-retries=0 >/dev/null 2>&1; then
|
||||
echo "$PACKAGE@$VERSION is available from npm."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
remaining=$((deadline - SECONDS))
|
||||
if (( remaining > 0 )); then
|
||||
echo "Waiting for $PACKAGE@$VERSION to become available (${remaining}s remaining)..."
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
|
||||
echo "::error::$PACKAGE@$VERSION was not available from npm after 12.5 minutes."
|
||||
exit 1
|
||||
|
||||
- name: Generate token scoped to the registry repo
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
id: registry-token
|
||||
with:
|
||||
app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }}
|
||||
private-key: ${{ secrets.REGISTRY_UPDATER_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: registry
|
||||
|
||||
- name: Dispatch registry version update
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.registry-token.outputs.token }}
|
||||
run: |
|
||||
gh workflow run update-versions.yml \
|
||||
--repo ${{ github.repository_owner }}/registry \
|
||||
--ref main \
|
||||
-f apply=true \
|
||||
-f agents=claude-acp
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist
|
||||
.DS_Store
|
||||
.claude/*.local.*
|
||||
CLAUDE.local.md
|
||||
.idea/
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.58.1"
|
||||
".": "0.79.0"
|
||||
}
|
||||
|
||||
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 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
|
||||
|
||||
Stable 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.
|
||||
|
||||
Every _other_ push to `main` publishes a preview to npm — `0.73.1-preview.4` and
|
||||
so on — under the `preview` dist-tag, tags the commit it came from, and updates
|
||||
the agent registry the same way a release does. Only `latest` is reserved for
|
||||
real releases. So anything merged to `main` is published within minutes; there is
|
||||
no staging branch. Full runbook, including how to recover a stalled release:
|
||||
[`docs/RELEASES.md`](docs/RELEASES.md).
|
||||
279
CHANGELOG.md
279
CHANGELOG.md
@@ -1,5 +1,284 @@
|
||||
# Changelog
|
||||
|
||||
## [0.79.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.78.0...v0.79.0) (2026-09-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude agent sdk 0.3.274 ([#1143](https://github.com/agentclientprotocol/claude-agent-acp/issues/1143)) ([d0fa8e0](https://github.com/agentclientprotocol/claude-agent-acp/commit/d0fa8e03853c4f5a9b074fc2af32931ffe6f588f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* show shell commands in permission prompts ([#1070](https://github.com/agentclientprotocol/claude-agent-acp/issues/1070)) ([bbae5c2](https://github.com/agentclientprotocol/claude-agent-acp/commit/bbae5c2c0936554104b07630b142fd71fe1d839e))
|
||||
|
||||
## [0.78.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.77.0...v0.78.0) (2026-09-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add experimental ACP compaction update support ([#1134](https://github.com/agentclientprotocol/claude-agent-acp/issues/1134)) ([543a9a2](https://github.com/agentclientprotocol/claude-agent-acp/commit/543a9a2f97429659bb28d09691a71e4c99e41ed5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* keep the picked option when AskUserQuestion custom text is also supplied ([#1131](https://github.com/agentclientprotocol/claude-agent-acp/issues/1131)) ([8823ea6](https://github.com/agentclientprotocol/claude-agent-acp/commit/8823ea6fcb6743d37c1aad91ccd44725164b8927))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* report file changes from Claude checkpoints ([#1138](https://github.com/agentclientprotocol/claude-agent-acp/issues/1138)) ([91f1699](https://github.com/agentclientprotocol/claude-agent-acp/commit/91f1699366ee98f9b3e9a6b96f0662c40b6ee38e))
|
||||
* supply AIR diff counts from Claude structured patches ([#1122](https://github.com/agentclientprotocol/claude-agent-acp/issues/1122)) ([735950a](https://github.com/agentclientprotocol/claude-agent-acp/commit/735950a83ee1fa25366515a3239482183a051dca))
|
||||
|
||||
## [0.77.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.76.0...v0.77.0) (2026-09-14)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* claudeCode.options.agent is no longer forwarded to Claude Code, and the agent-picker exports DEFAULT_AGENT_ID, AGENT_CONFIG_ID, BUILTIN_AGENT_NAMES, and discoverCustomAgents are removed. Consumers relying on main-thread agent selection must stop passing that option.
|
||||
|
||||
### Features
|
||||
|
||||
* Add tool names to tool calls ([#1128](https://github.com/agentclientprotocol/claude-agent-acp/issues/1128)) ([67c7c9b](https://github.com/agentclientprotocol/claude-agent-acp/commit/67c7c9b117af7464fd5d583f719bf6214a5508a8))
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.270 ([#1123](https://github.com/agentclientprotocol/claude-agent-acp/issues/1123)) ([d3205ab](https://github.com/agentclientprotocol/claude-agent-acp/commit/d3205ab11f873f58e50dddbf13f6d7c555a9f478))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* keep multi-select answers when a custom answer is typed ([#1031](https://github.com/agentclientprotocol/claude-agent-acp/issues/1031)) ([1a4512c](https://github.com/agentclientprotocol/claude-agent-acp/commit/1a4512c71199709f6c59bd56c2c67aba63fe243d))
|
||||
* parse TaskList output without ambiguous regex ([#1006](https://github.com/agentclientprotocol/claude-agent-acp/issues/1006)) ([b274923](https://github.com/agentclientprotocol/claude-agent-acp/commit/b274923ef84a5a39b4ab3d12b3d10a8fa23f51f6))
|
||||
* strip injected system reminders from replayed prompts ([#1040](https://github.com/agentclientprotocol/claude-agent-acp/issues/1040)) ([15ba608](https://github.com/agentclientprotocol/claude-agent-acp/commit/15ba608a79b2cdfdb94c04ce1ebe03cfdae2fb51))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove agent config option ([#1112](https://github.com/agentclientprotocol/claude-agent-acp/issues/1112)) ([6b7473b](https://github.com/agentclientprotocol/claude-agent-acp/commit/6b7473b1afb48fbf0cb3b463d576a89a73ddf7a3))
|
||||
|
||||
## [0.76.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.75.1...v0.76.0) (2026-09-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* advertise recommended config values ([#1111](https://github.com/agentclientprotocol/claude-agent-acp/issues/1111)) ([2b666c3](https://github.com/agentclientprotocol/claude-agent-acp/commit/2b666c3d35566c60edafc5050d23cf2ce7ea1e95))
|
||||
* **deps-dev:** Bump vitest from 4.1.11 to 5.0.0 ([#1099](https://github.com/agentclientprotocol/claude-agent-acp/issues/1099)) ([194e195](https://github.com/agentclientprotocol/claude-agent-acp/commit/194e195b057497311558fea2a5fbd1b4953ea29d))
|
||||
* **deps:** Bump fast-uri from 3.1.6 to 3.1.7 ([#1100](https://github.com/agentclientprotocol/claude-agent-acp/issues/1100)) ([d870ebd](https://github.com/agentclientprotocol/claude-agent-acp/commit/d870ebd2fd14bda95f81ec4b86d76ef4ae258005))
|
||||
|
||||
## [0.75.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.75.0...v0.75.1) (2026-09-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* restore session forks and speed up loading ([#1089](https://github.com/agentclientprotocol/claude-agent-acp/issues/1089)) ([f5e79f5](https://github.com/agentclientprotocol/claude-agent-acp/commit/f5e79f55f8ba8d1cd0a72cbc78bfea726bea2f00))
|
||||
|
||||
## [0.75.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.74.0...v0.75.0) (2026-09-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** report the agent's auth identity over ACP (authStatus extension) ([#1080](https://github.com/agentclientprotocol/claude-agent-acp/issues/1080)) ([eb5ca9f](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb5ca9fd17b6af4a4566be4ad75b64588ce60b52))
|
||||
* render usage statistics as Markdown ([#1085](https://github.com/agentclientprotocol/claude-agent-acp/issues/1085)) ([2081767](https://github.com/agentclientprotocol/claude-agent-acp/commit/20817678f68083a2997d1bc45ef20241a9097220))
|
||||
* surface context compaction as an ACP tool lifecycle ([#991](https://github.com/agentclientprotocol/claude-agent-acp/issues/991)) ([f74a517](https://github.com/agentclientprotocol/claude-agent-acp/commit/f74a51758dc42896addbcdaf7611a29ec1d1db17))
|
||||
|
||||
## [0.74.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.73.0...v0.74.0) (2026-09-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** refuse claude.ai subscriptions under `--hide-claude-auth` ([#1079](https://github.com/agentclientprotocol/claude-agent-acp/issues/1079)) ([da9f7e5](https://github.com/agentclientprotocol/claude-agent-acp/commit/da9f7e56956c770bd1339151d6166a011fe39174))
|
||||
|
||||
## [0.73.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.72.0...v0.73.0) (2026-09-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1066](https://github.com/agentclientprotocol/claude-agent-acp/issues/1066)) ([b4e3aaa](https://github.com/agentclientprotocol/claude-agent-acp/commit/b4e3aaa727821a85cef6dcf51452381d35fd68a5))
|
||||
|
||||
## [0.72.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.71.0...v0.72.0) (2026-09-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Update to @anthropic-ai/claude-agent-sdk 0.3.252 ([#1062](https://github.com/agentclientprotocol/claude-agent-acp/issues/1062)) ([9aaf066](https://github.com/agentclientprotocol/claude-agent-acp/commit/9aaf0663bf48a4209e8e6511af395102c05e501e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adopt per-model effort settings and user_message_uuid result attribution ([#1065](https://github.com/agentclientprotocol/claude-agent-acp/issues/1065)) ([a04d354](https://github.com/agentclientprotocol/claude-agent-acp/commit/a04d35496c1f1b4c19d8f4204ee6377de45624ab))
|
||||
|
||||
## [0.71.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.70.0...v0.71.0) (2026-08-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ai generated session title ([#984](https://github.com/agentclientprotocol/claude-agent-acp/issues/984)) ([d334766](https://github.com/agentclientprotocol/claude-agent-acp/commit/d334766ef95dd89201979d42252e3d2a5a259cb9))
|
||||
* align Claude modes and clear-context planning ([#1004](https://github.com/agentclientprotocol/claude-agent-acp/issues/1004)) ([996d488](https://github.com/agentclientprotocol/claude-agent-acp/commit/996d488589b8db7a0f9af3dfc7b886d9d47ebae9))
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.238 ([#1018](https://github.com/agentclientprotocol/claude-agent-acp/issues/1018)) ([ee9f300](https://github.com/agentclientprotocol/claude-agent-acp/commit/ee9f300db4562e8b7958cec9fa63dab05a5c2eb0))
|
||||
* expose native subagents and async tasks ([#1017](https://github.com/agentclientprotocol/claude-agent-acp/issues/1017)) ([14d192d](https://github.com/agentclientprotocol/claude-agent-acp/commit/14d192d1087ea8662392b5c61c9fcdd496c5a179))
|
||||
* expose permission mode kinds ([#1025](https://github.com/agentclientprotocol/claude-agent-acp/issues/1025)) ([caf609b](https://github.com/agentclientprotocol/claude-agent-acp/commit/caf609b56c91f677ffe82b6e9d11d9e9dfd99d45))
|
||||
* report per-model token usage on prompt responses ([#1037](https://github.com/agentclientprotocol/claude-agent-acp/issues/1037)) ([fad4d10](https://github.com/agentclientprotocol/claude-agent-acp/commit/fad4d10e46c5e65fc6d426c98074bbb76067bf25))
|
||||
* support message-specific ACP session forks ([#1046](https://github.com/agentclientprotocol/claude-agent-acp/issues/1046)) ([c3ff343](https://github.com/agentclientprotocol/claude-agent-acp/commit/c3ff3438844f5249d6a7f5c297906e2cd3d5fa7f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* change min zod version to 4.x to avoid failing directory imports on 'v4' ([#1057](https://github.com/agentclientprotocol/claude-agent-acp/issues/1057)) ([7c66108](https://github.com/agentclientprotocol/claude-agent-acp/commit/7c6610835f26f18cd162b78dff74a7b7cd74497a))
|
||||
* defer steering while user input is pending ([#1045](https://github.com/agentclientprotocol/claude-agent-acp/issues/1045)) ([8710ce1](https://github.com/agentclientprotocol/claude-agent-acp/commit/8710ce1cbccf562cb04b4bcc30e053e960aee05f))
|
||||
|
||||
## [0.70.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.69.0...v0.70.0) (2026-08-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* switch providers for loaded Claude sessions ([#1002](https://github.com/agentclientprotocol/claude-agent-acp/issues/1002)) ([50a9543](https://github.com/agentclientprotocol/claude-agent-acp/commit/50a95434e94318456f2d07c3d21aaf3595c3407d))
|
||||
|
||||
## [0.69.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.68.0...v0.69.0) (2026-08-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* report changed files to AIR ([#1001](https://github.com/agentclientprotocol/claude-agent-acp/issues/1001)) ([450d6b1](https://github.com/agentclientprotocol/claude-agent-acp/commit/450d6b19dc46a041128356a6fa3cfa3ce6a5a382))
|
||||
|
||||
## [0.68.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.67.0...v0.68.0) (2026-08-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* align typed session failures with AIR protocol ([#992](https://github.com/agentclientprotocol/claude-agent-acp/issues/992)) ([0581b9c](https://github.com/agentclientprotocol/claude-agent-acp/commit/0581b9cf397ffd88f2830db721c2d8e3689045e4))
|
||||
|
||||
## [0.67.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.66.0...v0.67.0) (2026-08-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Update to @anthropic-ai/claude-agent-sdk v0.3.232 ([#993](https://github.com/agentclientprotocol/claude-agent-acp/issues/993)) ([de0d0e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/de0d0e2b7d185c521c3d1ac7b86e4312c919abfe))
|
||||
* expose typed session failures for AIR ([#979](https://github.com/agentclientprotocol/claude-agent-acp/issues/979)) ([8157ee1](https://github.com/agentclientprotocol/claude-agent-acp/commit/8157ee113e705750be4eb6cc787bdd12f1db84ff))
|
||||
* publish the model fallback as a warning advisory ([#990](https://github.com/agentclientprotocol/claude-agent-acp/issues/990)) ([35aaddb](https://github.com/agentclientprotocol/claude-agent-acp/commit/35aaddb3d5b14eca316582ab285af8a660150dae))
|
||||
* surface resolved model name in default model option description ([#982](https://github.com/agentclientprotocol/claude-agent-acp/issues/982)) ([ec73cd8](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec73cd8560be7d5e8b9741e404d7d45e17336996))
|
||||
* surface Skill tool calls with name and kind in _meta ([#986](https://github.com/agentclientprotocol/claude-agent-acp/issues/986)) ([1f09e9a](https://github.com/agentclientprotocol/claude-agent-acp/commit/1f09e9a3cae787b0effdfae0e7205ef7fe22b9dc))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preserve task plans across prompts ([#974](https://github.com/agentclientprotocol/claude-agent-acp/issues/974)) ([1afa940](https://github.com/agentclientprotocol/claude-agent-acp/commit/1afa940a2c8c0f4c610f4f64d30c0961642907b0))
|
||||
* show a pending title while Claude prepares a file ([#978](https://github.com/agentclientprotocol/claude-agent-acp/issues/978)) ([3df1ede](https://github.com/agentclientprotocol/claude-agent-acp/commit/3df1ede89f217312bc237124dc1eccc10c860f99))
|
||||
|
||||
## [0.66.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.65.0...v0.66.0) (2026-08-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** Bump globals from 17.8.0 to 17.9.0 in the minor group ([#960](https://github.com/agentclientprotocol/claude-agent-acp/issues/960)) ([7f27c47](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f27c47c5c7c49e65014e9f7dc55cba17352d33b))
|
||||
* expose provider-neutral ACP goal extension ([#964](https://github.com/agentclientprotocol/claude-agent-acp/issues/964)) ([8b31dea](https://github.com/agentclientprotocol/claude-agent-acp/commit/8b31dea11bed54f86c41217759159c415611346c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* publish and replace Claude goals reliably ([#967](https://github.com/agentclientprotocol/claude-agent-acp/issues/967)) ([f8fd3ab](https://github.com/agentclientprotocol/claude-agent-acp/commit/f8fd3ab8224420f8ced570e974cde09612939d6b))
|
||||
|
||||
## [0.65.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.64.2...v0.65.0) (2026-08-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** Bump nanoid from 3.3.16 to 3.3.17 ([#951](https://github.com/agentclientprotocol/claude-agent-acp/issues/951)) ([b965dd2](https://github.com/agentclientprotocol/claude-agent-acp/commit/b965dd21917e822b56f5012c3572902f26c065c9))
|
||||
* **deps-dev:** Bump tinyexec from 1.2.4 to 1.3.0 in the minor group ([#959](https://github.com/agentclientprotocol/claude-agent-acp/issues/959)) ([15b4eb4](https://github.com/agentclientprotocol/claude-agent-acp/commit/15b4eb46f329566837eae58f2ee4b05e3e81bf64))
|
||||
* **deps:** Bump @hono/node-server from 1.19.17 to 2.1.0 ([#956](https://github.com/agentclientprotocol/claude-agent-acp/issues/956)) ([f9123f3](https://github.com/agentclientprotocol/claude-agent-acp/commit/f9123f3e18560b580398aabf49e2190f69746976))
|
||||
* **deps:** Bump fast-uri from 3.1.4 to 3.1.5 ([#952](https://github.com/agentclientprotocol/claude-agent-acp/issues/952)) ([0988438](https://github.com/agentclientprotocol/claude-agent-acp/commit/098843842895dcb450746bd064dbb1509e3049d1))
|
||||
* **steering:** settle a steered turn at idle, not at the interrupt ([#958](https://github.com/agentclientprotocol/claude-agent-acp/issues/958)) ([a84b810](https://github.com/agentclientprotocol/claude-agent-acp/commit/a84b81080a4127edf40bc448fc8bf2b15503304d))
|
||||
|
||||
## [0.64.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.64.1...v0.64.2) (2026-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* restore the single-tool representation for ExitPlanMode ([#942](https://github.com/agentclientprotocol/claude-agent-acp/issues/942)) ([4302a4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/4302a4b0b6df821b164cbe4857f26cf5b44b532c))
|
||||
|
||||
## [0.64.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.64.0...v0.64.1) (2026-08-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release 0.65.0 ([#939](https://github.com/agentclientprotocol/claude-agent-acp/issues/939)) ([0936ec2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0936ec281ec730714c605e3da732069ff47d8969))
|
||||
|
||||
## [0.64.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.63.0...v0.64.0) (2026-07-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump actions/checkout from 7.0.0 to 7.0.1 ([#925](https://github.com/agentclientprotocol/claude-agent-acp/issues/925)) ([8e099e8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8e099e844254c3e91508c79a02e3e7dc2239fcbb))
|
||||
* **deps:** Bump the minor group with 7 updates ([#928](https://github.com/agentclientprotocol/claude-agent-acp/issues/928)) ([3f60921](https://github.com/agentclientprotocol/claude-agent-acp/commit/3f609219592e63b947539f79c696b3cedb421060))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **steering:** add opt-in host-owned fallback ([#919](https://github.com/agentclientprotocol/claude-agent-acp/issues/919)) ([43af4ec](https://github.com/agentclientprotocol/claude-agent-acp/commit/43af4ec29ea5396c2614813af05967bfb0b1bac8)), closes [#903](https://github.com/agentclientprotocol/claude-agent-acp/issues/903)
|
||||
|
||||
## [0.63.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.62.0...v0.63.0) (2026-07-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update to claude agent sdk v0.3.220 ([#921](https://github.com/agentclientprotocol/claude-agent-acp/issues/921)) ([4c7b897](https://github.com/agentclientprotocol/claude-agent-acp/commit/4c7b89718306229254879e5045a183233b5ed073))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Only resolve a denied tool call the client was told about ([#923](https://github.com/agentclientprotocol/claude-agent-acp/issues/923)) ([8f67b6a](https://github.com/agentclientprotocol/claude-agent-acp/commit/8f67b6a92bec24ae43b3dfbd087fe35df0531857)), closes [#918](https://github.com/agentclientprotocol/claude-agent-acp/issues/918)
|
||||
* Report tool_progress heartbeats against the tool call they describe ([#916](https://github.com/agentclientprotocol/claude-agent-acp/issues/916)) ([5559ba8](https://github.com/agentclientprotocol/claude-agent-acp/commit/5559ba890ca614cdaa189500aba65d81cc4cd51a))
|
||||
* **tools:** key Bash terminal metas off the announced tool_use id ([#917](https://github.com/agentclientprotocol/claude-agent-acp/issues/917)) ([d060414](https://github.com/agentclientprotocol/claude-agent-acp/commit/d0604140f907adbf9747f26a070690926e1de82d))
|
||||
|
||||
## [0.62.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.61.0...v0.62.0) (2026-07-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @hono/node-server from 1.19.14 to 1.19.15 ([#908](https://github.com/agentclientprotocol/claude-agent-acp/issues/908)) ([5193604](https://github.com/agentclientprotocol/claude-agent-acp/commit/51936049e175274de8e0fd90bc1be2af988c3ca6))
|
||||
* **deps:** Bump media-typer from 1.1.0 to 1.1.1 ([#909](https://github.com/agentclientprotocol/claude-agent-acp/issues/909)) ([5d35001](https://github.com/agentclientprotocol/claude-agent-acp/commit/5d35001563ffd702a29c3bac3b7ca134e40c77f8))
|
||||
* **deps:** Bump the minor group with 2 updates ([#900](https://github.com/agentclientprotocol/claude-agent-acp/issues/900)) ([809d41c](https://github.com/agentclientprotocol/claude-agent-acp/commit/809d41c6b7c9e7ba3cb5b206d00793a70edba64a))
|
||||
* **deps:** Bump the minor group with 2 updates ([#907](https://github.com/agentclientprotocol/claude-agent-acp/issues/907)) ([14d0627](https://github.com/agentclientprotocol/claude-agent-acp/commit/14d06273c01ad4ae944912e36700f6b5599c4c4d))
|
||||
* Update to claude-agent-sdk 0.3.218 ([#904](https://github.com/agentclientprotocol/claude-agent-acp/issues/904)) ([8cbaf97](https://github.com/agentclientprotocol/claude-agent-acp/commit/8cbaf97254576089a3b5ee6ae222fb763003c01d))
|
||||
|
||||
## [0.61.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.60.0...v0.61.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump actions/setup-node from 6.4.0 to 7.0.0 ([#897](https://github.com/agentclientprotocol/claude-agent-acp/issues/897)) ([d9bd36d](https://github.com/agentclientprotocol/claude-agent-acp/commit/d9bd36d8b06764d63656d0387ad9430ec9fcb27d))
|
||||
* **deps:** Update to @anthropic-ai/claude-agent-sdk 0.3.217 ([#899](https://github.com/agentclientprotocol/claude-agent-acp/issues/899)) ([edf3af0](https://github.com/agentclientprotocol/claude-agent-acp/commit/edf3af043b6d00e5caca2cc81a2285c477c8b2ab))
|
||||
|
||||
## [0.60.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.59.0...v0.60.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Update to claude-agent-sdk 0.3.215 ([#890](https://github.com/agentclientprotocol/claude-agent-acp/issues/890)) ([92548f0](https://github.com/agentclientprotocol/claude-agent-acp/commit/92548f043547b0ddac95921ece020e69f7c12c5f))
|
||||
* implement configurable LLM providers ([#853](https://github.com/agentclientprotocol/claude-agent-acp/issues/853)) ([82cd692](https://github.com/agentclientprotocol/claude-agent-acp/commit/82cd692e500eedec182f817b18cebb005c4b98ce))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* parse Agent/Task trailers without regex ([#879](https://github.com/agentclientprotocol/claude-agent-acp/issues/879)) ([06c3d7b](https://github.com/agentclientprotocol/claude-agent-acp/commit/06c3d7bdbd8cc9415c8cabac060a50e0951c758b))
|
||||
* remove ~15s stall on session/new and model switch by seeding the context window synchronously ([#894](https://github.com/agentclientprotocol/claude-agent-acp/issues/894)) ([ff9b96d](https://github.com/agentclientprotocol/claude-agent-acp/commit/ff9b96d462831b1c3b96722ea20215ff6e529cb1))
|
||||
* Silence missing PostToolUse callbacks ([#895](https://github.com/agentclientprotocol/claude-agent-acp/issues/895)) ([1887ada](https://github.com/agentclientprotocol/claude-agent-acp/commit/1887ada215b27bb1025d9b7696a46ae7a4ac0f7a)), closes [#889](https://github.com/agentclientprotocol/claude-agent-acp/issues/889)
|
||||
|
||||
## [0.59.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.58.1...v0.59.0) (2026-07-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps-dev:** bump nanoid from 3.3.15 to 3.3.16 ([#875](https://github.com/agentclientprotocol/claude-agent-acp/issues/875)) ([e67dacd](https://github.com/agentclientprotocol/claude-agent-acp/commit/e67dacdcac629b00e5bdcda94b3de57459696b1e))
|
||||
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.207 ([#874](https://github.com/agentclientprotocol/claude-agent-acp/issues/874)) ([c7f5b8f](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7f5b8fe768507afa28cdffe5cc3c054c56306ad))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add subagent parent tool use attribution ([#859](https://github.com/agentclientprotocol/claude-agent-acp/issues/859)) ([9cd48c5](https://github.com/agentclientprotocol/claude-agent-acp/commit/9cd48c597af68f630774ec00bc3adc85f6b0fd4b))
|
||||
* forward result text when the turn emitted no assistant message ([#858](https://github.com/agentclientprotocol/claude-agent-acp/issues/858)) ([61ae860](https://github.com/agentclientprotocol/claude-agent-acp/commit/61ae8609d4343da6758c2376c6cf684c7fb0956a))
|
||||
* hold a turn open while its background subagents are still live ([#870](https://github.com/agentclientprotocol/claude-agent-acp/issues/870)) ([7a70f82](https://github.com/agentclientprotocol/claude-agent-acp/commit/7a70f82739e085014cad878f08513cdef7b7fe16))
|
||||
* Refine tool calls from streamed input ([#867](https://github.com/agentclientprotocol/claude-agent-acp/issues/867)) ([2c19974](https://github.com/agentclientprotocol/claude-agent-acp/commit/2c19974d5b80d3178b35e80a4bb0a1050588aab9))
|
||||
* Seed context window from SDK usage report ([#868](https://github.com/agentclientprotocol/claude-agent-acp/issues/868)) ([3ba2d36](https://github.com/agentclientprotocol/claude-agent-acp/commit/3ba2d367a282fe3053a73804a416a435cb01ee08)), closes [#596](https://github.com/agentclientprotocol/claude-agent-acp/issues/596)
|
||||
* Skip synthetic login messages on replay ([#869](https://github.com/agentclientprotocol/claude-agent-acp/issues/869)) ([c7dff3c](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7dff3cf7eeb03cb993493efafa96344f18de81b)), closes [#863](https://github.com/agentclientprotocol/claude-agent-acp/issues/863)
|
||||
|
||||
## [0.58.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.58.0...v0.58.1) (2026-07-09)
|
||||
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -12,12 +12,36 @@ 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)
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/session-failure-extension.md)
|
||||
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/recommended-config-values-extension.md)
|
||||
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/permission-extension.md)
|
||||
|
||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
To try changes that have landed on `main` but are not released yet, install from the
|
||||
`preview` channel — every push to `main` publishes one. See
|
||||
[`docs/RELEASES.md`](docs/RELEASES.md#preview-releases).
|
||||
|
||||
```sh
|
||||
npm install @agentclientprotocol/claude-agent-acp@preview
|
||||
```
|
||||
|
||||
### Subagent sessions
|
||||
|
||||
Subagents are exposed only after bilateral capability negotiation. Until the released ACP SDKs
|
||||
preserve the draft `clientCapabilities.subagents` field, a supporting client may advertise
|
||||
`nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; the adapter mirrors the capability
|
||||
in its initialize response. The canonical field remains supported and takes precedence once it is
|
||||
available. Without either client signal, Agent/Task lifecycle keeps its legacy ordinary ACP
|
||||
tool-call representation and child interactions stay on the root session. Clients that use the
|
||||
historical `_meta["subagent-transcript"]` capability or `forwardSubagentText` session option retain
|
||||
the flattened child transcript behavior.
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
This project does not require a Contributor License Agreement (CLA). Instead, contributions are accepted under the following terms:
|
||||
|
||||
243
docs/RELEASES.md
243
docs/RELEASES.md
@@ -1,24 +1,245 @@
|
||||
## How to publish a new release
|
||||
# Releases
|
||||
|
||||
1. Create a new branch prep-vX.X.X
|
||||
Releases are automated by [release-please](https://github.com/googleapis/release-please)
|
||||
in [`.github/workflows/publish.yml`](../.github/workflows/publish.yml). Every push
|
||||
to `main` re-computes the next version from the conventional commit messages and
|
||||
keeps a single release PR open, titled `chore(main): release X.Y.Z` and labelled
|
||||
`autorelease: pending`.
|
||||
|
||||
Merging that PR is what releases. It tags `vX.Y.Z`, creates the GitHub release,
|
||||
publishes to npm, and dispatches a version update to the agent registry.
|
||||
|
||||
There is no manual release button, and versions are never typed in by hand: the
|
||||
version is an output of the commit history, not an input.
|
||||
|
||||
Every _other_ push to `main` publishes a preview instead — see
|
||||
[Preview releases](#preview-releases). Anything merged to `main` is on npm within
|
||||
minutes; there is no staging branch.
|
||||
|
||||
## Releasing
|
||||
|
||||
```sh
|
||||
git checkout -b prep-vX.X.X
|
||||
npm run release:preflight
|
||||
```
|
||||
|
||||
2. Bump the version in package.json
|
||||
This reports the open release PR, the version it will ship, and checks that the
|
||||
repository is in a state where merging is safe. Nothing has to be remembered —
|
||||
if it exits non-zero, follow what it prints instead of merging.
|
||||
|
||||
Then merge it, using the PR number the preflight printed:
|
||||
|
||||
```sh
|
||||
npm version vX.X.X
|
||||
gh pr merge <pr-number> --squash
|
||||
gh run watch "$(gh run list --workflow=publish.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
|
||||
```
|
||||
|
||||
3. Add a new entry in CHANGELOG.md
|
||||
4. Create a PR for `prev-vX.X.X` & merge it
|
||||
5. Create tag and push it
|
||||
The run is looked up rather than picked interactively, so this is safe to script.
|
||||
If the workflow has already finished, `gh run list --workflow=publish.yml` shows
|
||||
the outcome instead.
|
||||
|
||||
Merging main requires no review, so a green preflight and `Build` are the only
|
||||
gates. Once the workflow finishes, confirm both outputs landed:
|
||||
|
||||
```sh
|
||||
git tag vX.X.X
|
||||
git push origin vX.X.X
|
||||
gh release view "v<version>"
|
||||
npm view "@agentclientprotocol/claude-agent-acp@<version>"
|
||||
```
|
||||
|
||||
6. Paste CHANGELOG into release notes and publish the version on GitHub
|
||||
## Preview releases
|
||||
|
||||
Every push to `main` that is not a release merge publishes a preview from the
|
||||
same workflow. There is no GitHub release — only an npm publish under the
|
||||
`preview` dist-tag, a `v<version>` tag on the commit it came from, and the same
|
||||
agent registry update a stable release dispatches, since the registry has its own
|
||||
handling for preview versions.
|
||||
|
||||
Those are three jobs, in that order: `publish-npm-preview` mirrors `publish-npm`
|
||||
and does nothing but publish; `publish-tag-preview` creates the tag; and
|
||||
`trigger-registry-update` is shared with the stable path. Before dispatching,
|
||||
the registry job polls npm for the exact published version for up to 12.5 minutes,
|
||||
including downloading its tarball, so the registry never checks while npm is
|
||||
still propagating the package. The tag is a separate job so that a tag failure
|
||||
can be retried on its own with **Re-run failed jobs** — re-running the publish is
|
||||
not an option, because npm versions are immutable and publishing the same one
|
||||
twice fails outright.
|
||||
|
||||
Both downstream jobs are gated on a `published` output that the publish step
|
||||
sets only after `npm publish --tag preview` succeeds. This ensures tagging runs
|
||||
only after publication and the registry update runs only after that exact version
|
||||
can also be downloaded from npm.
|
||||
|
||||
A stable and a preview dispatch can never collide — a release merge publishes
|
||||
stable and skips the preview, every other push does the reverse — so the registry
|
||||
sees exactly one dispatch per published version.
|
||||
|
||||
```sh
|
||||
npm install @agentclientprotocol/claude-agent-acp@preview
|
||||
npm view @agentclientprotocol/claude-agent-acp dist-tags
|
||||
git ls-remote --tags origin 'refs/tags/*preview*'
|
||||
```
|
||||
|
||||
The version is the `package.json` version with the patch incremented, plus
|
||||
`-preview.N`: with `main` at 0.73.0 the previews are `0.73.1-preview.1`,
|
||||
`0.73.1-preview.2`, and so on. `N` restarts at 1 whenever release-please moves
|
||||
`package.json`, which keeps the sequence monotonic whichever way the next release
|
||||
goes — a patch release makes the next base 0.73.2, a minor makes it 0.74.1, and
|
||||
both sort above every `0.73.1-preview.*`.
|
||||
|
||||
`0.73.1-preview.4` is **not** a promise that 0.73.1 will ship. The base is a
|
||||
patch bump because that is the only choice depending solely on `package.json`,
|
||||
which release-please only ever increases. Using release-please's predicted next
|
||||
version would read better but that prediction moves mid-flight: a `fix:` opens a
|
||||
0.73.1 release PR, a later `feat:` moves it to 0.74.0, and `N` would reset under
|
||||
previews that were already published.
|
||||
|
||||
`N` comes from [`scripts/next-preview-version.mjs`](../scripts/next-preview-version.mjs),
|
||||
which takes the larger of two sources. The npm registry says what is taken — npm
|
||||
versions are immutable and stay reserved even after `npm unpublish`, so reusing
|
||||
one is a hard failure — but it is CDN-served and can lag a publish by minutes.
|
||||
The git tags this job writes are strongly consistent and cover that window. The
|
||||
job publishes before it tags, so a version can exist on npm without a tag but
|
||||
never the reverse; that is why a registry read failure aborts the run rather than
|
||||
falling back to the tags alone.
|
||||
|
||||
Two pushes landing together cannot collide, because the job takes a concurrency
|
||||
group. GitHub keeps only one run pending per group, so a third push arriving
|
||||
while one preview runs and another waits drops the waiting one — that commit
|
||||
simply gets no preview.
|
||||
|
||||
`latest` stays put because the job passes `npm publish --tag preview`. Without
|
||||
it npm would move `latest` onto the preview: `--tag` defaults to `latest` even
|
||||
for a semver prerelease. Right after a release the `preview` dist-tag can name a
|
||||
version _below_ `latest` until the next push lands; that is cosmetic.
|
||||
|
||||
Release merges are excluded by checking the head commit's author and the subject
|
||||
release-please generates. Both are checked, either is enough, and the cost of a
|
||||
miss is one wasted version number plus a `preview` tag briefly pointing at
|
||||
already-released code — `latest` is untouched. Previews start directly on pushes
|
||||
to `main`, without waiting for CI or the `release-please` job. The commit checks
|
||||
let previews run independently of release-please's outputs.
|
||||
|
||||
To publish a preview by hand from any commit:
|
||||
|
||||
```sh
|
||||
gh workflow run publish.yml --ref main \
|
||||
-f channel=preview -f ref=<commit-or-branch> -f publish_npm=false
|
||||
```
|
||||
|
||||
`--ref main` is required: the `release` environment only accepts protected
|
||||
branches, so a dispatch from anywhere else is rejected before the job starts.
|
||||
|
||||
## 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.
|
||||
|
||||
`release-type` also switches release-please from `Manifest.fromManifest` to
|
||||
`Manifest.fromConfig`, which is a second and sharper reason never to set it. On
|
||||
the manifest path the previous release is found by an exact string match against
|
||||
the version in [`.release-please-manifest.json`](../.release-please-manifest.json),
|
||||
which is why the `v<x>-preview.<n>` tags are invisible to it. On the config path
|
||||
release-please instead sorts every candidate tag and release descending and takes
|
||||
the highest — and there the preview tags _would_ be candidates.
|
||||
|
||||
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.
|
||||
|
||||
### A preview published but the commit was not tagged
|
||||
|
||||
Only the publish is irreversible, so re-run just the tag job:
|
||||
|
||||
```sh
|
||||
gh run rerun <run-id> --failed
|
||||
```
|
||||
|
||||
Or **Re-run failed jobs** on the run in the web or mobile UI. This re-runs
|
||||
`publish-tag-preview` alone and leaves the successful publish untouched, which
|
||||
matters because re-publishing an immutable npm version would fail.
|
||||
|
||||
If the re-run reports that it received no version or commit, the run's carried
|
||||
over job outputs are gone and it cannot tag anything safely. Do it by hand
|
||||
instead, taking the version from the publish job's log:
|
||||
|
||||
```sh
|
||||
gh api "repos/$(gh repo view --json nameWithOwner --jq .nameWithOwner)/git/refs" \
|
||||
-f ref="refs/tags/v<version>" -f sha="<commit-sha>"
|
||||
```
|
||||
|
||||
Either way nothing is broken in the meantime: the next preview still picks the
|
||||
right `N` once the registry CDN catches up. The tag is how that number is known
|
||||
immediately.
|
||||
|
||||
## 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.
|
||||
|
||||
npm binds a trusted publisher to one repository, one **workflow filename** and
|
||||
one environment, and a package may only have one such binding. That is why
|
||||
preview publishing is another job inside `publish.yml` rather than a workflow of
|
||||
its own: a separate file would fail to authenticate, and registering it would
|
||||
cost the stable path its publisher.
|
||||
|
||||
30
docs/diff-statistics-extension.md
Normal file
30
docs/diff-statistics-extension.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# AIR diff statistics
|
||||
|
||||
The adapter supplies counts in each ACP diff block through `_meta.jetbrains.air.diffStats`.
|
||||
This uses the same contract as the Codex adapter. No capability negotiation is required.
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"diffStats": { "version": 1, "added": 20, "removed": 30 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both counts are nonnegative integers. They describe the operations in that block's patch.
|
||||
Context lines and EOF markers do not increase the counts.
|
||||
The extension carries no navigation position.
|
||||
|
||||
For `Edit` and `Write`, the adapter counts operations while it builds diff texts from the SDK's `structuredPatch`.
|
||||
Each hunk produces one block with its own counts. The adapter does not compare or scan the full file texts for statistics.
|
||||
|
||||
When a patch has unsafe, unordered, or mutually inconsistent coordinates, inconsistent line counts,
|
||||
misplaced EOF markers, or unsupported prefixes, the adapter omits the statistics.
|
||||
Empty patches, initial tool inputs, and history without structured patches retain their existing behavior.
|
||||
They provide no statistics because the adapter has no patch operations to count.
|
||||
AIR uses its existing comparison when statistics are absent.
|
||||
57
docs/goal-extension.md
Normal file
57
docs/goal-extension.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Goal extension
|
||||
|
||||
This document defines a provider-neutral experimental ACP extension implemented by `claude-agent-acp`. It is intentionally shaped like a possible future first-class ACP API: implementations publish `_meta.goal`, not provider-specific metadata such as `_meta.claudeCode.goal`.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
An agent advertises support in its `initialize` response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"goal": {
|
||||
"version": 1,
|
||||
"controlMethod": "_session/goal",
|
||||
"actions": ["clear"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; a future version may add action-specific fields such as an objective for `set`.
|
||||
|
||||
## Session state
|
||||
|
||||
The current snapshot is published in `session_info_update._meta.goal`. Clearing a goal publishes `goal: null`.
|
||||
|
||||
```json
|
||||
{
|
||||
"objective": "Ship the change",
|
||||
"status": "active",
|
||||
"iterations": 3,
|
||||
"lastReason": "Tests still need work",
|
||||
"createdAt": 1710000000123,
|
||||
"controlMethod": "_session/goal"
|
||||
}
|
||||
```
|
||||
|
||||
Common statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. Optional fields allow implementations to report budgets, usage, iteration count, and the last continuation reason. Timestamps are Unix milliseconds.
|
||||
|
||||
## Lifecycle architecture
|
||||
|
||||
A goal belongs to the ACP session, not to an individual `session/prompt` request. Goal activity and prompt activity are independent:
|
||||
|
||||
- `status: active` means the persistent objective can drive more work; it does not mean an ACP prompt is currently executing.
|
||||
- A prompt completes when its current backend turn reaches a quiescent boundary, even when the goal remains active.
|
||||
- A later autonomous cycle may publish more session updates outside that completed prompt.
|
||||
- While a turn is running, clients use steering or prompt queueing when advertised. While the session is quiescent, clients may send an ordinary `session/prompt`.
|
||||
|
||||
This separation prevents a persistent goal from monopolizing the session's prompt slot and lets clients model “working now” independently from “objective remains active.”
|
||||
|
||||
## Claude mapping
|
||||
|
||||
Claude's session-scoped `/goal` command installs a Stop hook. The runtime emits internal `active_goal` messages when that hook updates or clears its state; the adapter maps them into the neutral snapshot before the exhaustive SDK message switch. `condition`, `iterations`, `set_at`, and `last_reason` become `objective`, `iterations`, `createdAt`, and `lastReason`. Provider-only bookkeeping such as `tokens_at_start` is not exposed.
|
||||
|
||||
Some Claude runtime versions persist the goal and begin the Stop-hook workflow without emitting the initial `active_goal` message on the SDK stream. To avoid leaving clients without visible state for the whole long-running prompt, the adapter publishes a minimal active snapshot as soon as it submits `/goal <condition>`, and publishes `goal: null` for `/goal clear`. While that command is pending, late updates for the previous goal are ignored. A matching runtime `active_goal` remains authoritative and enriches the optimistic snapshot with iteration, reason, and timestamp fields; a failed command restores the previously published state.
|
||||
|
||||
`set` and `clear` are advertised for out-of-band control; pause and resume are not. When a goal turn is running, the adapter injects the corresponding `/goal` command at steering priority `now` so it cannot remain queued behind the Stop-hook workflow; on an idle session it uses the ordinary prompt lifecycle. Cancelling a turn does not clear the session-scoped goal: clients use the goal control method when they intend to replace or remove it.
|
||||
253
docs/permission-extension.md
Normal file
253
docs/permission-extension.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Permission extension
|
||||
|
||||
This document defines the experimental permission presentation extension implemented by
|
||||
`claude-agent-acp`. The adapter uses standard ACP permission requests and responses, while `_meta`
|
||||
adds compact request presentation text.
|
||||
|
||||
The extension does not replace ACP permissions. `RequestPermissionRequest.toolCall`, `options`,
|
||||
`RequestPermissionResponse.outcome`, and each option's standard `kind` remain authoritative. A client
|
||||
that does not understand the extension can ignore `_meta` and render the ordinary ACP request.
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
When the Claude Agent SDK invokes `canUseTool`, the adapter:
|
||||
|
||||
1. stops immediately if the tool-call signal is already aborted;
|
||||
2. ensures the referenced ACP `tool_call` has been emitted;
|
||||
3. validates and snapshots any SDK `suggestions: PermissionUpdate[]`;
|
||||
4. builds a tool-specific presentation and ordered option list;
|
||||
5. sends `session/request_permission` with the tool-call cancellation signal;
|
||||
6. validates that the selected option was offered in the exact request;
|
||||
7. maps the selection to a Claude SDK `PermissionResult`.
|
||||
|
||||
The eager `tool_call` announcement always precedes the permission request. If the announcement fails,
|
||||
its de-duplication marker is removed so the normal streamed tool-use path may retry it.
|
||||
|
||||
Request-level presentation metadata requires no capability negotiation. It is an additive `_meta`
|
||||
record and may be ignored by clients that do not render it.
|
||||
|
||||
## Request presentation
|
||||
|
||||
The request-level record is placed under `RequestPermissionRequest._meta.permission`:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "session-1",
|
||||
"toolCall": {
|
||||
"toolCallId": "toolu_1",
|
||||
"name": "Bash",
|
||||
"status": "pending",
|
||||
"rawInput": { "command": "npm test" }
|
||||
},
|
||||
"options": [
|
||||
{ "optionId": "allow-once", "name": "Yes", "kind": "allow_once" },
|
||||
{ "optionId": "reject", "name": "No", "kind": "reject_once" }
|
||||
],
|
||||
"_meta": {
|
||||
"permission": {
|
||||
"version": 1,
|
||||
"title": "npm test",
|
||||
"description": "Reason: Needed to verify the change."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Type | Meaning |
|
||||
| ------------- | -------: | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `version` | yes | integer `1` | Permission presentation schema version. |
|
||||
| `title` | yes | non-empty string | The standard tool-call operation title. |
|
||||
| `description` | no | string | Temporary diagnostic SDK decision reason. |
|
||||
| `defaultToNo` | no | `true` | The ask must not be approvable by a stray keystroke; the reject options are listed first and a client that pre-selects an option should focus the decline. |
|
||||
|
||||
The permission title normally duplicates `toolCall.title`: one operation has one heading across the
|
||||
tool card and approval UI. `ExitPlanMode` is the deliberate exception and uses the action-oriented
|
||||
permission heading `Ready to code?`. Commands, paths, URLs, and other structured details remain in
|
||||
`rawInput`, `content`, and `locations`.
|
||||
|
||||
`description` temporarily carries the SDK's non-blank `decisionReason` for diagnostics, prefixed
|
||||
with `Reason: `. The SDK `description` operation subtitle is not copied there.
|
||||
|
||||
## Tool-call presentation
|
||||
|
||||
The permission request carries the same standard ACP tool information used for normal tool updates:
|
||||
|
||||
- `name` is the SDK tool name (for example `Read` or `mcp__server__tool`), matching the standard
|
||||
`name` field on the initial `tool_call` update;
|
||||
- `kind`, `title`, `content`, and `locations` come from `toolInfoFromToolUse`;
|
||||
- `status` is `pending`;
|
||||
- `rawInput` is the original SDK input object;
|
||||
- `blockedPath` is appended to `locations` when it is valid and not already present;
|
||||
- subagent calls include `_meta.claudeCode.parentToolUseId` on the tool call;
|
||||
- Sandbox Network and Computer Use requests synthesize JSON content when the normal renderer has no
|
||||
content.
|
||||
|
||||
Compact text removes control characters, collapses whitespace where appropriate, and enforces length
|
||||
limits. Invalid optional presentation text is omitted instead of being truncated into misleading UI.
|
||||
Shell permission titles (`Bash` and `PowerShell`) preserve the full command verbatim, including
|
||||
whitespace and line breaks, without applying compact-text normalization or length limits.
|
||||
|
||||
Permission options are fixed. When a durable suggestion contains a command prefix, path, host, or
|
||||
other rule, the adapter includes that value directly in `PermissionOption.name`, for example
|
||||
`Yes, and don't ask again for npm test commands`. Selecting the option applies the exact snapshotted
|
||||
Claude SDK `PermissionUpdate`; the client cannot edit it. A selected reject is distinct from
|
||||
`{ "outcome": "cancelled" }`: cancellation aborts the tool use instead of returning a user rejection.
|
||||
|
||||
## Standard option ids
|
||||
|
||||
| Option id | Meaning |
|
||||
| ------------------------------ | ----------------------------------------------------------- |
|
||||
| `allow-once` | Allow this call without changing permission state. |
|
||||
| `allow-with-updates` | Allow and apply the displayed durable effect. |
|
||||
| `allow-skill-exact` | Allow the exact Skill invocation in local settings. |
|
||||
| `allow-skill-prefix` | Allow the parameterized Skill prefix in local settings. |
|
||||
| `exit-plan-auto` | Exit plan mode and set session mode to `auto`. |
|
||||
| `exit-plan-bypass` | Exit plan mode and set session mode to `bypassPermissions`. |
|
||||
| `exit-plan-accept-edits` | Exit plan mode and set session mode to `acceptEdits`. |
|
||||
| `exit-plan-default` | Exit plan mode and set session mode to `default`. |
|
||||
| `exit-plan-clear-auto` | Clear context, continue the plan, and use `auto`. |
|
||||
| `exit-plan-clear-bypass` | Clear context, continue the plan, and bypass permissions. |
|
||||
| `exit-plan-clear-accept-edits` | Clear context, continue the plan, and accept edits. |
|
||||
| `reject` | Deny the call, optionally returning feedback to Claude. |
|
||||
|
||||
The adapter stably groups options as `allow_once`, `allow_always`, then reject kinds. Options of the
|
||||
same kind retain their builder order. Clients must return the selected option id unchanged.
|
||||
|
||||
## Durable SDK suggestions
|
||||
|
||||
Claude may provide `suggestions: PermissionUpdate[]`. Before a suggestion can appear as a durable
|
||||
choice, the adapter validates the complete array and takes a structured clone. Validation recognizes:
|
||||
|
||||
- `addRules`, `replaceRules`, and `removeRules`;
|
||||
- `setMode` for known Claude permission modes;
|
||||
- `addDirectories` and `removeDirectories`;
|
||||
- known destinations and rule behaviors;
|
||||
- bounded array sizes and bounded non-empty strings.
|
||||
|
||||
Unknown update types, invalid values, oversized bundles, non-cloneable data, and empty suggestion
|
||||
arrays are omitted. The snapshot prevents the label shown to the user from diverging from the effect
|
||||
returned after a delayed response.
|
||||
|
||||
An SDK suggestion is offered only when the tool-specific builder can accurately describe the entire
|
||||
effect. The adapter never labels one part of a mixed bundle while silently applying the rest.
|
||||
|
||||
When `matchedAskRule` is present, all persistent choices are suppressed. A configured `ask` rule must
|
||||
continue asking; accepting a generated allow rule that cannot override it would be misleading.
|
||||
|
||||
## Tool-specific behavior
|
||||
|
||||
| Tool family | Permission choices and effects |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Bash / PowerShell | One-time allow; exact representable SDK bundle with its complete effect in the option label; reject. |
|
||||
| Read / Glob / Grep | One-time allow; matching session-scoped read grant; reject. |
|
||||
| Edit / Write / NotebookEdit | One-time allow; matching session edit grant, including `.claude` handling; reject. |
|
||||
| WebFetch | One-time allow; generated `domain:<hostname>` local rule; reject. |
|
||||
| Skill | One-time allow; exact skill and optional `prefix:*` local rules; reject. |
|
||||
| EnterPlanMode | Enter plan mode once, or reject and continue implementing. |
|
||||
| ExitPlanMode | One keep-context and one clear-context elevated mode (`auto` > bypass > accept edits), manual approval, or reject. |
|
||||
| SandboxNetworkAccess | One-time allow; exact SDK host rule when representable; reject. |
|
||||
| Computer Use MCP | One-time allow; exact representable SDK allow suggestion; reject. |
|
||||
| MCP | One-time allow; exact representable SDK allow suggestion for that tool; reject. |
|
||||
| WebSearch / Agent / Task | One-time allow; generated whole-tool local rule; reject. |
|
||||
| ReviewArtifact / Workflow | Generic fallback behavior until SDK renderer state is available. |
|
||||
| Monitor / unknown tools | Generic fallback behavior. |
|
||||
|
||||
Generated WebFetch, Skill, and non-MCP fallback effects use `localSettings`. Filesystem grants are
|
||||
offered only when the SDK bundle is session-scoped and covers the current path. Shell labels may
|
||||
describe command rules, read paths, additional directories, or a representable combination.
|
||||
|
||||
`AskUserQuestion` is not a permission dialog. With ACP form elicitation support it is routed through
|
||||
ACP elicitation; without that support it is disabled when the Claude session is created.
|
||||
|
||||
## Permission modes and bypass
|
||||
|
||||
The session exposes the Claude modes available for the current model using Claude Code's labels:
|
||||
`Manual`, `Accept edits`, `Plan`, conditional `Auto`, and conditional `Bypass permissions`. The
|
||||
internal `dontAsk` SDK mode is accepted from settings for compatibility but is not advertised as a
|
||||
user-selectable mode.
|
||||
|
||||
Hosts can remove `Bypass permissions` from a session by sending
|
||||
`_meta.claudeCode.options.allowDangerouslySkipPermissions: false` on `session/new` (and again on
|
||||
`session/load`); the mode leaves the catalog and a `bypassPermissions` settings default clamps to
|
||||
`default`.
|
||||
|
||||
The ACP mode ids are the Claude SDK wire ids: `default`, `acceptEdits`, `plan`, `auto`, and
|
||||
`bypassPermissions`. `Manual` deliberately retains the SDK id `default`; `manual` is only a settings
|
||||
input alias. The adapter does not invent parallel ids for presentation labels.
|
||||
|
||||
Like Claude Code, `ExitPlanMode` offers one elevated keep-context choice by priority: `auto`, then
|
||||
`bypassPermissions`, then `acceptEdits`. A non-empty plan also gets exactly one clear-context choice
|
||||
with the same priority. This requires no ACP capability or setting: after selection the adapter
|
||||
interrupts the old private Claude query, hides that internal rejection from the client, creates a
|
||||
new private query under the same public ACP session, and continues the pending turn with
|
||||
`Implement the following plan:` followed by the complete plan. Mode, model, agent, effort, Fast
|
||||
mode, and accumulated turn usage are preserved.
|
||||
|
||||
The SDK cannot reapply `allowedPrompts` through a public control request, so those renderer hints do
|
||||
not survive this internal handoff. Ultraplan remains unavailable because its required renderer state
|
||||
is not exposed by the SDK.
|
||||
|
||||
The adapter does not auto-allow a callback merely because the session advertises
|
||||
`bypassPermissions`. Claude Code applies bypass before invoking `canUseTool`. A request that still
|
||||
reaches the callback is bypass-immune, safety-sensitive, interactive, or forced by an explicit ask
|
||||
rule and must remain visible to the user.
|
||||
|
||||
## Cancellation and failures
|
||||
|
||||
The SDK tool-call `AbortSignal` is forwarded to the ACP client. The adapter also races the client
|
||||
request locally against the same signal, so a client that ignores cancellation cannot leave Claude's
|
||||
tool call waiting forever.
|
||||
|
||||
These cases all abort the tool use with `Tool use aborted`:
|
||||
|
||||
- the signal was already aborted;
|
||||
- the signal aborts while the permission request is open;
|
||||
- the client returns a cancelled outcome;
|
||||
- the client rejects after cancellation.
|
||||
|
||||
A normal selected reject returns `behavior: "deny"`, `decisionClassification: "user_reject"`, and
|
||||
the default refusal message.
|
||||
|
||||
## Client requirements
|
||||
|
||||
A client implementing version 1 should:
|
||||
|
||||
- render standard ACP tool-call content and locations as the action subject;
|
||||
- treat request `_meta.permission.title` and `description` as optional presentation hints;
|
||||
- preserve option ids;
|
||||
- ignore unknown `_meta` fields and future feature names;
|
||||
- settle the request when its cancellation signal fires.
|
||||
|
||||
The client must not infer storage scope, lifetime, or permission effects from `kind` or button text.
|
||||
The adapter owns the Claude `PermissionUpdate` and applies it only after validating the response.
|
||||
|
||||
## Current limits
|
||||
|
||||
The SDK does not expose enough renderer state to reproduce every Claude Code dialog. Version 1 does
|
||||
not implement:
|
||||
|
||||
- Bash output-redirection rewriting;
|
||||
- classifier-specific prompt state;
|
||||
- ExitPlanMode Ultraplan actions;
|
||||
- Computer Use macOS/TCC or application-allowlist dialogs;
|
||||
- structured permission lifetime or storage metadata for clients.
|
||||
|
||||
These omissions do not weaken the one-time choice. When a durable effect cannot be represented
|
||||
honestly, the adapter sends only one-time allow and reject.
|
||||
|
||||
## Verification
|
||||
|
||||
The executable contract lives in:
|
||||
|
||||
- `src/tests/permission-presentation.test.ts` for capability and request presentation;
|
||||
- `src/tests/permission-options.test.ts` for tool-specific option construction;
|
||||
- `src/tests/permission-effects.test.ts` for response validation and effects;
|
||||
- `src/tests/acp-agent.test.ts` for request ordering, cancellation, bypass, and subagent attribution;
|
||||
- `src/tests/session-config-options.test.ts` for plan-mode integration;
|
||||
- `src/tests/live-capability-*-audit.test.ts` for opt-in live SDK audits.
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run check
|
||||
npm run test:run
|
||||
```
|
||||
118
docs/recommended-config-values-extension.md
Normal file
118
docs/recommended-config-values-extension.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Recommended config values extension
|
||||
|
||||
This document defines the experimental AIR `recommendedValue` session configuration extension
|
||||
implemented by `claude-agent-acp`. It lets a client show concrete model and effort choices without
|
||||
an ambiguous `default` row while preserving legacy config options for every client that does not
|
||||
opt in.
|
||||
|
||||
Permission modes and all other session config options are outside this extension and remain
|
||||
unchanged.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
A client opts in through the common AIR capability list in `initialize`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientCapabilities": {
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"capabilities": ["recommendedValue"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The adapter enables the extension only when the AIR version is a finite integer greater than or
|
||||
equal to `1` and `capabilities` contains the singular `recommendedValue` capability. Otherwise it
|
||||
preserves the existing `default` rows, current values, and metadata exactly. The adapter advertises
|
||||
the same capability in its top-level initialize-response `_meta.jetbrains.air.capabilities` list.
|
||||
|
||||
## Config option metadata
|
||||
|
||||
For each selector transformed by the extension, the adapter writes the concrete recommendation
|
||||
through the common AIR metadata envelope at `_meta.jetbrains.air.recommendedValue`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "model",
|
||||
"type": "select",
|
||||
"currentValue": "sonnet",
|
||||
"options": [
|
||||
{ "value": "opus", "name": "Claude Opus" },
|
||||
{ "value": "sonnet", "name": "Claude Sonnet" },
|
||||
{ "value": "haiku", "name": "Claude Haiku" }
|
||||
],
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"recommendedValue": "sonnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`recommendedValue` always names one of that selector's advertised option values. It is independent
|
||||
from `currentValue`: a user's explicit selection remains current even when the SDK recommends a
|
||||
different value.
|
||||
|
||||
## Model behavior
|
||||
|
||||
The SDK's `default` model entry may carry a `resolvedModel` identifying the model currently
|
||||
recommended by Claude. The adapter matches that identifier exactly to a selectable named model
|
||||
(accepting equivalent `-1m` and `[1m]` suffix spellings),
|
||||
removes the `default` row, and advertises the named value in
|
||||
`_meta.jetbrains.air.recommendedValue`. When the session itself is still using the SDK default, the
|
||||
same concrete value is presented as `currentValue`.
|
||||
|
||||
If the SDK recommendation cannot be mapped to an advertised named model, the adapter retains the
|
||||
legacy `default` row for that selector and omits `recommendedValue`. This prevents a client from
|
||||
receiving a recommendation or current value it cannot select.
|
||||
|
||||
Terse SDK labels that match their underlying model family include the concrete version derived
|
||||
from model metadata: for example, `Sonnet` becomes `Sonnet 5`, `Fable` becomes `Fable 5.1`, and
|
||||
`Claude Haiku` becomes `Claude Haiku 4.5`.
|
||||
Context suffixes such as `Opus (1M context)` are omitted from the label because the context remains
|
||||
in the description. Custom labels are preserved, and normalization falls back to the original
|
||||
labels if two selectable entries would otherwise collide. This presentation also applies to
|
||||
legacy clients.
|
||||
|
||||
## Effort behavior
|
||||
|
||||
When the current model supports effort selection, the adapter removes the `default` effort row and
|
||||
advertises `medium` as the recommendation. An existing explicit or settings-derived effort remains
|
||||
the `currentValue`; an absent or legacy `default` current effort is presented as `medium`.
|
||||
|
||||
The adapter applies the displayed effort to the SDK on session creation and model switches, so
|
||||
the concrete selection reflects the effort actually used. Explicit SDK `options.effort` and ACP
|
||||
picker choices remain pinned across model switches while supported. Otherwise each switch
|
||||
re-reads the new model's settings before falling back to the recommendation. Switching to a
|
||||
model without effort support clears the flag override. Legacy clients continue to let the SDK
|
||||
resolve automatic effort.
|
||||
|
||||
If effort synchronization fails after the model has already switched, the adapter still reports
|
||||
the new model but never presents the unapplied effort as current. It retains the last successfully
|
||||
applied value when that value is selectable for the new model; otherwise it temporarily omits the
|
||||
effort selector until a later successful switch can rebuild truthful state.
|
||||
|
||||
If a future model exposes effort choices without `medium`, the first SDK-advertised effort level is
|
||||
used so both `recommendedValue` and `currentValue` remain valid option values.
|
||||
|
||||
## SDK upgrade check
|
||||
|
||||
The ordinary test suite pins the SDK version whose Opus label was verified. When updating the
|
||||
SDK, first run the live contract test in an authenticated environment:
|
||||
|
||||
```sh
|
||||
RUN_INTEGRATION_TESTS=true npx vitest run src/tests/model-presentation.test.ts
|
||||
```
|
||||
|
||||
Review any changes to the available Opus entries and their labels before updating the version
|
||||
guard and, if necessary, the normalization. The live test only initializes the SDK; it sends no
|
||||
model prompt.
|
||||
209
docs/session-failure-extension.md
Normal file
209
docs/session-failure-extension.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Session failure extension
|
||||
|
||||
This document defines the provider-neutral experimental `sessionFailure` extension implemented by
|
||||
`claude-agent-acp`. Warnings and errors are durable transcript entries shown in order beside user,
|
||||
agent, and tool messages. They are not assistant-authored text and not ephemeral chat banners.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
The extension is opt-in. A client advertises it in `initialize`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientCapabilities": {
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"capabilities": ["sessionFailure"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The adapter enables the extension only when `version` is a finite integer greater than or equal to
|
||||
`1` and `capabilities` contains `sessionFailure`. Otherwise it preserves the legacy ACP behavior.
|
||||
|
||||
## Wire record
|
||||
|
||||
The record is placed under `_meta.jetbrains.air.sessionFailure`:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"sessionFailure": {
|
||||
"id": "prompt-uuid:error",
|
||||
"revision": 1,
|
||||
"category": "limit",
|
||||
"severity": "error",
|
||||
"title": "You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit",
|
||||
"actions": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Type | Meaning |
|
||||
| ---------- | -------: | -------------------- | ----------------------------------------------------------------------- |
|
||||
| `id` | yes | non-empty string | Stable identity of one logical incident. |
|
||||
| `revision` | yes | positive integer | Monotonically increasing version of that incident. |
|
||||
| `category` | yes | category enum | Broad machine-readable visual group. |
|
||||
| `severity` | yes | `warning` or `error` | Inline warning or error presentation. |
|
||||
| `title` | yes | string | Complete normal user-facing presentation. |
|
||||
| `details` | no | string | Long explanation used only when required text is too large for `title`. |
|
||||
| `actions` | yes | ordered string array | Recovery actions recommended by the adapter. |
|
||||
|
||||
There are no `phase`, `source`, `safeMessage`, `retryable`, `retryAfterMs`, `turnId`, retry-counter,
|
||||
or provider-code fields. Retry progress belongs in `title` when Claude supplies it. Transcript order
|
||||
and persistence time come from the receiving ACP event.
|
||||
|
||||
## Identity, revisions, and history
|
||||
|
||||
`id` identifies one occurrence, not an error type.
|
||||
|
||||
- The first record for an incident creates one transcript entry at the current stream position.
|
||||
- The same `id` with a higher `revision` updates that entry in place without moving it.
|
||||
- The same or a lower revision is idempotently ignored by the client.
|
||||
- A later independent occurrence uses a new `id`, even when its category and text are identical.
|
||||
- Consecutive updates for the same notice may reuse one id and increment its revision.
|
||||
- Different ids are never deduplicated by comparing title or category.
|
||||
|
||||
Live turn failures use `<turnId>:error`. Session-scoped incidents use an adapter-session epoch plus an
|
||||
incident sequence. Notices have their own `:notice:` namespace. Replayed usage-limit failures recover
|
||||
the persisted user-message UUID used as the live turn id, so the same occurrence keeps the same id.
|
||||
Malformed history without a preceding user message falls back to
|
||||
`<sessionId>:history-error:<messageUuid>`.
|
||||
|
||||
Resolved records remain in transcript history. Recovery removes only the adapter's internal active
|
||||
state; it does not publish a clear tombstone and does not delete or rewrite the historical entry. A
|
||||
producer may publish a higher revision only when it has a real new user-facing status to show.
|
||||
|
||||
## Delivery surfaces
|
||||
|
||||
A turn-terminal failure is attached to the successful ACP `PromptResponse` in `_meta`; the response
|
||||
uses `stopReason: end_turn`. Session-scoped, replay-restored, warning, and background incidents use a
|
||||
`session_info_update`. The schema is identical on both carriers.
|
||||
|
||||
A warning does not end a turn. An error record does not independently invent ACP lifecycle state;
|
||||
the prompt response or transport lifecycle remains authoritative. If no capable client negotiated
|
||||
the extension, existing JSON-RPC errors and transcript text remain unchanged.
|
||||
|
||||
## Categories
|
||||
|
||||
Categories are deliberately broad and drive only iconography and generic accessibility labels.
|
||||
|
||||
| Category | Covers |
|
||||
| ------------ | --------------------------------------------------------------------- |
|
||||
| `connection` | Lost transport, stopped or shutting-down worker, unavailable runtime. |
|
||||
| `access` | Authentication required, rejected credentials, denied access. |
|
||||
| `limit` | Rate, quota, token, context, turn, or configured budget limit. |
|
||||
| `request` | Invalid input, unsupported model or operation, rejected request. |
|
||||
| `service` | Provider overload, provider failure, adapter/internal failure. |
|
||||
| `unknown` | A warning or failure that does not fit another group. |
|
||||
|
||||
Claude SDK conditions map to these groups:
|
||||
|
||||
| Claude condition | Category |
|
||||
| ---------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| `authentication_failed`, `oauth_org_not_allowed`, synthetic login message | `access` |
|
||||
| `verification_required`, `cloud_credential_error` | `access` (no `login` action) |
|
||||
| `billing_error`, `rate_limit`, `max_output_tokens`, usage/spend limit, budget/turn limit | `limit` |
|
||||
| `invalid_request`, `model_not_found` | `request` |
|
||||
| `overloaded`, `server_error`, unknown provider error, adapter internal error | `service` |
|
||||
| API retry without an HTTP response, query transport loss, worker shutdown | `connection` |
|
||||
| model fallback notice | `unknown` with `severity: warning` |
|
||||
|
||||
Unknown SDK error kinds degrade to `service`; they never become success. Category does not determine
|
||||
message text or client behavior beyond presentation.
|
||||
|
||||
## Severity
|
||||
|
||||
- `warning` means the operation may still succeed and normally has no actions while recovery is in
|
||||
progress. It does not terminate the turn. SDK `api_retry` progress reuses the active turn failure id;
|
||||
a later terminal failure updates that record with a higher revision.
|
||||
- `error` means the operation cannot continue without user action or another request.
|
||||
|
||||
Severity is always explicit.
|
||||
|
||||
## Title and details
|
||||
|
||||
`title` is the complete normal presentation: what happened, retry progress when present, and a short
|
||||
next step. The adapter must not replace a real Claude message with category-specific canned wording.
|
||||
|
||||
For Claude SDK failures the title is copied from the user-facing text Claude already emitted:
|
||||
|
||||
- top-level assistant error text, including synthetic usage-limit and login messages;
|
||||
- otherwise the terminal SDK result or error text;
|
||||
- the exact model-fallback notice for warning records.
|
||||
|
||||
Adapter-authored fallback text is allowed only when no Claude user-facing message exists, such as a
|
||||
query iterator throwing, worker-shutdown EOF, or an internal missing-result invariant. Raw thrown
|
||||
exceptions, stack traces, transport URLs, tokens, headers, environment values, and private paths are
|
||||
never used as titles.
|
||||
|
||||
`details` is reserved exclusively for required explanations too large to fit reasonably in `title`.
|
||||
If the complete message fits in `title`, `details` must be omitted. It is not a place for status text,
|
||||
retry counters, provider payloads, or diagnostics.
|
||||
|
||||
## Actions
|
||||
|
||||
Version 1 defines only:
|
||||
|
||||
| Action | Meaning |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `retry` | Retry the operation associated with this incident. |
|
||||
| `login` | Start authentication. |
|
||||
| `new_session` | Start a fresh agent session/runtime. |
|
||||
|
||||
The adapter supplies action ordering. The client filters actions it cannot safely execute and ignores
|
||||
unknown or duplicate values. The client must not infer actions from category.
|
||||
|
||||
Current Claude policies are:
|
||||
|
||||
- authentication: `login`;
|
||||
- provider overload/error and recoverable internal error: `retry`;
|
||||
- transport loss or worker shutdown: `new_session`;
|
||||
- context or configured session-budget exhaustion: `new_session`;
|
||||
- account quota and invalid request: no action.
|
||||
|
||||
## Internal recovery
|
||||
|
||||
Recovery policy is internal adapter state and is not serialized:
|
||||
|
||||
- restored quota remains active until a real model answer;
|
||||
- authentication remains active until successful `auth_status`;
|
||||
- transport loss and worker shutdown remain active until runtime replacement;
|
||||
- other turn-scoped failures stop being active at a later confirmed attempt boundary;
|
||||
- notices are not cleared merely by generic success.
|
||||
|
||||
Recovery never removes the transcript record. Publishing another incident is not evidence that an
|
||||
older one recovered.
|
||||
|
||||
## History replay
|
||||
|
||||
`session/load` scans top-level assistant history for SDK synthetic usage-limit messages. It recognizes
|
||||
only `<synthetic>` messages beginning with SDK-owned stable prefixes, not arbitrary model prose. Every
|
||||
recognized occurrence is replayed as a typed record at its original transcript position. The latest
|
||||
matching message stays active until a later real-model answer proves recovery; older and recovered
|
||||
records remain historical typed entries.
|
||||
|
||||
For a capable client, replay suppresses duplicate assistant prose and restores typed errors using the
|
||||
exact stored Claude text as `title`. Legacy clients receive the original transcript and no typed record.
|
||||
|
||||
## Verification
|
||||
|
||||
The executable contract lives primarily in `src/tests/acp-agent.test.ts`, under `usage-limit failure
|
||||
replay`, `stop reason propagation`, and `model refusal fallback handling`.
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run check
|
||||
npm run test:run
|
||||
```
|
||||
706
examples/simple-client.ts
Normal file
706
examples/simple-client.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
/**
|
||||
* Example: a dead-simple interactive ACP client for this agent.
|
||||
*
|
||||
* This is the "no editor required" way to exercise the agent: it launches
|
||||
* `dist/index.js` as a subprocess, speaks ACP over its stdio using the SDK, and
|
||||
* prints every JSON-RPC message that crosses the wire in both directions. Each
|
||||
* line you type becomes a `session/prompt`; the agent's answer streams back as
|
||||
* `session/update` notifications.
|
||||
*
|
||||
* Every message is preceded by a header line naming its direction:
|
||||
*
|
||||
* [agent] ◂ inbound — everything the agent sends us
|
||||
* [user] ▸ outbound — everything we send the agent, including our own
|
||||
* permission answers and `session/cancel`
|
||||
*
|
||||
* The label and the arrow carry the direction, so it survives NO_COLOR and
|
||||
* redirection to a file (`2> wire.log`). The JSON itself is printed in full,
|
||||
* flush left and undecorated — select it and you have valid JSON on the
|
||||
* clipboard. This exists to show you the protocol, not to summarize it.
|
||||
*
|
||||
* There are four ways for a client to get a message to this agent, and all four
|
||||
* are reachable from the prompt so you can watch what each one looks like on
|
||||
* the wire. Plain text is the first; the rest are `!`-prefixed commands, chosen
|
||||
* so they cannot be confused with the agent's own slash commands (`/compact`
|
||||
* and friends are ordinary prompt text here). `!!` escapes a literal `!`.
|
||||
*
|
||||
* <text> `session/prompt`. Typed mid-turn it is *buffered* by this
|
||||
* client and sent once the running turn responds.
|
||||
* !steer <text> `_session/steering` — injected into the turn that is
|
||||
* already running, so it can change course between tool
|
||||
* calls. Answers "injected", or "startedNewTurn" if the turn
|
||||
* beat us to the finish. An injected reply streams as
|
||||
* `session/update` notifications inside that turn, whose
|
||||
* `stopReason` waits for it. "startedNewTurn" output belongs
|
||||
* to a detached turn no `session/prompt` of ours tracks.
|
||||
* See ./steering.ts.
|
||||
* !queue <text> `session/prompt` sent mid-turn anyway. The agent advertises
|
||||
* `promptQueueing`, so it takes the backlog instead of us —
|
||||
* same ordering, different owner.
|
||||
* !cancel `session/cancel` for the running turn, as Ctrl-C does.
|
||||
*
|
||||
* Run (build the agent first so `dist/index.js` exists):
|
||||
*
|
||||
* npm run build
|
||||
* node examples/simple-client.ts
|
||||
*
|
||||
* or, equivalently, `npm run example:simple-client`.
|
||||
*
|
||||
* To debug the agent itself, pass a port. The agent is then rebuilt with source
|
||||
* maps and started under `--inspect-brk=<port>`, i.e. paused before its first
|
||||
* line, so you can attach (IntelliJ: Run → Attach to Node.js/Chrome; VS Code:
|
||||
* "Attach to Node Process"), set breakpoints in `src/*.ts`, and resume:
|
||||
*
|
||||
* node examples/simple-client.ts --debug-port=9229
|
||||
*
|
||||
* The client will sit waiting for `initialize` until you attach and resume —
|
||||
* that is expected, ACP has no request timeout.
|
||||
*
|
||||
* (Node < 22.18 needs `node --experimental-strip-types examples/simple-client.ts`.)
|
||||
*
|
||||
* Keys: Enter sends (or buffers), Ctrl-C cancels the running turn (again to
|
||||
* quit), Ctrl-D drains anything buffered and exits. `!help` lists the commands.
|
||||
*
|
||||
* Flags: --debug-port=<port>, --no-build, --help.
|
||||
* Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()).
|
||||
*
|
||||
* Requires the agent to be authenticated, since it talks to the real model.
|
||||
*
|
||||
* Note: this client does not advertise the `elicitation` capability, so the
|
||||
* agent disables its AskUserQuestion tool instead of asking us to render a
|
||||
* form. That is the one intentional behaviour difference from a full client.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
client as acpClient,
|
||||
methods,
|
||||
ndJsonStream,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
// Type-only: Node's type stripping does not elide value imports, and these two
|
||||
// exist only in the .d.ts — importing them normally is a runtime SyntaxError.
|
||||
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
|
||||
|
||||
// ---------------------------------------------------------------- steering
|
||||
|
||||
/**
|
||||
* The steering extension (../steering_protocol.md, and ./steering.ts for a
|
||||
* scripted walkthrough). It is not part of ACP proper, so it has no entry in
|
||||
* `methods` and is sent as a raw method name with hand-written types.
|
||||
*/
|
||||
const STEERING_METHOD = "_session/steering";
|
||||
|
||||
type SteeringRequest = {
|
||||
sessionId: string;
|
||||
prompt: Array<{ type: "text"; text: string }>;
|
||||
};
|
||||
|
||||
/** Both outcomes are successes; they say where the message landed, not whether
|
||||
* it worked. "startedNewTurn" means the turn finished before we got there. */
|
||||
type SteeringResponse = {
|
||||
outcome: "injected" | "startedNewTurn";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- arguments
|
||||
|
||||
const ARGV = process.argv.slice(2);
|
||||
|
||||
/** Reads `--name=value` or `--name value`; returns "" for a valueless flag. */
|
||||
function flag(name: string): string | undefined {
|
||||
const index = ARGV.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
|
||||
if (index === -1) return undefined;
|
||||
const [, ...rest] = ARGV[index].split("=");
|
||||
return rest.length > 0 ? rest.join("=") : (ARGV[index + 1] ?? "");
|
||||
}
|
||||
|
||||
if (ARGV.includes("--help") || ARGV.includes("-h")) {
|
||||
process.stdout.write(
|
||||
[
|
||||
"Usage: node examples/simple-client.ts [--debug-port=<port>] [--no-build]",
|
||||
"",
|
||||
" --debug-port=<port> rebuild with source maps and start the agent under",
|
||||
" --inspect-brk=<port>, paused until a debugger attaches",
|
||||
" --no-build skip the tsc rebuild and use dist/ as-is",
|
||||
"",
|
||||
"Env: AGENT_ENTRY (default dist/index.js), CWD (default process.cwd()).",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// The built agent entry. Rebuilt below unless --no-build is passed.
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const AGENT_ENTRY = process.env.AGENT_ENTRY ?? path.join(repoRoot, "dist", "index.js");
|
||||
const CWD = process.env.CWD ?? process.cwd();
|
||||
const BUILD = !ARGV.includes("--no-build");
|
||||
|
||||
const debugPortArg = flag("debug-port");
|
||||
const DEBUG_PORT = debugPortArg === undefined ? undefined : Number(debugPortArg);
|
||||
if (
|
||||
DEBUG_PORT !== undefined &&
|
||||
(!Number.isInteger(DEBUG_PORT) || DEBUG_PORT < 1 || DEBUG_PORT > 65535)
|
||||
) {
|
||||
process.stderr.write(`--debug-port expects a port between 1 and 65535, got "${debugPortArg}"\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Shown by `!help`, and once at startup. */
|
||||
const HELP = [
|
||||
"<text> send it — as a new turn, or buffered until this turn ends",
|
||||
"!steer <text> inject into the running turn (_session/steering)",
|
||||
"!queue <text> send session/prompt now and let the agent queue it",
|
||||
"!cancel session/cancel the running turn (same as Ctrl-C)",
|
||||
"!help this list; !! sends a line that really starts with '!'",
|
||||
];
|
||||
|
||||
// How long to wait for a cancelled turn to settle, and for the agent to exit
|
||||
// after we close its stdin, before escalating during shutdown.
|
||||
const CANCEL_TIMEOUT_MS = 5_000;
|
||||
const EXIT_TIMEOUT_MS = 5_000;
|
||||
|
||||
// ---------------------------------------------------------------- output
|
||||
|
||||
const COLOR = process.stderr.isTTY === true && !process.env.NO_COLOR;
|
||||
const paint = (code: string, text: string) => (COLOR ? `\x1b[${code}m${text}\x1b[0m` : text);
|
||||
const dim = (text: string) => paint("2", text);
|
||||
|
||||
const TTY = process.stdin.isTTY === true && process.stderr.isTTY === true;
|
||||
const startedAt = Date.now();
|
||||
|
||||
let rl: readline.Interface | null = null;
|
||||
let shuttingDown = false;
|
||||
let queue: string[] = [];
|
||||
// How many `session/prompt` requests are awaiting a response. Usually 0 or 1;
|
||||
// `!queue` deliberately drives it higher to exercise the agent's own queueing.
|
||||
let inFlight = 0;
|
||||
const turnRunning = () => inFlight > 0;
|
||||
let cancelRequested = false;
|
||||
let lastUpdateKind = "";
|
||||
// False until `initialize` returns — an agent still paused under --inspect-brk
|
||||
// will never act on stdin EOF, so shutdown skips straight to signalling it.
|
||||
let agentReady = false;
|
||||
|
||||
/**
|
||||
* The single writer for everything this process prints. It wipes the half-typed
|
||||
* input line, writes the block, then asks readline to redraw its prompt with the
|
||||
* buffer intact — without this, streamed output and typing shred each other.
|
||||
* (One caveat: a soft-wrapped input line leaves a stale fragment behind, since
|
||||
* clearLine only clears the row the cursor is on.)
|
||||
*/
|
||||
function writeBlock(text: string) {
|
||||
if (TTY) {
|
||||
readline.cursorTo(process.stderr, 0);
|
||||
readline.clearLine(process.stderr, 0);
|
||||
}
|
||||
process.stderr.write(text.endsWith("\n") ? text : `${text}\n`);
|
||||
if (TTY) rl?.prompt(true);
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
writeBlock(`${dim("[client]")} ${message}`);
|
||||
}
|
||||
|
||||
function refreshPrompt() {
|
||||
if (!rl) return;
|
||||
const queued = queue.length > 0 ? `[${queue.length} queued] ` : "";
|
||||
const running = inFlight > 1 ? `[${inFlight} turns] ` : "";
|
||||
const busy = turnRunning() ? dim(`⋯ ${lastUpdateKind} `) : "";
|
||||
rl.setPrompt(`${busy}${running}${queued}› `);
|
||||
if (TTY) rl.prompt(true);
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ---------------------------------------------------------------- wire log
|
||||
|
||||
/** A permissive view of the JSON-RPC union, purely for the summary line. */
|
||||
type WireMessage = {
|
||||
id?: string | number | null;
|
||||
method?: string;
|
||||
params?: { update?: { sessionUpdate?: string } };
|
||||
result?: { stopReason?: string };
|
||||
error?: { code: number; message: string };
|
||||
};
|
||||
|
||||
/** One-line summary: method (plus session update kind), or which reply this is. */
|
||||
function describe(message: AnyMessage): string {
|
||||
const wire = message as WireMessage;
|
||||
const id = wire.id === undefined || wire.id === null ? "" : ` #${wire.id}`;
|
||||
if (wire.method !== undefined) {
|
||||
const kind = wire.params?.update?.sessionUpdate;
|
||||
return `${wire.method}${kind ? ` · ${kind}` : ""}${id}`;
|
||||
}
|
||||
if (wire.error !== undefined) {
|
||||
return `error${id} · ${wire.error.code} ${wire.error.message}`;
|
||||
}
|
||||
const stopReason = wire.result?.stopReason;
|
||||
return `result${id}${stopReason ? ` · ${stopReason}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints one JSON-RPC message. The SDK exposes no logging hook, so this is
|
||||
* called from a stream tap (see `tapStream`) that sits between the SDK and the
|
||||
* transport — what you see is exactly what is written to / read from the pipe.
|
||||
*/
|
||||
function logMessage(direction: "agent" | "user", message: AnyMessage) {
|
||||
const inbound = direction === "agent";
|
||||
const color = inbound ? "36" : "35";
|
||||
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(3);
|
||||
const header = [
|
||||
dim(`+${elapsed}s`),
|
||||
paint(`1;${color}`, inbound ? "[agent]" : "[user] "),
|
||||
paint(color, inbound ? "◂" : "▸"),
|
||||
dim(describe(message)),
|
||||
].join(" ");
|
||||
// The body is printed flush left and unadorned so that selecting it in the
|
||||
// terminal yields valid JSON you can paste straight into a tool.
|
||||
writeBlock(`${header}\n${JSON.stringify(message, null, 2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a transport stream so every message is logged post-parse/pre-stringify.
|
||||
* A TransformStream per direction is the only complete interposition point: the
|
||||
* SDK reads inbound with a single reader and takes a fresh writer per outbound
|
||||
* message, and both survive being handed a transform.
|
||||
*/
|
||||
function tapStream(inner: Stream): Stream {
|
||||
const inbound = new TransformStream<AnyMessage, AnyMessage>({
|
||||
transform(message, controller) {
|
||||
logMessage("agent", message);
|
||||
controller.enqueue(message);
|
||||
},
|
||||
});
|
||||
const outbound = new TransformStream<AnyMessage, AnyMessage>({
|
||||
transform(message, controller) {
|
||||
logMessage("user", message);
|
||||
controller.enqueue(message);
|
||||
},
|
||||
});
|
||||
// The SDK never closes its writable, so this pipe only settles on failure —
|
||||
// typically the agent died and its stdin is gone. Teardown handles the rest;
|
||||
// without the catch this would be an unhandled rejection.
|
||||
outbound.readable.pipeTo(inner.writable).catch((err) => {
|
||||
if (!shuttingDown) log(`transport write failed: ${err}`);
|
||||
});
|
||||
return { readable: inner.readable.pipeThrough(inbound), writable: outbound.writable };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- build
|
||||
|
||||
/** Runs the repo's tsc, sending its diagnostics to our stderr. */
|
||||
async function build(sourceMaps: boolean) {
|
||||
const localTsc = path.join(repoRoot, "node_modules", "typescript", "bin", "tsc");
|
||||
const useLocal = existsSync(localTsc);
|
||||
const args = sourceMaps ? ["--sourceMap"] : [];
|
||||
log(`building agent (tsc${sourceMaps ? " --sourceMap" : ""}) …`);
|
||||
|
||||
const child = useLocal
|
||||
? spawn(process.execPath, [localTsc, ...args], { cwd: repoRoot, stdio: ["ignore", 2, 2] })
|
||||
: spawn(process.platform === "win32" ? "npx.cmd" : "npx", ["tsc", ...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", 2, 2],
|
||||
});
|
||||
|
||||
const [code] = (await once(child, "exit")) as [number | null];
|
||||
if (code !== 0) {
|
||||
log(`build failed (tsc exit ${code}) — fix the errors above, or pass --no-build`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- main
|
||||
|
||||
async function main() {
|
||||
if (BUILD) {
|
||||
await build(DEBUG_PORT !== undefined);
|
||||
} else if (!existsSync(AGENT_ENTRY)) {
|
||||
log(`${AGENT_ENTRY} not found — run \`npm run build\` first, or unset --no-build`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// An ACP client launches the agent as a subprocess and speaks JSON-RPC over
|
||||
// its stdin/stdout. Its stderr is piped rather than inherited so that agent
|
||||
// logs go through writeBlock() and don't corrupt the readline input line.
|
||||
const nodeArgs =
|
||||
DEBUG_PORT === undefined ? [AGENT_ENTRY] : [`--inspect-brk=${DEBUG_PORT}`, AGENT_ENTRY];
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
cwd: repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
log(`failed to spawn agent (${AGENT_ENTRY}): ${err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
// A write racing the agent's exit surfaces as an unhandled 'error' event that
|
||||
// would take this client down with it.
|
||||
child.stdin?.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code !== "EPIPE") log(`agent stdin error: ${err}`);
|
||||
});
|
||||
child.stdout?.on("error", () => {});
|
||||
process.stderr.on("error", () => {});
|
||||
|
||||
pumpAgentStderr(child);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!shuttingDown) log(`agent exited (code ${code}, signal ${signal})`);
|
||||
});
|
||||
|
||||
const stream = tapStream(
|
||||
ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
|
||||
),
|
||||
);
|
||||
|
||||
const connection = acpClient({ name: "simple-client" })
|
||||
// The tap already prints every update; this handler exists so the prompt line
|
||||
// can show what the agent is currently doing.
|
||||
.onNotification(methods.client.session.update, (ctx) => {
|
||||
lastUpdateKind = ctx.params.update.sessionUpdate;
|
||||
})
|
||||
// Auto-approve, chosen by `kind` and never by a hard-coded optionId: for
|
||||
// ExitPlanMode this agent uses permission-*mode* names as option ids
|
||||
// (auto / acceptEdits / default / plan / bypassPermissions).
|
||||
.onRequest(methods.client.session.requestPermission, (ctx) => {
|
||||
const options = ctx.params.options;
|
||||
const option = options.find((o) => o.kind === "allow_once") ?? options[0];
|
||||
if (!option) return { outcome: { outcome: "cancelled" } };
|
||||
log(`auto-approving permission: ${option.name} (${option.kind}/${option.optionId})`);
|
||||
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
||||
})
|
||||
// Minimal file-system stubs. This agent performs its own file I/O inside the
|
||||
// Claude Agent SDK subprocess and never calls these today.
|
||||
.onRequest(methods.client.fs.readTextFile, () => ({ content: "" }))
|
||||
.onRequest(methods.client.fs.writeTextFile, () => ({}))
|
||||
.connect(stream);
|
||||
|
||||
const agent = connection.agent;
|
||||
|
||||
// Signals are wired up *before* the first request. Under --inspect-brk the
|
||||
// wait below is unbounded, and a client that dies without running shutdown()
|
||||
// orphans a paused agent still holding the debug port.
|
||||
const onInterrupt = () => {
|
||||
if (turnRunning() && !cancelRequested) {
|
||||
cancelRequested = true;
|
||||
const dropped = queue.length;
|
||||
queue = [];
|
||||
// A notification, not a request: every turn in flight settles through its
|
||||
// own session/prompt response, normally with stopReason "cancelled".
|
||||
void agent.notify(methods.agent.session.cancel, { sessionId });
|
||||
log(`sent session/cancel${dropped > 0 ? `, dropped ${dropped} queued line(s)` : ""}`);
|
||||
refreshPrompt();
|
||||
return;
|
||||
}
|
||||
void shutdown(turnRunning() ? "^C while cancelling" : "^C");
|
||||
};
|
||||
// In terminal mode readline puts stdin in raw mode and owns ^C, so this only
|
||||
// ever fires for an explicit `kill -INT`; with piped stdin it is the ^C path.
|
||||
process.on("SIGINT", onInterrupt);
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// 1. Initialize. Under --inspect-brk the agent is paused before it reads a
|
||||
// single byte, so this request stays unanswered until a debugger attaches
|
||||
// and resumes it. ACP has no request timeout, so waiting is safe; the
|
||||
// interval below just keeps the terminal from looking dead.
|
||||
let nudge: NodeJS.Timeout | undefined;
|
||||
if (DEBUG_PORT !== undefined) {
|
||||
log(`agent is PAUSED before its first line (--inspect-brk=${DEBUG_PORT})`);
|
||||
log(`attach a debugger to port ${DEBUG_PORT} and resume it — breakpoints go in src/*.ts`);
|
||||
nudge = setInterval(
|
||||
() => log(`still waiting for a debugger on port ${DEBUG_PORT} … (Ctrl-C to give up)`),
|
||||
10_000,
|
||||
);
|
||||
}
|
||||
let init;
|
||||
try {
|
||||
init = await agent.request(methods.agent.initialize, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
terminal: false,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (nudge) clearInterval(nudge);
|
||||
}
|
||||
agentReady = true;
|
||||
log(`connected to ${init.agentInfo?.name ?? "agent"} ${init.agentInfo?.version ?? ""}`.trim());
|
||||
|
||||
// Steering is advertised at the TOP-LEVEL `_meta` of the initialize result —
|
||||
// a sibling of `agentCapabilities`, not nested inside it. Prompt queueing is
|
||||
// a Claude-specific capability flag and does live inside.
|
||||
const initMeta = init._meta as { steering?: { supported?: boolean } } | null | undefined;
|
||||
const steeringSupported = initMeta?.steering?.supported === true;
|
||||
const queueMeta = init.agentCapabilities?._meta as
|
||||
{ claudeCode?: { promptQueueing?: boolean } } | null | undefined;
|
||||
log(
|
||||
`agent supports: steering=${steeringSupported} ` +
|
||||
`promptQueueing=${queueMeta?.claudeCode?.promptQueueing === true}`,
|
||||
);
|
||||
|
||||
// 2. Open a session.
|
||||
let sessionId: string;
|
||||
try {
|
||||
const session = await agent.request(methods.agent.session.new, { cwd: CWD, mcpServers: [] });
|
||||
sessionId = session.sessionId;
|
||||
} catch (err) {
|
||||
log(`session/new failed: ${err}`);
|
||||
log("if this is an auth error, log in first: node dist/index.js --cli auth login");
|
||||
await shutdown("session/new failed", 1);
|
||||
return;
|
||||
}
|
||||
log(`session ${sessionId} in ${CWD}`);
|
||||
|
||||
// 3. Turns. There are four ways to get a message to the agent, and the client
|
||||
// can drive all of them (see `handleCommand`); plain text uses the first.
|
||||
//
|
||||
// session/prompt a new turn, or — for text typed mid-turn — one
|
||||
// buffered here and sent when the turn responds
|
||||
// session/prompt sent mid-turn anyway, so the *agent* queues it
|
||||
// (!queue) (it advertises `_meta.claudeCode.promptQueueing`)
|
||||
// _session/steering injected into the turn that is already running
|
||||
// (!steer)
|
||||
// session/cancel abandon the running turn
|
||||
// (!cancel, ^C)
|
||||
let draining = false;
|
||||
|
||||
/** One `session/prompt` round-trip. Several may be in flight at once when
|
||||
* `!queue` is used; the agent runs them in order. */
|
||||
async function sendPrompt(text: string) {
|
||||
inFlight += 1;
|
||||
cancelRequested = false;
|
||||
refreshPrompt();
|
||||
try {
|
||||
const result = await agent.request(methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text }],
|
||||
});
|
||||
log(`turn ended: ${result.stopReason}`);
|
||||
} catch (err) {
|
||||
// The error response itself was already printed by the tap; this is
|
||||
// the human-readable "your prompt did not run".
|
||||
log(`prompt failed: ${err}`);
|
||||
if (connection.signal.aborted) queue = [];
|
||||
} finally {
|
||||
inFlight -= 1;
|
||||
refreshPrompt();
|
||||
exitWhenIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the buffered lines one turn at a time, oldest first. */
|
||||
async function drain() {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
try {
|
||||
while (queue.length > 0 && !shuttingDown) {
|
||||
await sendPrompt(queue.shift()!);
|
||||
if (connection.signal.aborted) break;
|
||||
}
|
||||
} finally {
|
||||
draining = false;
|
||||
refreshPrompt();
|
||||
exitWhenIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** A piped stdin ends; exiting has to wait for the work already handed out. */
|
||||
function exitWhenIdle() {
|
||||
if (inputClosed && !draining && queue.length === 0 && inFlight === 0) {
|
||||
void shutdown("input closed, queue drained");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side commands, prefixed with `!` so they cannot collide with the
|
||||
* agent's own slash commands (`/compact` and friends, which are just prompt
|
||||
* text as far as this client is concerned). `!!` escapes a literal `!`.
|
||||
*/
|
||||
function handleCommand(line: string) {
|
||||
const word = line.split(/\s+/)[0];
|
||||
const text = line.slice(word.length).trim();
|
||||
switch (word) {
|
||||
case "!steer":
|
||||
if (text.length === 0) {
|
||||
log("usage: !steer <message> — injects into the turn that is running");
|
||||
break;
|
||||
}
|
||||
if (!steeringSupported) log("agent did not advertise steering; trying anyway");
|
||||
void steer(text);
|
||||
break;
|
||||
case "!queue":
|
||||
if (text.length === 0) {
|
||||
log("usage: !queue <message> — sends session/prompt now, agent queues it");
|
||||
break;
|
||||
}
|
||||
void sendPrompt(text);
|
||||
break;
|
||||
case "!cancel":
|
||||
if (!turnRunning()) {
|
||||
log("nothing to cancel");
|
||||
break;
|
||||
}
|
||||
onInterrupt();
|
||||
break;
|
||||
case "!help":
|
||||
for (const entry of HELP) log(entry);
|
||||
break;
|
||||
default:
|
||||
log(`unknown command ${word} — try !help`);
|
||||
break;
|
||||
}
|
||||
refreshPrompt();
|
||||
}
|
||||
|
||||
/** `_session/steering`: deliver a message to the turn already in progress. */
|
||||
async function steer(text: string) {
|
||||
const params: SteeringRequest = { sessionId, prompt: [{ type: "text", text }] };
|
||||
try {
|
||||
const result = await agent.request<SteeringResponse>(STEERING_METHOD, params);
|
||||
// Either way the steered message's own output arrives as `session/update`
|
||||
// notifications, not in this response. When injected, it lands inside the
|
||||
// turn we steered, whose `stopReason` waits for it.
|
||||
log(
|
||||
result.outcome === "injected"
|
||||
? "steer outcome: injected into the running turn"
|
||||
: "steer outcome: startedNewTurn — the turn had already finished, so the " +
|
||||
"agent began a new one that no session/prompt of ours is tracking",
|
||||
);
|
||||
} catch (err) {
|
||||
log(`steer rejected: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
let inputClosed = false;
|
||||
let steerHinted = false;
|
||||
|
||||
rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: TTY,
|
||||
crlfDelay: Infinity,
|
||||
historySize: 200,
|
||||
});
|
||||
|
||||
rl.on("line", (raw) => {
|
||||
const line = raw.trim();
|
||||
if (line.length === 0 || shuttingDown) {
|
||||
refreshPrompt();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith("!") && !line.startsWith("!!")) {
|
||||
handleCommand(line);
|
||||
return;
|
||||
}
|
||||
// "!!steer …" is how you send text that begins with the command prefix.
|
||||
queue.push(line.startsWith("!!") ? line.slice(1) : line);
|
||||
if (turnRunning()) {
|
||||
log(`buffered — will send when this turn ends (${queue.length} queued)`);
|
||||
if (!steerHinted) {
|
||||
steerHinted = true;
|
||||
log("…or use !steer <text> to inject into the running turn instead");
|
||||
}
|
||||
}
|
||||
refreshPrompt();
|
||||
void drain();
|
||||
});
|
||||
|
||||
rl.on("SIGINT", onInterrupt);
|
||||
|
||||
rl.on("close", () => {
|
||||
inputClosed = true;
|
||||
exitWhenIdle();
|
||||
});
|
||||
|
||||
void connection.closed.then(() => {
|
||||
if (!shuttingDown) void shutdown("connection closed");
|
||||
});
|
||||
|
||||
for (const entry of HELP) log(entry);
|
||||
log("Enter sends, Ctrl-C cancels a turn, Ctrl-D exits");
|
||||
refreshPrompt();
|
||||
|
||||
/**
|
||||
* Order matters here. The agent exits cleanly on stdin EOF (src/index.ts wires
|
||||
* `connection.closed` to `agent.dispose()`), whereas killing it skips disposal
|
||||
* and can orphan the Claude Agent SDK subprocess. Closing our connection first
|
||||
* would cancel the reader on the agent's stdout and make it die of EPIPE
|
||||
* mid-write, so that comes last.
|
||||
*/
|
||||
async function shutdown(reason: string, exitCode = 0) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log(`shutting down (${reason})`);
|
||||
queue = [];
|
||||
rl?.removeAllListeners("close");
|
||||
rl?.close();
|
||||
|
||||
if (turnRunning()) {
|
||||
if (!cancelRequested) {
|
||||
try {
|
||||
await agent.notify(methods.agent.session.cancel, { sessionId });
|
||||
} catch {
|
||||
// The connection may already be gone; teardown continues regardless.
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + CANCEL_TIMEOUT_MS;
|
||||
while (turnRunning() && Date.now() < deadline) await delay(50);
|
||||
}
|
||||
|
||||
child.stdin?.end();
|
||||
const exited = await Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
delay(agentReady ? EXIT_TIMEOUT_MS : 250).then(() => false),
|
||||
]);
|
||||
if (!exited) {
|
||||
log("agent did not exit on stdin EOF; sending SIGTERM");
|
||||
child.kill("SIGTERM");
|
||||
const stopped = await Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
delay(2_000).then(() => false),
|
||||
]);
|
||||
if (!stopped) child.kill("SIGKILL");
|
||||
}
|
||||
|
||||
connection.close();
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Streams the agent's stderr through writeBlock, flagging inspector messages. */
|
||||
function pumpAgentStderr(child: ReturnType<typeof spawn>) {
|
||||
let buffered = "";
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stderr?.on("data", (chunk: string) => {
|
||||
buffered += chunk;
|
||||
const lines = buffered.split("\n");
|
||||
buffered = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) continue;
|
||||
if (line.includes("Debugger listening on")) {
|
||||
writeBlock(paint("1;33", `[agent] ${line.trim()}`));
|
||||
} else if (line.includes("address already in use")) {
|
||||
writeBlock(paint("1;31", `[agent] ${line.trim()}`));
|
||||
log("that inspector port is taken — pass a different --debug-port");
|
||||
} else {
|
||||
writeBlock(dim(`[agent:stderr] ${line}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Tearing down rejects whatever request main() is awaiting; that is the
|
||||
// teardown finishing its job, not a failure, and shutdown() owns the exit.
|
||||
if (shuttingDown) return;
|
||||
log(`fatal: ${err?.stack ?? err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
221
examples/steering.ts
Normal file
221
examples/steering.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Example: mid-turn steering over ACP with the `_session/steering` extension.
|
||||
*
|
||||
* "Steering" lets a client deliver a follow-up message to a turn that is still
|
||||
* running, instead of waiting for it to finish and sending a fresh
|
||||
* `session/prompt`. This is what powers "the user typed something while the
|
||||
* agent was still working" — the new message joins the in-flight turn so the
|
||||
* agent can adapt immediately (it shines in multi-step / tool-using turns,
|
||||
* where the message slots in between tool calls).
|
||||
*
|
||||
* The wire protocol has three moving parts:
|
||||
*
|
||||
* 1. The agent advertises support in its `initialize` response, at the
|
||||
* top-level `_meta.steering` (a sibling of `agentCapabilities`).
|
||||
* 2. The client calls the `_session/steering` request with `{ sessionId,
|
||||
* prompt }` while a turn is running.
|
||||
* 3. The agent replies with an `outcome`:
|
||||
* - "injected" the message joined the running turn;
|
||||
* - "startedNewTurn" the turn had already finished and the legacy
|
||||
* detached fallback was used;
|
||||
* - "promptRequired" the turn had already finished, but this request
|
||||
* explicitly opted into Host-owned prompt delivery.
|
||||
*
|
||||
* This example launches the agent as a subprocess, starts a deliberately
|
||||
* long-running prompt, and — as soon as the agent begins streaming — injects a
|
||||
* steering message and prints the outcome. All agent output is streamed to
|
||||
* stdout so you can watch the turn change course.
|
||||
*
|
||||
* Run (build the agent first so `dist/index.js` exists):
|
||||
*
|
||||
* npm run build
|
||||
* node examples/steering.ts
|
||||
*
|
||||
* (Node < 22.18 needs `node --experimental-strip-types examples/steering.ts`.)
|
||||
*
|
||||
* Override the prompts with the PROMPT / STEER env vars. Requires the agent to
|
||||
* be authenticated, since it talks to the real model.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import {
|
||||
client as acpClient,
|
||||
methods,
|
||||
ndJsonStream,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
|
||||
/** The steering extension method, per the ACP steering wire protocol. */
|
||||
const STEERING_METHOD = "_session/steering";
|
||||
|
||||
/** Params for a `_session/steering` request — the same shape as the relevant
|
||||
* subset of a `session/prompt`. */
|
||||
type SteeringRequest = {
|
||||
sessionId: string;
|
||||
prompt: Array<{ type: "text"; text: string }>;
|
||||
_meta?: { steering?: { idleBehavior?: "promptRequired" } };
|
||||
};
|
||||
|
||||
/** Result of a `_session/steering` request. `injected` means the message joined
|
||||
* the running turn; `promptRequired` means the turn had already settled, so the
|
||||
* message was NOT consumed and must be (re)sent through a normal
|
||||
* `session/prompt`. Both are successes. */
|
||||
type SteeringResponse =
|
||||
| { outcome: "injected" }
|
||||
| { outcome: "startedNewTurn" }
|
||||
| { outcome: "promptRequired"; reason: "noRunningTurn" };
|
||||
|
||||
/** The existing steering capability advertised at the top-level `_meta.steering`
|
||||
* of the `initialize` result. The idle behavior is selected per request. */
|
||||
type SteeringCapability = {
|
||||
supported?: boolean;
|
||||
};
|
||||
|
||||
// The built agent entry. Run `npm run build` first so this exists.
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const AGENT_ENTRY = process.env.AGENT_ENTRY ?? path.join(repoRoot, "dist", "index.js");
|
||||
const CWD = process.env.CWD ?? process.cwd();
|
||||
|
||||
// A deliberately long-running first prompt, and the follow-up injected while it
|
||||
// is still streaming. Override either via env vars to experiment.
|
||||
const PROMPT =
|
||||
process.env.PROMPT ??
|
||||
"Count slowly from 1 to 30, one number per line, with a short sentence of " +
|
||||
"commentary after each. Do not stop early.";
|
||||
const STEER =
|
||||
process.env.STEER ?? "Actually stop counting and instead reply with exactly one line: STEERED-OK";
|
||||
|
||||
function log(msg: string) {
|
||||
process.stderr.write(`\x1b[2m[client]\x1b[0m ${msg}\n`);
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function main() {
|
||||
// An ACP client launches the agent as a subprocess and speaks JSON-RPC over
|
||||
// its stdin/stdout. stderr is inherited so the agent's own logs stay visible.
|
||||
const child = spawn(process.execPath, [AGENT_ENTRY], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: process.env,
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
log(`failed to spawn agent (${AGENT_ENTRY}): ${err}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const stream = ndJsonStream(
|
||||
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
|
||||
);
|
||||
|
||||
// Resolves the first time the agent streams assistant text — our signal that
|
||||
// the turn is genuinely underway and therefore steerable.
|
||||
let signalFirstOutput = () => {};
|
||||
const firstOutput = new Promise<void>((resolve) => (signalFirstOutput = resolve));
|
||||
|
||||
const connection = acpClient({ name: "steering-example" })
|
||||
.onNotification(methods.client.session.update, (ctx) => {
|
||||
const update = ctx.params.update;
|
||||
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
||||
process.stdout.write(update.content.text);
|
||||
signalFirstOutput();
|
||||
}
|
||||
})
|
||||
// Auto-approve permission prompts so the turn is never blocked on us.
|
||||
.onRequest(methods.client.session.requestPermission, (ctx) => {
|
||||
const options = ctx.params.options;
|
||||
const option = options.find((o) => o.kind === "allow_once") ?? options[0];
|
||||
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
||||
})
|
||||
// Minimal file-system stubs; the example prompts don't touch files.
|
||||
.onRequest(methods.client.fs.readTextFile, () => ({ content: "" }))
|
||||
.onRequest(methods.client.fs.writeTextFile, () => ({}))
|
||||
.connect(stream);
|
||||
|
||||
try {
|
||||
const agent = connection.agent;
|
||||
|
||||
// 1. Initialize and confirm the agent advertises steering. Per the wire
|
||||
// protocol the capability lives at the TOP-LEVEL `_meta` of the initialize
|
||||
// result — a sibling of `agentCapabilities`, not nested inside it.
|
||||
const init = await agent.request(methods.agent.initialize, {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
});
|
||||
const steering = (init._meta as { steering?: SteeringCapability } | null | undefined)
|
||||
?.steering;
|
||||
if (steering?.supported !== true) {
|
||||
throw new Error("agent does not advertise steering support");
|
||||
}
|
||||
log("agent advertises steering support");
|
||||
|
||||
// 2. Open a session.
|
||||
const { sessionId } = await agent.request(methods.agent.session.new, {
|
||||
cwd: CWD,
|
||||
mcpServers: [],
|
||||
});
|
||||
log(`session: ${sessionId}`);
|
||||
|
||||
// 3. Start a long turn, but DON'T await it yet — we need it in flight so we
|
||||
// can steer it. Its output streams through the notification handler above.
|
||||
log(`prompt: ${PROMPT}`);
|
||||
process.stdout.write("\n----- agent output -----\n");
|
||||
const turn = agent.request(methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: PROMPT }],
|
||||
});
|
||||
|
||||
// 4. Once the turn is producing output, inject the follow-up. Wait for the
|
||||
// first streamed chunk (with a fallback) plus a beat, so the steer clearly
|
||||
// lands mid-turn.
|
||||
await Promise.race([firstOutput, delay(5000)]);
|
||||
await delay(1000);
|
||||
|
||||
process.stdout.write("\n");
|
||||
log(`steer: ${STEER}`);
|
||||
const steerRequest: SteeringRequest = {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: STEER }],
|
||||
// Opt into the host-owned idle fallback. Without this request metadata,
|
||||
// the Adapter preserves its legacy `startedNewTurn` behavior.
|
||||
_meta: { steering: { idleBehavior: "promptRequired" } },
|
||||
};
|
||||
const result = await agent.request<SteeringResponse>(STEERING_METHOD, steerRequest);
|
||||
log(`steer outcome: ${result.outcome}`);
|
||||
|
||||
if (result.outcome === "promptRequired") {
|
||||
// The target turn already settled, so steering did not consume the message.
|
||||
// Start a normal session/prompt on the same session to deliver it — that
|
||||
// request owns the continuation's updates and terminal response.
|
||||
log(`steer fallback: ${result.reason}; starting a normal session/prompt`);
|
||||
const continuationRequest: PromptRequest = {
|
||||
sessionId: steerRequest.sessionId,
|
||||
prompt: steerRequest.prompt,
|
||||
};
|
||||
const continuation = await agent.request<PromptResponse, PromptRequest>(
|
||||
methods.agent.session.prompt,
|
||||
continuationRequest,
|
||||
);
|
||||
log(`continuation stopReason: ${continuation.stopReason}`);
|
||||
}
|
||||
|
||||
// 5. Await the original turn. With outcome "injected" the steer already
|
||||
// reshaped the output above; the promptRequired branch owns its own turn.
|
||||
const response = await turn;
|
||||
log(`original turn stopReason: ${response.stopReason}`);
|
||||
process.stdout.write("\n----- end of agent output -----\n");
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log(`fatal: ${err?.stack ?? err}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
1230
package-lock.json
generated
1230
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.58.1",
|
||||
"version": "0.79.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -29,11 +29,13 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "npm run build && npm run start",
|
||||
"example:simple-client": "node --experimental-strip-types --disable-warning=ExperimentalWarning examples/simple-client.ts",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"lint:fix": "eslint src --ext .ts --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"check": "npm run lint && npm run format:check",
|
||||
"release:preflight": "bash scripts/release-preflight.sh",
|
||||
"test": "vitest",
|
||||
"test:integration": "RUN_INTEGRATION_TESTS=true vitest run",
|
||||
"test:run": "vitest run",
|
||||
@@ -63,23 +65,23 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.205",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
"@agentclientprotocol/sdk": "1.4.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.274",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.110.0",
|
||||
"@anthropic-ai/sdk": "0.126.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.5",
|
||||
"@types/node": "26.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"eslint": "10.6.0",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.5.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.70.0",
|
||||
"@typescript-eslint/parser": "8.70.0",
|
||||
"eslint": "10.10.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.7.0",
|
||||
"prettier": "3.9.4",
|
||||
"globals": "17.12.0",
|
||||
"prettier": "3.9.7",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.10"
|
||||
"vitest": "5.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true,
|
||||
"include-component-in-tag": false,
|
||||
"packages": {
|
||||
".": {
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"release-type": "node",
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true
|
||||
"include-component-in-tag": false
|
||||
}
|
||||
},
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
||||
|
||||
202
scripts/next-preview-version.mjs
Executable file
202
scripts/next-preview-version.mjs
Executable file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Prints the next preview version for this package, e.g. `0.73.1-preview.4`.
|
||||
//
|
||||
// The base is package.json's version with the patch incremented, so a preview always
|
||||
// sorts above the last release and below whatever release follows it. `-preview.N`
|
||||
// counts from 1 per base and restarts whenever release-please moves package.json on.
|
||||
//
|
||||
// Basing the preview on release-please's *predicted* next version would be more
|
||||
// truthful — it would give `0.74.0-preview.N` when a `feat:` is queued — but the
|
||||
// prediction moves mid-flight: a `fix:` opens a 0.73.1 release PR, a later `feat:`
|
||||
// moves it to 0.74.0, and N resets under previews that were already published. If the
|
||||
// prediction ever moved down, the next preview would sort below the previous one.
|
||||
// package.json only ever increases, so patch+1 is monotonic in every case:
|
||||
// 0.73.1-preview.9 < 0.73.2-preview.1 and < 0.74.1-preview.1, whichever way the next
|
||||
// release goes. The cost is that `0.73.1-preview.4` is not a promise that 0.73.1 will
|
||||
// ship.
|
||||
//
|
||||
// N has to be a number nobody has used, and two sources are consulted because either
|
||||
// alone is unsafe:
|
||||
//
|
||||
// * the npm registry, authoritative for what is taken — npm versions are immutable
|
||||
// and stay reserved even after `npm unpublish`, so a taken N is a hard E403 at
|
||||
// publish time — but served from a CDN, so it can lag a publish by minutes;
|
||||
// * git tags, written by the workflow after each publish and strongly consistent,
|
||||
// which cover that window and any unpublish.
|
||||
//
|
||||
// The workflow publishes before it tags, so a version can exist on npm without a tag
|
||||
// but never the other way round. That asymmetry is why the registry is the authority
|
||||
// and the tags are only a floor, and why a registry read failure is fatal here rather
|
||||
// than falling back to the tags alone.
|
||||
//
|
||||
// See docs/RELEASES.md.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
||||
const PREVIEW_ID = "preview";
|
||||
const FETCH_ATTEMPTS = 3;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const BACKOFF_MS = 500;
|
||||
|
||||
/** X.Y.Z: no prerelease, no build metadata, no leading zeros. */
|
||||
const RELEASE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
|
||||
/** The base a preview anticipates: package.json's version with the patch bumped. */
|
||||
export function bumpPatch(version) {
|
||||
const match = RELEASE_VERSION.exec(String(version ?? "").trim());
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`expected a plain X.Y.Z release version, got ${JSON.stringify(version)}. ` +
|
||||
`release-please owns package.json's version; a prerelease or a range here means ` +
|
||||
`something else has edited it.`,
|
||||
);
|
||||
}
|
||||
const [, major, minor, patch] = match;
|
||||
return `${major}.${minor}.${Number(patch) + 1}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(literal) {
|
||||
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Matcher for `<base>-preview.<n>`. `0731-preview.1` would match a base of `0.73.1`. `n`
|
||||
* is a semver-legal numeric identifier, so leading zeros are rejected; npm would never
|
||||
* have accepted `preview.01` in the first place.
|
||||
*/
|
||||
export function previewPattern(base) {
|
||||
return new RegExp(`^${escapeRegExp(base)}-${PREVIEW_ID}\\.(0|[1-9]\\d*)$`);
|
||||
}
|
||||
|
||||
/** The N in `<base>-preview.N`, or null for anything else. */
|
||||
export function previewNumber(candidate, base) {
|
||||
const match = previewPattern(base).exec(String(candidate ?? "").trim());
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/** `refs/tags/v1.2.3`, `v1.2.3` and `1.2.3` all become `1.2.3`. */
|
||||
export function versionFromTag(tag) {
|
||||
return String(tag ?? "")
|
||||
.trim()
|
||||
.replace(/^refs\/tags\//, "")
|
||||
.replace(/^v/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest N among the candidates, or 0 when none match.
|
||||
*/
|
||||
export function highestPreviewNumber(candidates, base) {
|
||||
let highest = 0;
|
||||
for (const candidate of candidates) {
|
||||
const n = previewNumber(candidate, base);
|
||||
if (n !== null && n > highest) {
|
||||
highest = n;
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
export function nextPreviewVersion({ base, registryVersions = [], tags = [] }) {
|
||||
const taken = Math.max(
|
||||
highestPreviewNumber(registryVersions, base),
|
||||
highestPreviewNumber(tags.map(versionFromTag), base),
|
||||
);
|
||||
return `${base}-${PREVIEW_ID}.${taken + 1}`;
|
||||
}
|
||||
|
||||
/** Every version this package has ever published, per the registry. */
|
||||
export async function fetchPublishedVersions(name, options = {}) {
|
||||
const {
|
||||
registry = DEFAULT_REGISTRY,
|
||||
fetchImpl = fetch,
|
||||
attempts = FETCH_ATTEMPTS,
|
||||
backoffMs = BACKOFF_MS,
|
||||
} = options;
|
||||
const url = `${registry}/${name.replace("/", "%2f")}`;
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
// The abbreviated packument is a fraction of the full one — tens of kilobytes
|
||||
// rather than megabytes — and still carries every version key.
|
||||
headers: { accept: "application/vnd.npm.install-v1+json" },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
// Never published. A definite answer, not a failure.
|
||||
return [];
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} answered ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const packument = await response.json();
|
||||
// `versions` on a packument is always an object keyed by version, so there is
|
||||
// none of the "a single version comes back as a bare string" shape that
|
||||
// `npm view <pkg> versions --json` has.
|
||||
return Object.keys(packument.versions ?? {});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs * 2 ** (attempt - 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`could not read published versions for ${name} after ${attempts} attempts: ${lastError}. ` +
|
||||
`Refusing to guess a preview number: the registry is the only record of versions ` +
|
||||
`that were published and then unpublished, and those can never be reused.`,
|
||||
{ cause: lastError },
|
||||
);
|
||||
}
|
||||
|
||||
function localPreviewTags(base) {
|
||||
try {
|
||||
return execFileSync("git", ["tag", "--list", `v${base}-${PREVIEW_ID}.*`], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
// No git, no repository, or a clone without tags. Tags are only a floor under the
|
||||
// registry, so carry on rather than fail.
|
||||
process.stderr.write("warning: could not list git tags; using the registry alone\n");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readFlag(argv, name) {
|
||||
const index = argv.indexOf(name);
|
||||
return index === -1 ? undefined : argv[index + 1];
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
const packageJsonPath = readFlag(argv, "--package-json") ?? "package.json";
|
||||
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
const base = bumpPatch(manifest.version);
|
||||
const registryVersions = await fetchPublishedVersions(manifest.name, {
|
||||
registry: readFlag(argv, "--registry") ?? DEFAULT_REGISTRY,
|
||||
});
|
||||
const tags = argv.includes("--no-git-tags") ? [] : localPreviewTags(base);
|
||||
|
||||
// Diagnostics on stderr so stdout stays a single machine-readable line.
|
||||
process.stderr.write(
|
||||
`${manifest.name} ${manifest.version} -> base ${base}; registry high-water ` +
|
||||
`${highestPreviewNumber(registryVersions, base)}, tag high-water ` +
|
||||
`${highestPreviewNumber(tags.map(versionFromTag), base)}\n`,
|
||||
);
|
||||
process.stdout.write(`${nextPreviewVersion({ base, registryVersions, tags })}\n`);
|
||||
}
|
||||
|
||||
// Only when invoked as a program, so the helpers above can be imported by the test
|
||||
// without doing any I/O.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
main(process.argv.slice(2)).catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
249
scripts/next-preview-version.test.mjs
Normal file
249
scripts/next-preview-version.test.mjs
Normal file
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
bumpPatch,
|
||||
previewNumber,
|
||||
versionFromTag,
|
||||
highestPreviewNumber,
|
||||
nextPreviewVersion,
|
||||
fetchPublishedVersions,
|
||||
} from "./next-preview-version.mjs";
|
||||
|
||||
const PACKAGE = "@agentclientprotocol/claude-agent-acp";
|
||||
|
||||
/** A packument response, abbreviated the way the registry returns it. */
|
||||
function packument(versions) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ name: PACKAGE, versions }),
|
||||
};
|
||||
}
|
||||
|
||||
function failure(status, statusText = "Internal Server Error") {
|
||||
return { ok: false, status, statusText, json: async () => ({}) };
|
||||
}
|
||||
|
||||
describe("bumpPatch", () => {
|
||||
it("increments the patch", () => {
|
||||
expect(bumpPatch("0.73.0")).toBe("0.73.1");
|
||||
expect(bumpPatch("1.0.0")).toBe("1.0.1");
|
||||
expect(bumpPatch("0.0.0")).toBe("0.0.1");
|
||||
});
|
||||
|
||||
it("increments numerically rather than by concatenation", () => {
|
||||
expect(bumpPatch("0.73.9")).toBe("0.73.10");
|
||||
expect(bumpPatch("0.73.19")).toBe("0.73.20");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(bumpPatch(" 0.73.0 ")).toBe("0.73.1");
|
||||
});
|
||||
|
||||
// release-please owns package.json's version, so anything that is not a plain
|
||||
// release means something else has edited it. Failing loudly beats guessing.
|
||||
it.each([
|
||||
["a two-part version", "0.73"],
|
||||
["a four-part version", "0.73.0.1"],
|
||||
["a prerelease", "0.73.0-preview.1"],
|
||||
["build metadata", "0.73.0+build.1"],
|
||||
["a v prefix", "v0.73.0"],
|
||||
["a range", "^0.73.0"],
|
||||
["leading zeros", "01.2.3"],
|
||||
["the empty string", ""],
|
||||
["undefined", undefined],
|
||||
])("throws on %s", (_label, version) => {
|
||||
expect(() => bumpPatch(version)).toThrow(/plain X\.Y\.Z release version/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewNumber", () => {
|
||||
it("reads N out of a matching version", () => {
|
||||
expect(previewNumber("0.73.1-preview.1", "0.73.1")).toBe(1);
|
||||
expect(previewNumber("0.73.1-preview.10", "0.73.1")).toBe(10);
|
||||
});
|
||||
|
||||
it("treats zero as a number rather than as no match", () => {
|
||||
expect(previewNumber("0.73.1-preview.0", "0.73.1")).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a plain release", "0.73.1"],
|
||||
["a different base", "0.73.2-preview.1"],
|
||||
["a missing number", "0.73.1-preview"],
|
||||
["leading zeros in N", "0.73.1-preview.01"],
|
||||
["a dotted N", "0.73.1-preview.1.2"],
|
||||
["build metadata", "0.73.1-preview.1+build.5"],
|
||||
["another prerelease id", "0.73.1-alpha.1"],
|
||||
// Proves the base's dots are escaped rather than matching any character.
|
||||
["a base whose dots are elided", "0731-preview.1"],
|
||||
// Proves the pattern is anchored at the start.
|
||||
["a base with a numeric prefix", "10.73.1-preview.1"],
|
||||
])("returns null for %s", (_label, candidate) => {
|
||||
expect(previewNumber(candidate, "0.73.1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("versionFromTag", () => {
|
||||
it("strips a refs/tags/ prefix and a v", () => {
|
||||
expect(versionFromTag("refs/tags/v0.73.1-preview.3")).toBe("0.73.1-preview.3");
|
||||
expect(versionFromTag("v0.73.1")).toBe("0.73.1");
|
||||
expect(versionFromTag("0.73.1")).toBe("0.73.1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("highestPreviewNumber", () => {
|
||||
it("is 0 when nothing matches", () => {
|
||||
expect(highestPreviewNumber([], "0.73.1")).toBe(0);
|
||||
expect(highestPreviewNumber(["0.73.1", "0.72.1-preview.4"], "0.73.1")).toBe(0);
|
||||
});
|
||||
|
||||
// Sorted as strings, `preview.9` would beat `preview.10`.
|
||||
it("compares numerically, not lexicographically", () => {
|
||||
expect(highestPreviewNumber(["0.73.1-preview.9", "0.73.1-preview.10"], "0.73.1")).toBe(10);
|
||||
expect(highestPreviewNumber(["0.73.1-preview.10", "0.73.1-preview.9"], "0.73.1")).toBe(10);
|
||||
});
|
||||
|
||||
it("ignores order and duplicates", () => {
|
||||
const candidates = [
|
||||
"0.73.1-preview.2",
|
||||
"0.73.1-preview.7",
|
||||
"0.73.1-preview.2",
|
||||
"0.73.1-preview.5",
|
||||
];
|
||||
expect(highestPreviewNumber(candidates, "0.73.1")).toBe(7);
|
||||
});
|
||||
|
||||
it("counts only the requested base", () => {
|
||||
const candidates = ["0.72.1-preview.99", "0.73.1-preview.3", "0.74.1-preview.42"];
|
||||
expect(highestPreviewNumber(candidates, "0.73.1")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextPreviewVersion", () => {
|
||||
it("starts at 1 with no history", () => {
|
||||
expect(nextPreviewVersion({ base: "0.73.1" })).toBe("0.73.1-preview.1");
|
||||
});
|
||||
|
||||
it("continues from the registry", () => {
|
||||
const registryVersions = ["0.73.0", "0.73.1-preview.1", "0.73.1-preview.2", "0.73.1-preview.3"];
|
||||
expect(nextPreviewVersion({ base: "0.73.1", registryVersions })).toBe("0.73.1-preview.4");
|
||||
});
|
||||
|
||||
// The registry is CDN-served and can lag a publish by minutes; the tag the previous
|
||||
// run wrote is what closes that window.
|
||||
it("uses the tags as a floor when the registry is behind", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: "0.73.1",
|
||||
registryVersions: ["0.73.1-preview.1"],
|
||||
tags: ["refs/tags/v0.73.1-preview.3"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.4");
|
||||
});
|
||||
|
||||
// The converse: publish succeeded but the tag step did not, so the registry is
|
||||
// ahead. It stays authoritative.
|
||||
it("keeps the registry authoritative when the tags are behind", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: "0.73.1",
|
||||
registryVersions: ["0.73.1-preview.1", "0.73.1-preview.2", "0.73.1-preview.3"],
|
||||
tags: ["v0.73.1-preview.1"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.4");
|
||||
});
|
||||
|
||||
it("restarts at 1 once a release moves the base", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: "0.73.1",
|
||||
registryVersions: ["0.72.1-preview.7", "0.73.0"],
|
||||
tags: ["v0.72.1-preview.7"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.1");
|
||||
});
|
||||
|
||||
it("composes with bumpPatch", () => {
|
||||
const next = nextPreviewVersion({
|
||||
base: bumpPatch("0.73.0"),
|
||||
registryVersions: ["0.73.0"],
|
||||
tags: ["v0.73.0"],
|
||||
});
|
||||
expect(next).toBe("0.73.1-preview.1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchPublishedVersions", () => {
|
||||
const options = (fetchImpl) => ({ fetchImpl, backoffMs: 0 });
|
||||
|
||||
it("returns the version keys of the packument", async () => {
|
||||
const fetchImpl = vi.fn(async () => packument({ "0.73.0": {}, "0.73.1-preview.1": {} }));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([
|
||||
"0.73.0",
|
||||
"0.73.1-preview.1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests the abbreviated packument at the scope-encoded URL", async () => {
|
||||
const fetchImpl = vi.fn(async () => packument({}));
|
||||
await fetchPublishedVersions(PACKAGE, options(fetchImpl));
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe("https://registry.npmjs.org/@agentclientprotocol%2fclaude-agent-acp");
|
||||
expect(init.headers.accept).toBe("application/vnd.npm.install-v1+json");
|
||||
});
|
||||
|
||||
it("honours a custom registry", async () => {
|
||||
const fetchImpl = vi.fn(async () => packument({}));
|
||||
await fetchPublishedVersions(PACKAGE, {
|
||||
...options(fetchImpl),
|
||||
registry: "http://127.0.0.1:4873",
|
||||
});
|
||||
expect(fetchImpl.mock.calls[0][0]).toBe(
|
||||
"http://127.0.0.1:4873/@agentclientprotocol%2fclaude-agent-acp",
|
||||
);
|
||||
});
|
||||
|
||||
it("tolerates a packument with no versions", async () => {
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ name: PACKAGE }),
|
||||
}));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
// A 404 is a definite answer — the package has never been published — so it must not
|
||||
// be retried or turned into a failure.
|
||||
it("treats 404 as an empty history without retrying", async () => {
|
||||
const fetchImpl = vi.fn(async () => failure(404, "Not Found"));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual([]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries a server error and resolves", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(failure(500))
|
||||
.mockResolvedValueOnce(packument({ "0.73.0": {} }));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["0.73.0"]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries a network failure and resolves", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError("fetch failed"))
|
||||
.mockResolvedValueOnce(packument({ "0.73.0": {} }));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).resolves.toEqual(["0.73.0"]);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Guessing N after a failed read risks reusing a version that was published and then
|
||||
// unpublished, which npm reserves forever.
|
||||
it("gives up rather than guessing when every attempt fails", async () => {
|
||||
const fetchImpl = vi.fn(async () => failure(500));
|
||||
await expect(fetchPublishedVersions(PACKAGE, options(fetchImpl))).rejects.toThrow(
|
||||
/could not read published versions for @agentclientprotocol\/claude-agent-acp after 3 attempts/,
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
124
scripts/release-preflight.sh
Executable file
124
scripts/release-preflight.sh
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Preflight checks for a release.
|
||||
#
|
||||
# release-please decides what the next version is; this script only verifies the
|
||||
# repository is in a state where merging the open release PR produces a correct
|
||||
# release. Merging main needs no review, so these checks are the only thing
|
||||
# standing between a mistake and a published package — keep them as code rather
|
||||
# than as a list someone has to remember. See docs/RELEASES.md.
|
||||
#
|
||||
# Requires an authenticated GitHub CLI. Nothing else: gh has a jq engine builtin.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PENDING_LABEL="autorelease: pending"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
pass() {
|
||||
printf 'ok %s\n' "$1"
|
||||
}
|
||||
|
||||
command -v gh >/dev/null 2>&1 ||
|
||||
fail "GitHub CLI (gh) is not installed."
|
||||
gh auth status >/dev/null 2>&1 ||
|
||||
fail "GitHub CLI is not authenticated. Run 'gh auth login'."
|
||||
|
||||
repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null) ||
|
||||
fail "cannot work out which GitHub repository this is. Run from a checkout."
|
||||
|
||||
# A merged release PR that still carries the pending label means release-please
|
||||
# never tagged it. In that state it refuses to open any new release PR at all,
|
||||
# so every later release stalls silently until it is recovered by hand.
|
||||
stuck=$(gh pr list --repo "$repo" --state merged --label "$PENDING_LABEL" \
|
||||
--json number --jq 'map("#\(.number)") | join(", ")')
|
||||
[ -z "$stuck" ] ||
|
||||
fail "merged release PR still labelled '$PENDING_LABEL': $stuck.
|
||||
release-please is jammed and will not open new release PRs.
|
||||
See docs/RELEASES.md, 'Recovering a stalled release'."
|
||||
pass "no untagged merged release PR"
|
||||
|
||||
rows=$(gh pr list --repo "$repo" --state open --label "$PENDING_LABEL" \
|
||||
--json number,title,headRefName --jq '.[] | [.number, .headRefName, .title] | @tsv')
|
||||
count=$(printf '%s\n' "$rows" | grep -c . || true)
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
fail "no open release PR.
|
||||
Nothing has landed since the last tag that release-please puts in a
|
||||
changelog, so there is nothing to release. Land a feat/fix/perf/revert/docs
|
||||
change first, or check that the Publish and Release workflow is running."
|
||||
fi
|
||||
if [ "$count" -gt 1 ]; then
|
||||
fail "$count open release PRs, expected exactly one:
|
||||
$rows"
|
||||
fi
|
||||
IFS=$(printf '\t') read -r pr_number head_ref pr_title <<EOF
|
||||
$rows
|
||||
EOF
|
||||
pass "one open release PR: #$pr_number ($pr_title)"
|
||||
|
||||
# release-please reads the version to tag from the PR title, while npm publishes
|
||||
# whatever package.json says. If those disagree the tag and the published package
|
||||
# describe different releases, so check all three sources against each other.
|
||||
# The version is the last field, not everything after 'release': release-please
|
||||
# writes the component into the title as 'release <component> X.Y.Z' whenever it
|
||||
# is configured to tag with one.
|
||||
title_version=${pr_title##* }
|
||||
case $title_version in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) fail "cannot read a version from PR title '$pr_title'." ;;
|
||||
esac
|
||||
pkg_version=$(gh api "repos/$repo/contents/package.json?ref=$head_ref" \
|
||||
-H "Accept: application/vnd.github.raw" --jq .version)
|
||||
manifest_version=$(gh api "repos/$repo/contents/.release-please-manifest.json?ref=$head_ref" \
|
||||
-H "Accept: application/vnd.github.raw" --jq '."."')
|
||||
|
||||
if [ "$title_version" != "$pkg_version" ] || [ "$title_version" != "$manifest_version" ]; then
|
||||
fail "version mismatch on #$pr_number:
|
||||
PR title $title_version
|
||||
package.json $pkg_version
|
||||
.release-please-manifest.json $manifest_version"
|
||||
fi
|
||||
pass "version agrees across PR title, package.json and manifest: $title_version"
|
||||
|
||||
# The tag comes from the config, not from the PR title. With
|
||||
# include-component-in-tag left on, release-please tags <component>-vX.Y.Z, which
|
||||
# breaks the vX.Y.Z scheme the rest of this script and docs/RELEASES.md rely on,
|
||||
# and makes it rewrite the whole history into the changelog because no tag under
|
||||
# that scheme exists yet. The compare link it writes into the PR body names the
|
||||
# tags it is going to use, so check those rather than trusting the config.
|
||||
compare_tag=$(gh pr view "$pr_number" --repo "$repo" --json body --jq '
|
||||
.body | capture("/compare/[^)]*[.][.][.](?<to>[^)\\s]+)").to // ""' 2>/dev/null || true)
|
||||
case $compare_tag in
|
||||
"" | "v$title_version") ;;
|
||||
*) fail "release-please is going to tag '$compare_tag', not 'v$title_version'.
|
||||
Every previous release is tagged vX.Y.Z, and the changelog for #$pr_number
|
||||
covers the whole history rather than just this release, because no tag
|
||||
under that scheme exists. Set 'include-component-in-tag': false in
|
||||
release-please-config.json, then let it rewrite the release PR." ;;
|
||||
esac
|
||||
|
||||
if gh release view "v$title_version" --repo "$repo" >/dev/null 2>&1; then
|
||||
fail "tag v$title_version already exists. Merging would try to release it twice."
|
||||
fi
|
||||
pass "tag v$title_version does not exist yet"
|
||||
|
||||
build=$(gh pr view "$pr_number" --repo "$repo" --json statusCheckRollup --jq '
|
||||
[.statusCheckRollup[]? | select(.name == "Build")]
|
||||
| if length == 0 then "MISSING" else (.[0].conclusion // .[0].status // "PENDING") end')
|
||||
[ "$build" = "SUCCESS" ] ||
|
||||
fail "required check 'Build' is $build on #$pr_number, expected SUCCESS."
|
||||
pass "required check 'Build' passed"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Ready to release $title_version.
|
||||
|
||||
gh pr merge $pr_number --squash
|
||||
|
||||
Merging tags v$title_version, publishes to npm and updates the agent registry.
|
||||
EOF
|
||||
8495
src/acp-agent.ts
8495
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
118
src/acp-subagents.ts
Normal file
118
src/acp-subagents.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
SessionCapabilities,
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY,
|
||||
clientSupportsAirCapability,
|
||||
} from "./air-extension.js";
|
||||
|
||||
export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Temporary typed surface for agentclientprotocol/agent-client-protocol#1992.
|
||||
*
|
||||
* The wire contract is already defined by the ACP draft, but the published
|
||||
* TypeScript SDK does not contain it yet. Keep the compatibility boundary in
|
||||
* this file so it can be replaced by SDK exports without changing lifecycle
|
||||
* code when the draft ships.
|
||||
*/
|
||||
export type SubagentSessionCapabilities = {
|
||||
cancel?: boolean;
|
||||
close?: boolean;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type SubagentSpawnedUpdate = {
|
||||
sessionUpdate: "subagent_spawned";
|
||||
subagentSessionId: string;
|
||||
name: string;
|
||||
task: string;
|
||||
capabilities: SubagentSessionCapabilities;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type SubagentState = "completed" | "failed" | "cancelled" | "disconnected";
|
||||
|
||||
export type SubagentStateUpdate = {
|
||||
sessionUpdate: "subagent_state_update";
|
||||
subagentSessionId: string;
|
||||
state: SubagentState;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AsyncTaskState = "running" | "paused" | "completed" | "failed" | "stopped";
|
||||
|
||||
export type AsyncTaskSpawnedUpdate = {
|
||||
sessionUpdate: "async_task_spawned";
|
||||
asyncTaskId: string;
|
||||
name: string;
|
||||
taskType: string;
|
||||
description: string;
|
||||
showInTranscript: boolean;
|
||||
canStop: boolean;
|
||||
outputFilePath?: string;
|
||||
toolCallId?: string;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AsyncTaskProgressUpdate = {
|
||||
sessionUpdate: "async_task_progress";
|
||||
asyncTaskId: string;
|
||||
description?: string;
|
||||
summary?: string;
|
||||
lastToolName?: string;
|
||||
usage?: { totalTokens: number; toolUses: number; durationMs: number };
|
||||
/** Latest durable task log path. May arrive after spawn. */
|
||||
outputFilePath?: string;
|
||||
/** Originating tool call, when correlation becomes known after spawn. */
|
||||
toolCallId?: string;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AsyncTaskStateUpdate = {
|
||||
sessionUpdate: "async_task_state_update";
|
||||
asyncTaskId: string;
|
||||
state: AsyncTaskState;
|
||||
summary?: string;
|
||||
/** Latest durable task log path, including terminal-only SDK reports. */
|
||||
outputFilePath?: string;
|
||||
/** Originating tool call, including terminal-only late correlation. */
|
||||
toolCallId?: string;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type AcpSessionUpdate =
|
||||
| SessionNotification["update"]
|
||||
| SubagentSpawnedUpdate
|
||||
| SubagentStateUpdate
|
||||
| AsyncTaskSpawnedUpdate
|
||||
| AsyncTaskProgressUpdate
|
||||
| AsyncTaskStateUpdate;
|
||||
|
||||
export type AcpSessionNotification = Omit<SessionNotification, "update"> & {
|
||||
update: AcpSessionUpdate;
|
||||
};
|
||||
|
||||
export type SubagentAwareSessionCapabilities = SessionCapabilities & {
|
||||
subagents?: Record<string, never>;
|
||||
};
|
||||
|
||||
export function clientSupportsSubagents(capabilities?: ClientCapabilities | null): boolean {
|
||||
const subagents = (
|
||||
capabilities as (ClientCapabilities & { subagents?: unknown }) | null | undefined
|
||||
)?.subagents;
|
||||
if (typeof subagents === "object" && subagents !== null && !Array.isArray(subagents)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return clientSupportsAirCapability(capabilities, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY);
|
||||
}
|
||||
|
||||
/** The only cast needed until the TypeScript SDK publishes PR #1992. */
|
||||
export function asSdkSessionNotification(
|
||||
notification: AcpSessionNotification,
|
||||
): SessionNotification {
|
||||
return notification as SessionNotification;
|
||||
}
|
||||
77
src/air-extension.ts
Normal file
77
src/air-extension.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions";
|
||||
export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks";
|
||||
export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure";
|
||||
export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue";
|
||||
export const AIR_DIFF_STATS_KEY = "diffStats";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
const AIR_EXTENSION_VERSION_KEY = "version";
|
||||
const AIR_EXTENSION_CAPABILITIES_KEY = "capabilities";
|
||||
const AIR_EXTENSION_VERSION = 1;
|
||||
|
||||
/** The capability list this side advertises, as its own `_meta` object. */
|
||||
export function airCapabilityMeta(...capabilities: string[]) {
|
||||
return withAirMeta(undefined, AIR_EXTENSION_CAPABILITIES_KEY, capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges one AIR extension payload into an existing `_meta`.
|
||||
*
|
||||
* Every other namespace is preserved: an update can carry both agent-native
|
||||
* `claudeCode` metadata and an AIR payload, and two AIR payloads can share the
|
||||
* same `air` object. Pass `undefined` to build a fresh `_meta`.
|
||||
*/
|
||||
export function withAirMeta(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
capability: string,
|
||||
payload: unknown,
|
||||
): Record<string, unknown> {
|
||||
const jetbrains = asRecord(meta?.[JETBRAINS_META_KEY]);
|
||||
const air = asRecord(jetbrains[AIR_META_KEY]);
|
||||
return {
|
||||
...meta,
|
||||
[JETBRAINS_META_KEY]: {
|
||||
...jetbrains,
|
||||
[AIR_META_KEY]: {
|
||||
...air,
|
||||
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
|
||||
[capability]: payload,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The `air` object inside a `_meta`, or undefined when the peer sent no AIR extension. */
|
||||
export function airExtensionMeta(meta: unknown): Record<string, unknown> | undefined {
|
||||
const air = asRecord(asRecord(meta)[JETBRAINS_META_KEY])[AIR_META_KEY];
|
||||
return air && typeof air === "object" && !Array.isArray(air)
|
||||
? (air as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the peer advertised `capability`.
|
||||
*
|
||||
* Takes `unknown` because every caller is reading wire data: an ACP
|
||||
* `ClientCapabilities`, or a bag whose `_meta` was never validated.
|
||||
*/
|
||||
export function clientSupportsAirCapability(capabilities: unknown, capability: string): boolean {
|
||||
const air = airExtensionMeta(asRecord(capabilities)._meta);
|
||||
const version = air?.[AIR_EXTENSION_VERSION_KEY];
|
||||
const advertised = air?.[AIR_EXTENSION_CAPABILITIES_KEY];
|
||||
return (
|
||||
typeof version === "number" &&
|
||||
Number.isFinite(version) &&
|
||||
Number.isInteger(version) &&
|
||||
version >= AIR_EXTENSION_VERSION &&
|
||||
Array.isArray(advertised) &&
|
||||
advertised.includes(capability)
|
||||
);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
757
src/async-tasks.ts
Normal file
757
src/async-tasks.ts
Normal file
@@ -0,0 +1,757 @@
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import type { AcpSessionNotification, AsyncTaskState } from "./acp-subagents.js";
|
||||
import { AIR_ASYNC_TASKS_CAPABILITY, clientSupportsAirCapability } from "./air-extension.js";
|
||||
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
type TerminalSource = "event" | "level" | "shutdown";
|
||||
|
||||
type AsyncTask = {
|
||||
id: string;
|
||||
name: string;
|
||||
taskType: string;
|
||||
description: string;
|
||||
showInTranscript: boolean;
|
||||
outputFilePath?: string;
|
||||
toolCallId?: string;
|
||||
announced: boolean;
|
||||
ignored: boolean;
|
||||
startedObserved: boolean;
|
||||
panelOnlyRecovery: boolean;
|
||||
stopping: boolean;
|
||||
stopAnnounced: boolean;
|
||||
state: AsyncTaskState;
|
||||
terminalSummary?: string;
|
||||
terminalSource?: TerminalSource;
|
||||
};
|
||||
|
||||
type TaskIdentity = { taskId?: unknown; task_id?: unknown };
|
||||
|
||||
export type AsyncTaskStarted = TaskIdentity & {
|
||||
taskType?: unknown;
|
||||
task_type?: unknown;
|
||||
description?: unknown;
|
||||
subagentType?: unknown;
|
||||
subagent_type?: unknown;
|
||||
isBackgrounded?: unknown;
|
||||
is_backgrounded?: unknown;
|
||||
workflowName?: unknown;
|
||||
workflow_name?: unknown;
|
||||
skipTranscript?: unknown;
|
||||
skip_transcript?: unknown;
|
||||
outputFilePath?: unknown;
|
||||
output_file?: unknown;
|
||||
toolCallId?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
};
|
||||
|
||||
export type AsyncTaskUpdated = TaskIdentity & { patch?: TaskPatch };
|
||||
|
||||
type TaskPatch = {
|
||||
status?: unknown;
|
||||
description?: unknown;
|
||||
isBackgrounded?: unknown;
|
||||
is_backgrounded?: unknown;
|
||||
error?: unknown;
|
||||
outputFilePath?: unknown;
|
||||
output_file?: unknown;
|
||||
};
|
||||
|
||||
export type AsyncTaskNotification = TaskIdentity & {
|
||||
status?: unknown;
|
||||
summary?: unknown;
|
||||
outputFilePath?: unknown;
|
||||
output_file?: unknown;
|
||||
};
|
||||
|
||||
type TaskProgress = TaskIdentity & {
|
||||
description?: unknown;
|
||||
summary?: unknown;
|
||||
lastToolName?: unknown;
|
||||
last_tool_name?: unknown;
|
||||
usage?: unknown;
|
||||
outputFilePath?: unknown;
|
||||
output_file?: unknown;
|
||||
};
|
||||
|
||||
type BackgroundTaskLevel = TaskIdentity & {
|
||||
taskType?: unknown;
|
||||
task_type?: unknown;
|
||||
description?: unknown;
|
||||
};
|
||||
|
||||
export function clientSupportsAsyncTasks(capabilities?: ClientCapabilities | null): boolean {
|
||||
return clientSupportsAirCapability(capabilities, AIR_ASYNC_TASKS_CAPABILITY);
|
||||
}
|
||||
|
||||
/** Publishes Claude's non-agent background work as a separate AIR task lifecycle. */
|
||||
export class AsyncTaskRuntime {
|
||||
/**
|
||||
* One registry owns both active tasks and terminal tombstones. Keeping an
|
||||
* unannounced terminal record is intentional: the Bash result proving that
|
||||
* a command was backgrounded can arrive after its terminal SDK edge.
|
||||
*/
|
||||
private readonly tasks = new Map<string, AsyncTask>();
|
||||
|
||||
constructor(
|
||||
readonly enabled: boolean,
|
||||
private readonly sessionId: string,
|
||||
private readonly publish: Publish,
|
||||
) {}
|
||||
|
||||
async taskStarted(message: AsyncTaskStarted): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
const previousToolCallId = task.toolCallId;
|
||||
const wasAnnounced = task.announced;
|
||||
this.mergeStarted(task, message);
|
||||
if (
|
||||
isBackgroundTask(
|
||||
field(message, "isBackgrounded", "is_backgrounded"),
|
||||
field(message, "taskType", "task_type"),
|
||||
)
|
||||
) {
|
||||
await this.announce(task);
|
||||
}
|
||||
if (
|
||||
wasAnnounced &&
|
||||
(task.outputFilePath !== previousOutputFilePath || task.toolCallId !== previousToolCallId)
|
||||
) {
|
||||
await this.publishMetadata(task);
|
||||
}
|
||||
}
|
||||
|
||||
async taskBackgrounded(message: AsyncTaskStarted): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
const previousToolCallId = task.toolCallId;
|
||||
const wasAnnounced = task.announced;
|
||||
this.mergeStarted(task, message);
|
||||
await this.announce(task);
|
||||
if (
|
||||
wasAnnounced &&
|
||||
(task.outputFilePath !== previousOutputFilePath || task.toolCallId !== previousToolCallId)
|
||||
) {
|
||||
await this.publishMetadata(task);
|
||||
}
|
||||
}
|
||||
|
||||
async taskUpdated(message: AsyncTaskUpdated): Promise<void>;
|
||||
async taskUpdated(taskId: string, patch: TaskPatch): Promise<void>;
|
||||
async taskUpdated(taskOrId: string | AsyncTaskUpdated, legacyPatch?: TaskPatch): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const taskId = typeof taskOrId === "string" ? nonBlankString(taskOrId) : taskIdOf(taskOrId);
|
||||
const patch = typeof taskOrId === "string" ? legacyPatch : taskOrId.patch;
|
||||
if (!taskId || !patch) return;
|
||||
|
||||
const task = this.task(taskId);
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
this.mergePatch(task, patch);
|
||||
if (field(patch, "isBackgrounded", "is_backgrounded") === true) {
|
||||
await this.announce(task);
|
||||
}
|
||||
|
||||
const state = taskState(patch.status);
|
||||
if (!state) {
|
||||
if (task.announced && task.outputFilePath !== previousOutputFilePath) {
|
||||
await this.publishMetadata(task);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "running" || state === "paused") {
|
||||
if (isTerminal(task.state)) {
|
||||
if (task.announced && task.outputFilePath !== previousOutputFilePath) {
|
||||
await this.publishMetadata(task);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (task.state !== state) {
|
||||
task.state = state;
|
||||
if (task.announced) await this.publishState(task, state, nonBlankString(patch.error));
|
||||
} else if (task.announced && task.outputFilePath !== previousOutputFilePath) {
|
||||
await this.publishMetadata(task);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.finish(task, state, nonBlankString(patch.error), "event");
|
||||
}
|
||||
|
||||
async taskProgress(message: TaskProgress): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task || !task.announced || task.ignored) return;
|
||||
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
this.mergeOutputFilePath(task, message);
|
||||
const outputChanged = task.outputFilePath !== previousOutputFilePath;
|
||||
if (isTerminal(task.state)) {
|
||||
if (outputChanged) await this.publishMetadata(task);
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = taskUsage(message.usage);
|
||||
const description = nonBlankString(message.description);
|
||||
const summary = nonBlankString(message.summary);
|
||||
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
|
||||
await this.publishProgress(task, {
|
||||
...(description ? { description } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(lastToolName ? { lastToolName } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(outputChanged && task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async taskNotification(message: AsyncTaskNotification): Promise<void>;
|
||||
async taskNotification(
|
||||
taskId: string,
|
||||
status: unknown,
|
||||
summary?: unknown,
|
||||
outputFilePath?: unknown,
|
||||
): Promise<void>;
|
||||
async taskNotification(
|
||||
taskOrId: string | AsyncTaskNotification,
|
||||
legacyStatus?: unknown,
|
||||
legacySummary?: unknown,
|
||||
legacyOutputFilePath?: unknown,
|
||||
): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const message: AsyncTaskNotification =
|
||||
typeof taskOrId === "string"
|
||||
? {
|
||||
taskId: taskOrId,
|
||||
status: legacyStatus,
|
||||
summary: legacySummary,
|
||||
outputFilePath: legacyOutputFilePath,
|
||||
}
|
||||
: taskOrId;
|
||||
const taskId = taskIdOf(message);
|
||||
const state = taskState(message.status);
|
||||
if (!taskId || !state || state === "running" || state === "paused") return;
|
||||
|
||||
const task = this.task(taskId);
|
||||
const wasTerminal = isTerminal(task.state);
|
||||
const correctsLevelState = wasTerminal && task.terminalSource === "level";
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
this.mergeOutputFilePath(task, message);
|
||||
await this.finish(task, state, nonBlankString(message.summary), "event");
|
||||
if (
|
||||
wasTerminal &&
|
||||
!correctsLevelState &&
|
||||
task.announced &&
|
||||
task.outputFilePath !== previousOutputFilePath
|
||||
) {
|
||||
await this.publishMetadata(task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the SDK's replace-semantics background task level. It does not
|
||||
* create unknown tasks because this level normally precedes task_started and
|
||||
* carries no tool-call attribution. It can, however, promote a known
|
||||
* foreground task and terminate an announced task whose edge was lost.
|
||||
*/
|
||||
async backgroundTasksChanged(
|
||||
tasksOrMessage: readonly BackgroundTaskLevel[] | { tasks?: unknown },
|
||||
): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const value = Array.isArray(tasksOrMessage)
|
||||
? tasksOrMessage
|
||||
: isRecord(tasksOrMessage)
|
||||
? tasksOrMessage.tasks
|
||||
: undefined;
|
||||
if (!Array.isArray(value)) return;
|
||||
|
||||
const live = new Set<string>();
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue;
|
||||
const taskId = taskIdOf(item);
|
||||
if (!taskId) continue;
|
||||
live.add(taskId);
|
||||
const task = this.task(taskId);
|
||||
if (task.ignored || isTerminal(task.state)) continue;
|
||||
if (field(item, "taskType", "task_type") === "local_agent") {
|
||||
task.ignored = true;
|
||||
continue;
|
||||
}
|
||||
if (!task.startedObserved) {
|
||||
// The replace-level proves liveness but carries neither transcript
|
||||
// policy nor full identity. Recover it for the Async Tasks panel only;
|
||||
// creating a transcript card here would be irreversible if a late
|
||||
// task_started says skip_transcript=true.
|
||||
task.panelOnlyRecovery = true;
|
||||
task.showInTranscript = false;
|
||||
}
|
||||
this.mergeLevel(task, item);
|
||||
await this.announce(task);
|
||||
}
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.announced && !task.ignored && !isTerminal(task.state) && !live.has(task.id)) {
|
||||
// This replace-semantics level is itself the authoritative liveness
|
||||
// boundary. Close immediately so a lost terminal edge cannot leave a
|
||||
// permanent running card; a following task_notification may correct
|
||||
// the best-effort stopped state to completed/failed.
|
||||
await this.finish(task, "stopped", undefined, "level");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async finishAll(state: Extract<AsyncTaskState, "failed" | "stopped">): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.announced && !task.ignored && !isTerminal(task.state)) {
|
||||
try {
|
||||
await this.finish(task, state, undefined, "shutdown");
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to finish async tasks");
|
||||
}
|
||||
|
||||
canStop(taskId: string): boolean {
|
||||
const task = this.tasks.get(taskId);
|
||||
return task?.announced === true && !task.ignored && !task.stopping && !isTerminal(task.state);
|
||||
}
|
||||
|
||||
claimStop(taskId: string): boolean {
|
||||
if (!this.canStop(taskId)) return false;
|
||||
this.tasks.get(taskId)!.stopping = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
releaseStop(taskId: string): void {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (task && !isTerminal(task.state)) task.stopping = false;
|
||||
}
|
||||
|
||||
async taskStopped(taskId: string): Promise<void> {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task || !task.announced || task.ignored || task.stopAnnounced) return;
|
||||
task.stopAnnounced = true;
|
||||
// No terminal summary: a stopped task leaves the Async Tasks panel at once,
|
||||
// so anything said there is said to nobody.
|
||||
await this.finish(task, "stopped", undefined, "event");
|
||||
// The transcript is where the acknowledgement has to land, and it is the
|
||||
// only one the user gets -- the SDK injects nothing into the model's
|
||||
// context for a stopped shell task.
|
||||
//
|
||||
// Terminal state deliberately does not gate this. The SDK's own
|
||||
// `task_notification` routinely wins the race against the `stopTask`
|
||||
// response that brought us here, so by now the task is usually already
|
||||
// terminal: gating on that would silence the very stop the user asked for.
|
||||
// `stopAnnounced` is what keeps this to one line per task.
|
||||
//
|
||||
// `showInTranscript` does not gate it either: that flag decides whether the
|
||||
// task owns a transcript *card* (a background Bash task is already drawn as
|
||||
// its tool call), not whether the agent may answer a direct user action.
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: `**Task stopped by user:** ${task.name}.` },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tasks.clear();
|
||||
}
|
||||
|
||||
private task(taskId: string): AsyncTask {
|
||||
let task = this.tasks.get(taskId);
|
||||
if (task) return task;
|
||||
task = {
|
||||
id: taskId,
|
||||
name: "Background task",
|
||||
taskType: "task",
|
||||
description: "Background task",
|
||||
showInTranscript: true,
|
||||
announced: false,
|
||||
ignored: false,
|
||||
startedObserved: false,
|
||||
panelOnlyRecovery: false,
|
||||
stopping: false,
|
||||
stopAnnounced: false,
|
||||
state: "running",
|
||||
};
|
||||
this.tasks.set(taskId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
private mergeStarted(task: AsyncTask, message: AsyncTaskStarted): void {
|
||||
task.startedObserved = true;
|
||||
const rawTaskType = field(message, "taskType", "task_type");
|
||||
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
|
||||
const description = nonBlankString(message.description);
|
||||
if (description) task.description = description;
|
||||
else if (task.description === "Background task")
|
||||
task.description = defaultDescription(task.taskType);
|
||||
task.name = nonBlankString(field(message, "workflowName", "workflow_name")) ?? task.description;
|
||||
const skipTranscript = field(message, "skipTranscript", "skip_transcript");
|
||||
if (task.panelOnlyRecovery) {
|
||||
// A level-only recovery was already announced without a transcript
|
||||
// entry. Keep that decision monotonic: a late task_started can enrich
|
||||
// the panel, but cannot retroactively create or mutate transcript UI.
|
||||
task.showInTranscript = false;
|
||||
} else if (skipTranscript !== undefined) {
|
||||
task.showInTranscript = skipTranscript !== true;
|
||||
}
|
||||
this.mergeOutputFilePath(task, message);
|
||||
const toolCallId = nonBlankString(field(message, "toolCallId", "tool_use_id"));
|
||||
if (toolCallId) task.toolCallId = toolCallId;
|
||||
}
|
||||
|
||||
private mergePatch(task: AsyncTask, patch: TaskPatch): void {
|
||||
const description = nonBlankString(patch.description);
|
||||
if (description) {
|
||||
task.description = description;
|
||||
task.name = description;
|
||||
}
|
||||
this.mergeOutputFilePath(task, patch);
|
||||
}
|
||||
|
||||
private mergeLevel(task: AsyncTask, level: BackgroundTaskLevel): void {
|
||||
const rawTaskType = field(level, "taskType", "task_type");
|
||||
if (rawTaskType !== undefined) task.taskType = friendlyTaskType(rawTaskType);
|
||||
const description = nonBlankString(level.description);
|
||||
if (description) {
|
||||
task.description = description;
|
||||
task.name = description;
|
||||
}
|
||||
}
|
||||
|
||||
private mergeOutputFilePath(
|
||||
task: AsyncTask,
|
||||
value: { outputFilePath?: unknown; output_file?: unknown },
|
||||
): void {
|
||||
const outputFilePath = nonBlankString(field(value, "outputFilePath", "output_file"));
|
||||
if (outputFilePath) task.outputFilePath = outputFilePath;
|
||||
}
|
||||
|
||||
private async announce(task: AsyncTask): Promise<void> {
|
||||
if (task.announced || task.ignored) return;
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_spawned",
|
||||
asyncTaskId: task.id,
|
||||
name: task.name,
|
||||
taskType: task.taskType,
|
||||
description: task.description,
|
||||
showInTranscript: task.showInTranscript,
|
||||
canStop: true,
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
},
|
||||
});
|
||||
task.announced = true;
|
||||
if (isTerminal(task.state)) {
|
||||
await this.publishState(task, task.state, task.terminalSummary);
|
||||
}
|
||||
}
|
||||
|
||||
private async finish(
|
||||
task: AsyncTask,
|
||||
state: Extract<AsyncTaskState, "completed" | "failed" | "stopped">,
|
||||
summary: string | undefined,
|
||||
source: TerminalSource,
|
||||
): Promise<void> {
|
||||
if (task.ignored) return;
|
||||
if (isTerminal(task.state)) {
|
||||
// A level event may precede the authoritative terminal edge. Correct its
|
||||
// best-effort stopped state if that edge later arrives.
|
||||
if (task.terminalSource !== "level" || source !== "event") return;
|
||||
}
|
||||
const previous = {
|
||||
state: task.state,
|
||||
terminalSummary: task.terminalSummary,
|
||||
terminalSource: task.terminalSource,
|
||||
};
|
||||
task.state = state;
|
||||
task.terminalSummary = summary;
|
||||
task.terminalSource = source;
|
||||
try {
|
||||
if (task.announced) await this.publishState(task, state, summary);
|
||||
} catch (error) {
|
||||
task.state = previous.state;
|
||||
task.terminalSummary = previous.terminalSummary;
|
||||
task.terminalSource = previous.terminalSource;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async publishMetadata(task: AsyncTask): Promise<void> {
|
||||
if (task.outputFilePath || task.toolCallId) {
|
||||
if (isTerminal(task.state)) {
|
||||
await this.publishState(task, task.state, task.terminalSummary);
|
||||
} else {
|
||||
await this.publishProgress(task, {
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async publishProgress(
|
||||
task: AsyncTask,
|
||||
update: {
|
||||
description?: string;
|
||||
summary?: string;
|
||||
lastToolName?: string;
|
||||
usage?: { totalTokens: number; toolUses: number; durationMs: number };
|
||||
outputFilePath?: string;
|
||||
toolCallId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_progress",
|
||||
asyncTaskId: task.id,
|
||||
...update,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async publishState(
|
||||
task: AsyncTask,
|
||||
state: AsyncTaskState,
|
||||
summary?: string,
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: task.id,
|
||||
state,
|
||||
...(summary ? { summary } : {}),
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Recovers background Bash lifecycle data exposed only on its tool result. */
|
||||
export function backgroundBashTaskFromToolResult(
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
): AsyncTaskStarted | undefined {
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const toolResults = content.flatMap(toolResultBlock);
|
||||
const bashResults = toolResults.filter((result) => toolUses[result.toolUseId]?.name === "Bash");
|
||||
if (bashResults.length === 0) return undefined;
|
||||
|
||||
const backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
|
||||
const distinctTaskIds = new Set([
|
||||
...backgroundResults.map((result) => result.taskId),
|
||||
...bashResults
|
||||
.map((result) => backgroundTaskIdFromText(result.content))
|
||||
.filter((taskId): taskId is string => taskId !== undefined),
|
||||
]);
|
||||
if (distinctTaskIds.size !== 1) return undefined;
|
||||
const taskId = [...distinctTaskIds][0];
|
||||
if (!taskId) return undefined;
|
||||
|
||||
const hintedToolUseIds = new Set(
|
||||
backgroundResults
|
||||
.filter((result) => result.taskId === taskId && result.toolUseId)
|
||||
.map((result) => result.toolUseId!),
|
||||
);
|
||||
let matches: typeof bashResults;
|
||||
if (hintedToolUseIds.size > 0) {
|
||||
matches = bashResults.filter((result) => hintedToolUseIds.has(result.toolUseId));
|
||||
} else {
|
||||
const pathMatches = bashResults.filter(
|
||||
(result) => asyncTaskOutputFilePath(result.content, taskId) !== undefined,
|
||||
);
|
||||
matches = pathMatches.length > 0 ? pathMatches : bashResults.length === 1 ? bashResults : [];
|
||||
}
|
||||
if (matches.length !== 1) return undefined;
|
||||
|
||||
const match = matches[0];
|
||||
const toolUse = toolUses[match.toolUseId];
|
||||
const input = isRecord(toolUse?.input) ? toolUse.input : undefined;
|
||||
return {
|
||||
taskId,
|
||||
taskType: "local_bash",
|
||||
description: nonBlankString(input?.command),
|
||||
isBackgrounded: true,
|
||||
outputFilePath: asyncTaskOutputFilePath(match.content, taskId),
|
||||
toolCallId: match.toolUseId,
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundTaskIdFromText(content: unknown): string | undefined {
|
||||
const text = textContent(content);
|
||||
if (!text) return undefined;
|
||||
const marker = "Command running in background with ID: ";
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) return undefined;
|
||||
const valueStart = start + marker.length;
|
||||
const valueEnd = text.indexOf(".", valueStart);
|
||||
return valueEnd < 0 ? undefined : nonBlankString(text.slice(valueStart, valueEnd));
|
||||
}
|
||||
|
||||
function structuredBackgroundResults(
|
||||
value: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
hintedToolUseId?: string,
|
||||
seen = new Set<unknown>(),
|
||||
): { taskId: string; toolUseId?: string }[] {
|
||||
if (typeof value !== "object" || value === null || seen.has(value)) return [];
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) =>
|
||||
structuredBackgroundResults(item, toolUses, hintedToolUseId, seen),
|
||||
);
|
||||
}
|
||||
|
||||
const directTaskId = nonBlankString(field(value, "backgroundTaskId", "background_task_id"));
|
||||
const directToolUseId =
|
||||
nonBlankString(field(value, "toolUseId", "tool_use_id")) ?? hintedToolUseId;
|
||||
const results: { taskId: string; toolUseId?: string }[] = directTaskId
|
||||
? [{ taskId: directTaskId, toolUseId: directToolUseId }]
|
||||
: [];
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "backgroundTaskId" || key === "background_task_id") continue;
|
||||
results.push(
|
||||
...structuredBackgroundResults(child, toolUses, toolUses[key] ? key : directToolUseId, seen),
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function toolResultBlock(value: unknown): { toolUseId: string; content?: unknown }[] {
|
||||
if (!isRecord(value) || value.type !== "tool_result") return [];
|
||||
const toolUseId = nonBlankString(field(value, "toolUseId", "tool_use_id"));
|
||||
return toolUseId ? [{ toolUseId, content: value.content }] : [];
|
||||
}
|
||||
|
||||
function asyncTaskOutputFilePath(content: unknown, taskId: string): string | undefined {
|
||||
const text = textContent(content);
|
||||
if (!text) return undefined;
|
||||
const marker = "Output is being written to: ";
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) return undefined;
|
||||
const valueStart = start + marker.length;
|
||||
const valueEnd = text.indexOf(". You will be notified", valueStart);
|
||||
if (valueEnd < 0) return undefined;
|
||||
const path = text.slice(valueStart, valueEnd).trim();
|
||||
const expectedPosixSuffix = `/tasks/${taskId}.output`;
|
||||
const expectedWindowsSuffix = `\\tasks\\${taskId}.output`;
|
||||
return path.endsWith(expectedPosixSuffix) || path.endsWith(expectedWindowsSuffix)
|
||||
? path
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function textContent(value: unknown): string | undefined {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value.map(textContent).filter((part): part is string => part !== undefined);
|
||||
return parts.length > 0 ? parts.join("") : undefined;
|
||||
}
|
||||
if (!isRecord(value)) return undefined;
|
||||
if (typeof value.text === "string") return value.text;
|
||||
return value.content === value ? undefined : textContent(value.content);
|
||||
}
|
||||
|
||||
function friendlyTaskType(value: unknown): string {
|
||||
if (value === "local_bash") return "shell";
|
||||
if (value === "local_workflow") return "workflow";
|
||||
if (value === "local_monitor" || value === "mcp") return "monitor";
|
||||
return nonBlankString(value) ?? "task";
|
||||
}
|
||||
|
||||
function defaultDescription(taskType: string): string {
|
||||
return taskType === "task" ? "Background task" : taskType[0].toUpperCase() + taskType.slice(1);
|
||||
}
|
||||
|
||||
function isBackgroundTask(isBackgrounded: unknown, taskType: unknown): boolean {
|
||||
if (isBackgrounded === true) return true;
|
||||
if (isBackgrounded === false) return false;
|
||||
return taskType !== "local_bash" && taskType !== "local_agent";
|
||||
}
|
||||
|
||||
function isSubagentTask(message: AsyncTaskStarted): boolean {
|
||||
return (
|
||||
field(message, "subagentType", "subagent_type") !== undefined ||
|
||||
field(message, "taskType", "task_type") === "local_agent"
|
||||
);
|
||||
}
|
||||
|
||||
function taskState(value: unknown): AsyncTaskState | undefined {
|
||||
if (value === "pending" || value === "running") return "running";
|
||||
if (value === "paused") return "paused";
|
||||
if (value === "completed") return "completed";
|
||||
if (value === "failed") return "failed";
|
||||
if (value === "killed" || value === "cancelled" || value === "stopped") return "stopped";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTerminal(state: AsyncTaskState): state is "completed" | "failed" | "stopped" {
|
||||
return state === "completed" || state === "failed" || state === "stopped";
|
||||
}
|
||||
|
||||
function taskUsage(
|
||||
value: unknown,
|
||||
): { totalTokens: number; toolUses: number; durationMs: number } | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const totalTokens = numberField(value, "totalTokens", "total_tokens");
|
||||
const toolUses = numberField(value, "toolUses", "tool_uses");
|
||||
const durationMs = numberField(value, "durationMs", "duration_ms");
|
||||
if (totalTokens === undefined || toolUses === undefined || durationMs === undefined)
|
||||
return undefined;
|
||||
return { totalTokens, toolUses, durationMs };
|
||||
}
|
||||
|
||||
function taskIdOf(value: TaskIdentity): string | undefined {
|
||||
return nonBlankString(field(value, "taskId", "task_id"));
|
||||
}
|
||||
|
||||
function field(value: object, camelCase: string, snakeCase: string): unknown {
|
||||
const record = value as Record<string, unknown>;
|
||||
return record[camelCase] !== undefined ? record[camelCase] : record[snakeCase];
|
||||
}
|
||||
|
||||
function numberField(
|
||||
value: Record<string, unknown>,
|
||||
camelCase: string,
|
||||
snakeCase: string,
|
||||
): number | undefined {
|
||||
const result = field(value, camelCase, snakeCase);
|
||||
return typeof result === "number" ? result : undefined;
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
337
src/auth-status.ts
Normal file
337
src/auth-status.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* `authStatus` — interim `_meta`-based ACP extension that reports which auth
|
||||
* identity the agent process itself uses (Claude subscription, API key,
|
||||
* gateway, external cloud credentials, or nothing).
|
||||
*
|
||||
* Push only. One carrier, connection-scoped (never per session): the
|
||||
* notification `_auth/status_update` → `{authStatus}`. The client never asks;
|
||||
* there is no request method. The empty `agentCapabilities._meta.authStatus`
|
||||
* marker means "this agent pushes its identity", nothing more.
|
||||
*
|
||||
* The agent reads the identity at these moments:
|
||||
* - `initialize`, with an asynchronous CLI probe. Nothing waits for it, and its
|
||||
* result is the first push of the connection;
|
||||
* - a completed `authenticate` or a `logout`;
|
||||
* - each `createSession`, from the session account. A refused session reports
|
||||
* too: the client must know which account was refused;
|
||||
* - each turn the `--hide-claude-auth` guard checks. The guard reads the
|
||||
* account anyway, so this costs nothing;
|
||||
* - the START of each user prompt, with an asynchronous CLI probe. This finds a
|
||||
* login or a logout done in another terminal, including the sign-in a client
|
||||
* performs after a refusal, because the retried prompt is a new prompt.
|
||||
* Nothing waits for it and the push goes out mid-turn if that is when the
|
||||
* read lands.
|
||||
*
|
||||
* A probe whose kind differs from a live session's account marks that session
|
||||
* for recreation under `--hide-claude-auth`. The mark is a flag, consumed at
|
||||
* the next turn boundary: a running turn is never interrupted. The probe judges
|
||||
* nothing itself — the next turn runs a new `initialize`, and the creation
|
||||
* guard decides on the account it reports.
|
||||
*
|
||||
* A read does not always cause a push. The agent sends `_auth/status_update`
|
||||
* only when the payload is different from the last one it sent.
|
||||
*
|
||||
* "Cannot determine" is expressed by silence: an agent that cannot read its own
|
||||
* identity pushes nothing, and the client shows "not reported". A known
|
||||
* logged-out state is a payload of its own (`kind: "none"`), which is what
|
||||
* distinguishes the two.
|
||||
*
|
||||
* Reporting only — there is no write path in v1. When the upstream RFD lands,
|
||||
* the same payload moves from `_meta` to first-class fields and this module
|
||||
* is retired.
|
||||
*/
|
||||
|
||||
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Change notification, agent → client. Fire and forget: clients that do not
|
||||
* know the method drop it silently, so it is sent unconditionally. */
|
||||
export const AUTH_STATUS_UPDATE_METHOD = "_auth/status_update";
|
||||
|
||||
/** Upper bound on one `claude auth status --json` run, so a hung CLI cannot
|
||||
* wedge every later probe that joins it. */
|
||||
export const AUTH_STATUS_PROBE_TIMEOUT_MS = 5000;
|
||||
|
||||
export type AuthStatusKind = "account" | "api_key" | "gateway" | "external" | "none";
|
||||
|
||||
export type AuthStatusAccount = {
|
||||
email?: string;
|
||||
organization?: string;
|
||||
/** Vendor plan/license string, not normalized. */
|
||||
plan?: string;
|
||||
};
|
||||
|
||||
export type AuthStatus = {
|
||||
kind: AuthStatusKind;
|
||||
/** Human-readable and usable as a UI string on its own. The "type" line:
|
||||
* "Claude Max", "Anthropic API key", "AWS Bedrock". */
|
||||
label: string;
|
||||
/** Optional second line carrying the specifics (key source, gateway host, …).
|
||||
* Clients render it under `label`, falling back to `account.email`. */
|
||||
detail?: string;
|
||||
account?: AuthStatusAccount;
|
||||
/** Vendor-namespaced extras, e.g. `{claudeCode: {...}}`. */
|
||||
vendor?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AuthStatusUpdateNotification = { authStatus: AuthStatus };
|
||||
|
||||
/** Advertisement carried in `agentCapabilities._meta.authStatus`, never a
|
||||
* status payload: its mere presence means "this agent pushes its identity",
|
||||
* and it stays empty. Informational — a client that never saw it still
|
||||
* accepts `_auth/status_update`, because there is nothing to ask for. */
|
||||
export type AuthStatusCapability = Record<string, never>;
|
||||
|
||||
export function authStatusCapability(): AuthStatusCapability {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Shape of `claude auth status --json`. Absent fields are omitted, not null,
|
||||
* and the logged-out case exits 1 while still printing valid JSON. */
|
||||
export type CliAuthStatus = {
|
||||
loggedIn?: boolean;
|
||||
authMethod?: string;
|
||||
apiProvider?: string;
|
||||
apiKeySource?: string;
|
||||
email?: string;
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
subscriptionType?: string;
|
||||
};
|
||||
|
||||
const NOT_LOGGED_IN: AuthStatus = {
|
||||
kind: "none",
|
||||
label: "Not logged in",
|
||||
};
|
||||
|
||||
/** Display names for the non-firstParty backends, where auth lives outside the
|
||||
* agent (AWS credentials, gcloud ADC, …). */
|
||||
const EXTERNAL_PROVIDER_LABELS: Record<string, string> = {
|
||||
bedrock: "AWS Bedrock",
|
||||
vertex: "Google Vertex AI",
|
||||
foundry: "Azure AI Foundry",
|
||||
anthropicAws: "Anthropic on AWS",
|
||||
anthropicGoogleCloud: "Anthropic on Google Cloud",
|
||||
mantle: "Mantle",
|
||||
};
|
||||
|
||||
export function notLoggedInAuthStatus(): AuthStatus {
|
||||
return { ...NOT_LOGGED_IN };
|
||||
}
|
||||
|
||||
/** ACP-level gateway auth (`authenticate` with `gateway`/`gateway-bedrock`).
|
||||
* The gateway owns the credentials, so it wins over anything the CLI reports. */
|
||||
export function gatewayAuthStatus(baseUrl?: string): AuthStatus {
|
||||
let host: string | undefined;
|
||||
if (baseUrl) {
|
||||
try {
|
||||
host = new URL(baseUrl).host;
|
||||
} catch {
|
||||
host = baseUrl;
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "gateway",
|
||||
label: "Custom model gateway",
|
||||
...(host ? { detail: host } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this `AccountInfo` say anything about the identity?
|
||||
*
|
||||
* The SDK always fills `apiProvider`, but the identity fields come from the
|
||||
* claude.ai profile: with an `apiKeyHelper` and no subscription login the
|
||||
* session reports `{apiProvider: "firstParty"}` and nothing else. That is "no
|
||||
* information", not "logged out" — an empty read must never be mapped to
|
||||
* `none`, or it destroys what the CLI probe already established.
|
||||
*
|
||||
* `tokenSource` is deliberately not a signal: the CLI sets it to the OAuth
|
||||
* token's origin (`CLAUDE_CODE_OAUTH_TOKEN`,
|
||||
* `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`, …), never to a key source, so it
|
||||
* cannot attribute a key and carries no plan of its own.
|
||||
*/
|
||||
export function accountInfoHasIdentitySignal(account: AccountInfo | undefined): boolean {
|
||||
if (!account) return false;
|
||||
return Boolean(
|
||||
account.apiKeySource ||
|
||||
account.subscriptionType ||
|
||||
(account.apiProvider && account.apiProvider !== "firstParty"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Session-time mapping from the SDK's `AccountInfo`, the richest source when
|
||||
* it is populated. Returns `undefined` when the account carries no identity
|
||||
* signal, so callers keep the status they already know. */
|
||||
export function fromAccountInfo(account: AccountInfo | undefined): AuthStatus | undefined {
|
||||
if (!account || !accountInfoHasIdentitySignal(account)) {
|
||||
return undefined;
|
||||
}
|
||||
return mapAuthFields({
|
||||
apiProvider: account.apiProvider,
|
||||
subscriptionType: account.subscriptionType,
|
||||
apiKeySource: account.apiKeySource,
|
||||
email: account.email,
|
||||
organization: account.organization,
|
||||
});
|
||||
}
|
||||
|
||||
/** Pre-session mapping from `claude auth status --json` stdout. Returns
|
||||
* `undefined` when the output is not the expected JSON object, so callers can
|
||||
* report "not known" instead of guessing. */
|
||||
export function fromCliStatus(stdout: string): AuthStatus | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
const status = parsed as CliAuthStatus;
|
||||
if (typeof status.loggedIn !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
// `loggedIn` tracks the claude.ai login only. On a 3P backend (Bedrock,
|
||||
// Vertex, …) it is false while the credentials live outside the agent, so the
|
||||
// backend must decide the kind before the logged-out shortcut runs.
|
||||
const externalBackend = Boolean(status.apiProvider && status.apiProvider !== "firstParty");
|
||||
if (!externalBackend && !status.loggedIn && !status.apiKeySource) {
|
||||
return notLoggedInAuthStatus();
|
||||
}
|
||||
return mapAuthFields({
|
||||
apiProvider: status.apiProvider,
|
||||
subscriptionType: status.subscriptionType,
|
||||
apiKeySource: status.apiKeySource,
|
||||
email: status.email,
|
||||
organization: status.orgName,
|
||||
});
|
||||
}
|
||||
|
||||
/** Single mapping table shared by both sources; see the `kind`/`label` table in
|
||||
* `extension-authstatus-meta.md`. Order matters: a 3P backend or a gateway
|
||||
* decides the kind before any firstParty identity fields are considered, and
|
||||
* an API key outranks a stored subscription login.
|
||||
*
|
||||
* Both can be reported at once — an `apiKeyHelper` in settings.json next to a
|
||||
* claude.ai OAuth login yields `apiKeySource` AND `subscriptionType`. Claude
|
||||
* Code's authentication precedence puts `apiKeyHelper` above the `/login`
|
||||
* subscription, so the key is what actually pays for the turn and is what we
|
||||
* report. */
|
||||
function mapAuthFields(fields: {
|
||||
apiProvider?: string;
|
||||
subscriptionType?: string;
|
||||
apiKeySource?: string;
|
||||
email?: string;
|
||||
organization?: string;
|
||||
}): AuthStatus {
|
||||
const { apiProvider, subscriptionType, apiKeySource, email, organization } = fields;
|
||||
|
||||
if (apiProvider === "gateway") {
|
||||
return gatewayAuthStatus();
|
||||
}
|
||||
if (apiProvider && apiProvider !== "firstParty") {
|
||||
return {
|
||||
kind: "external",
|
||||
label: EXTERNAL_PROVIDER_LABELS[apiProvider] ?? apiProvider,
|
||||
};
|
||||
}
|
||||
if (apiKeySource) {
|
||||
return {
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
// The source ("apiKeyHelper", "env", …) is the specifics line.
|
||||
detail: apiKeySource,
|
||||
};
|
||||
}
|
||||
if (subscriptionType) {
|
||||
// `account.plan` keeps the raw vendor string; only the label is titled.
|
||||
const account: AuthStatusAccount = { plan: subscriptionType };
|
||||
if (email) account.email = email;
|
||||
if (organization) account.organization = organization;
|
||||
return {
|
||||
kind: "account",
|
||||
label: planLabel(subscriptionType),
|
||||
// No detail: the client falls back to `account.email` for line 2.
|
||||
account,
|
||||
};
|
||||
}
|
||||
return notLoggedInAuthStatus();
|
||||
}
|
||||
|
||||
/** Names the plan with exactly one "Claude". A newer CLI already reports
|
||||
* "Claude Max", an older one reports "max", and both must read "Claude Max".
|
||||
* Each word is title-cased; a word that already carries capitals is kept. */
|
||||
function planLabel(plan: string): string {
|
||||
const titled = plan.replace(/\S+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1));
|
||||
return /^claude(\s|$)/i.test(plan) ? titled : `Claude ${titled}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two payloads describe the same login? Compared on the field that
|
||||
* identifies each kind — the key source for `api_key`, the email for
|
||||
* `account`, the kind alone for the rest, which carry no per-identity key.
|
||||
*/
|
||||
export function sameIdentity(a: AuthStatus, b: AuthStatus): boolean {
|
||||
if (a.kind !== b.kind) {
|
||||
return false;
|
||||
}
|
||||
switch (a.kind) {
|
||||
case "api_key":
|
||||
return a.detail === b.detail;
|
||||
case "account":
|
||||
return (a.account?.email ?? "") === (b.account?.email ?? "");
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds a fresh read into what is already known. Sources differ in richness for
|
||||
* the very same login: the SDK's session `AccountInfo` can carry an
|
||||
* organization the CLI probe omits. So when the read describes the same
|
||||
* identity, its values win field by field but the fields it lacks are kept;
|
||||
* when it describes a different identity, it replaces the old one wholesale.
|
||||
*/
|
||||
export function mergeAuthStatus(previous: AuthStatus | undefined, next: AuthStatus): AuthStatus {
|
||||
if (!previous || !sameIdentity(previous, next)) {
|
||||
return next;
|
||||
}
|
||||
const merged: AuthStatus = { ...next };
|
||||
if (merged.detail === undefined && previous.detail !== undefined) {
|
||||
merged.detail = previous.detail;
|
||||
}
|
||||
if (previous.account || next.account) {
|
||||
merged.account = { ...previous.account, ...next.account };
|
||||
}
|
||||
if (merged.vendor === undefined && previous.vendor !== undefined) {
|
||||
merged.vendor = previous.vendor;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do two payloads carry the same information? Unlike {@link sameIdentity},
|
||||
* which asks "is this the same login", this compares every rendered field, so
|
||||
* a richer read of one login is NOT the same. Callers use it to suppress a
|
||||
* push that would tell the client nothing new.
|
||||
*
|
||||
* `a` undefined means "nothing reported yet", which never equals a payload.
|
||||
*/
|
||||
export function sameAuthStatus(a: AuthStatus | undefined, b: AuthStatus): boolean {
|
||||
if (!a) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
a.kind === b.kind &&
|
||||
a.label === b.label &&
|
||||
a.detail === b.detail &&
|
||||
a.account?.email === b.account?.email &&
|
||||
a.account?.organization === b.account?.organization &&
|
||||
a.account?.plan === b.account?.plan &&
|
||||
// Vendor extras are an open bag, so compare them structurally. Both sides
|
||||
// are built by this module from the same key order, so a string compare is
|
||||
// enough and costs nothing on the usual `undefined`/`undefined` pair.
|
||||
JSON.stringify(a.vendor) === JSON.stringify(b.vendor)
|
||||
);
|
||||
}
|
||||
162
src/clear-context-coordinator.ts
Normal file
162
src/clear-context-coordinator.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { NewSessionRequest, SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
EffortLevel,
|
||||
Options,
|
||||
PermissionMode,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
|
||||
export type ClearContextReset = {
|
||||
toolUseId: string;
|
||||
plan: string;
|
||||
mode: PermissionMode;
|
||||
};
|
||||
|
||||
type Usage = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedReadTokens: number;
|
||||
cachedWriteTokens: number;
|
||||
};
|
||||
|
||||
export type ClearContextTurn = {
|
||||
settled: boolean;
|
||||
promptUuid: string;
|
||||
carriedUsage?: Usage;
|
||||
carriedModelUsage?: Record<string, Usage>;
|
||||
};
|
||||
|
||||
/** The session state needed to replace Claude's private conversation without
|
||||
* replacing the public ACP turn. Keeping this projection here makes the
|
||||
* restart workflow independent from the agent's much larger Session record. */
|
||||
export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn> = {
|
||||
cwd: string;
|
||||
creationParams?: NewSessionRequest;
|
||||
accumulatedUsage: Usage;
|
||||
accumulatedModelUsage?: Record<string, Usage>;
|
||||
models: { currentModelId: string };
|
||||
configOptions: SessionConfigOption[];
|
||||
fastModeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
turnQueue?: Turn[];
|
||||
pendingExitPlanContextReset?: ClearContextReset;
|
||||
contextUsedTokens?: number;
|
||||
input: { push(message: SDKUserMessage): unknown };
|
||||
};
|
||||
|
||||
export type ClearContextCoordinatorHost<
|
||||
Session extends ClearContextSession<Turn>,
|
||||
Turn extends ClearContextTurn,
|
||||
> = {
|
||||
currentSession(sessionId: string): Session | undefined;
|
||||
closeQueryStream(session: Session): void;
|
||||
restartSession(
|
||||
params: NewSessionRequest,
|
||||
options: { publicSessionId: string; permissionMode: PermissionMode },
|
||||
): Promise<Session>;
|
||||
applyFastMode(session: Session, enabled: boolean): Promise<void>;
|
||||
publishSessionState(
|
||||
sessionId: string,
|
||||
mode: PermissionMode,
|
||||
configOptions: SessionConfigOption[],
|
||||
): Promise<void>;
|
||||
continuationMessage(sessionId: string, plan: string, promptUuid: string): SDKUserMessage;
|
||||
ensureConsumer(session: Session, sessionId: string): void;
|
||||
logError(message: string, error: unknown): void;
|
||||
};
|
||||
|
||||
function restartParams<Session extends ClearContextSession>(session: Session): NewSessionRequest {
|
||||
const originalParams = session.creationParams ?? { cwd: session.cwd, mcpServers: [] };
|
||||
const originalMeta = originalParams._meta as
|
||||
({ claudeCode?: { options?: Options } } & Record<string, unknown>) | undefined;
|
||||
const originalOptions = originalMeta?.claudeCode?.options;
|
||||
const unmanagedOptions = { ...(originalOptions ?? {}) };
|
||||
delete unmanagedOptions.model;
|
||||
delete unmanagedOptions.agent;
|
||||
delete unmanagedOptions.effort;
|
||||
|
||||
return {
|
||||
...originalParams,
|
||||
_meta: {
|
||||
...(originalMeta ?? {}),
|
||||
claudeCode: {
|
||||
...(originalMeta?.claudeCode ?? {}),
|
||||
options: {
|
||||
...unmanagedOptions,
|
||||
...(session.models.currentModelId !== DEFAULT_MODEL_ID
|
||||
? { model: session.models.currentModelId }
|
||||
: {}),
|
||||
...(session.effortPinnedLevel !== undefined
|
||||
? { effort: session.effortPinnedLevel as EffortLevel }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace Claude's private conversation and attach the still-pending ACP turn
|
||||
* to it. The host owns provider-specific creation and stream mechanics; this
|
||||
* coordinator owns the ordering and state transfer invariants. */
|
||||
export async function continuePlanInFreshContext<
|
||||
Turn extends ClearContextTurn,
|
||||
Session extends ClearContextSession<Turn>,
|
||||
>(
|
||||
sessionId: string,
|
||||
oldSession: Session,
|
||||
reset: ClearContextReset,
|
||||
host: ClearContextCoordinatorHost<Session, Turn>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const assertRestartActive = (session?: Session): void => {
|
||||
if (signal?.aborted || (session && host.currentSession(sessionId) !== session)) {
|
||||
throw new Error("Clear-context restart aborted");
|
||||
}
|
||||
};
|
||||
assertRestartActive();
|
||||
const turn = oldSession.activeTurn;
|
||||
if (!turn || turn.settled || host.currentSession(sessionId) !== oldSession) {
|
||||
throw new Error("Cannot clear context without an active ACP turn");
|
||||
}
|
||||
|
||||
const params = restartParams(oldSession);
|
||||
host.closeQueryStream(oldSession);
|
||||
|
||||
const freshSession = await host.restartSession(params, {
|
||||
publicSessionId: sessionId,
|
||||
permissionMode: reset.mode,
|
||||
});
|
||||
assertRestartActive(freshSession);
|
||||
|
||||
// Do not consume the reset or mutate the turn until a replacement exists.
|
||||
// A failed restart must remain distinguishable from a lost query transport.
|
||||
turn.carriedUsage = { ...oldSession.accumulatedUsage };
|
||||
turn.carriedModelUsage = { ...oldSession.accumulatedModelUsage };
|
||||
oldSession.pendingExitPlanContextReset = undefined;
|
||||
if (oldSession.fastModeEnabled !== freshSession.fastModeEnabled) {
|
||||
try {
|
||||
await host.applyFastMode(freshSession, oldSession.fastModeEnabled);
|
||||
} catch (error) {
|
||||
host.logError("Failed to restore Fast mode after clearing context:", error);
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
}
|
||||
|
||||
oldSession.activeTurn = null;
|
||||
oldSession.turnQueue = [];
|
||||
freshSession.turnQueue = [turn];
|
||||
freshSession.contextUsedTokens = 0;
|
||||
|
||||
try {
|
||||
await host.publishSessionState(sessionId, reset.mode, freshSession.configOptions);
|
||||
} catch (error) {
|
||||
host.logError("Failed to publish clear-context session state:", error);
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
|
||||
freshSession.input.push(host.continuationMessage(sessionId, reset.plan, turn.promptUuid));
|
||||
assertRestartActive(freshSession);
|
||||
host.ensureConsumer(freshSession, sessionId);
|
||||
}
|
||||
29
src/context-compaction-meta.ts
Normal file
29
src/context-compaction-meta.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const CONTEXT_COMPACTION_META_KEY = "contextCompaction";
|
||||
export const CONTEXT_COMPACTION_META_VERSION = 1;
|
||||
|
||||
export type ContextCompactionTrigger = "manual" | "automatic";
|
||||
|
||||
export interface ContextCompactionMetadata {
|
||||
version: typeof CONTEXT_COMPACTION_META_VERSION;
|
||||
trigger?: ContextCompactionTrigger;
|
||||
preTokens?: number;
|
||||
postTokens?: number;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-neutral metadata for a synthetic ACP context-compaction tool call.
|
||||
* The standard toolCallId and status fields own lifecycle identity and phase;
|
||||
* this extension carries only compaction-specific facts.
|
||||
*/
|
||||
export function createContextCompactionMeta(
|
||||
metadata: Omit<ContextCompactionMetadata, "version"> = {},
|
||||
): Record<typeof CONTEXT_COMPACTION_META_KEY, ContextCompactionMetadata> {
|
||||
return {
|
||||
[CONTEXT_COMPACTION_META_KEY]: {
|
||||
version: CONTEXT_COMPACTION_META_VERSION,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
422
src/context-compaction.ts
Normal file
422
src/context-compaction.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
ContextCompactionMetadata,
|
||||
createContextCompactionMeta,
|
||||
} from "./context-compaction-meta.js";
|
||||
|
||||
type CompactionStatus = "completed" | "failed";
|
||||
type TerminalStatus = CompactionStatus | "cancelled";
|
||||
|
||||
/**
|
||||
* How compaction reaches the client.
|
||||
*
|
||||
* - `compaction_update`: the ACP session-compaction contract (ID-addressed
|
||||
* `compaction_update` / `compaction_summary_chunk`), used when the v1 client
|
||||
* advertises `clientCapabilities.session.compaction`.
|
||||
* - `tool_call`: the legacy synthetic "Compact conversation" tool call, kept
|
||||
* for clients that do not advertise the capability.
|
||||
*/
|
||||
export type CompactionPresentation = "tool_call" | "compaction_update";
|
||||
|
||||
type CompactionState = {
|
||||
/** Doubles as the toolCallId under the legacy presentation. */
|
||||
compactionId: string;
|
||||
terminalStatus?: TerminalStatus;
|
||||
heartbeatSent: boolean;
|
||||
/** User-displayable summary reported by the PostCompact hook. While the
|
||||
* compaction is in progress it awaits the terminal update; on a `completed`
|
||||
* state it has been sent. */
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
type SendUpdate = (notification: SessionNotification) => Promise<void>;
|
||||
|
||||
export type ContextCompactionLifecycleOptions = {
|
||||
sessionId: string;
|
||||
presentation?: CompactionPresentation;
|
||||
/** Receives failures of the send that must not propagate: the turn-boundary
|
||||
* `cancelled` terminal. */
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
export function clientSupportsCompactionUpdates(capabilities?: ClientCapabilities | null): boolean {
|
||||
const compaction = capabilities?.session?.compaction;
|
||||
return typeof compaction === "object" && compaction !== null && !Array.isArray(compaction);
|
||||
}
|
||||
|
||||
/** Tags on their own line, as the model emits them; a summary that merely
|
||||
* quotes `<summary>` mid-sentence must not be truncated at the mention. */
|
||||
const SUMMARY_OPEN_LINE = /^[ \t]*<summary>[ \t]*\r?\n/gim;
|
||||
const SUMMARY_CLOSE_LINE = /^[ \t]*<\/summary>[ \t]*$/gim;
|
||||
/** The model's private reasoning block that precedes the summary; stripped
|
||||
* first so a `<summary>` it merely mentions cannot anchor the summary span,
|
||||
* and even when the summary tags themselves are missing. Own-line tags
|
||||
* anywhere; inline tags only as a leading block, since a summary body may
|
||||
* quote both tag names. */
|
||||
const ANALYSIS_BLOCKS = [
|
||||
/^[ \t]*<analysis>[ \t]*\r?\n[\s\S]*?\n[ \t]*<\/analysis>[ \t]*$/gim,
|
||||
/^\s*<analysis>[\s\S]*?<\/analysis>/i,
|
||||
];
|
||||
/** The framing Claude Code wraps around the retained summary when it persists
|
||||
* it as the post-compaction user message. */
|
||||
const CONTINUATION_PREAMBLE = /^\s*This session is being continued from a previous conversation/i;
|
||||
const PERSISTED_PREAMBLE =
|
||||
/^\s*This session is being continued from a previous conversation[^\n]*\n+Summary:[ \t]*\r?\n/;
|
||||
const PERSISTED_TRAILERS = [
|
||||
/\n+If you need specific details from before compaction[\s\S]*$/,
|
||||
/\n+(?:Please )?[Cc]ontinue the conversation from where it left (?:it )?off[\s\S]*$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Reduce Claude's compaction output to the user-displayable summary.
|
||||
*
|
||||
* The PostCompact hook reports the model's raw output, which wraps the
|
||||
* summary in `<summary>` tags (often preceded by an `<analysis>` block). The
|
||||
* persisted transcript instead frames the same summary with continuation
|
||||
* instructions for the model. Neither framing belongs in the ACP `summary`,
|
||||
* and a continuation prompt whose framing is not recognized yields no summary
|
||||
* at all rather than model-facing instructions.
|
||||
*/
|
||||
export function compactionSummaryText(raw: string): string | undefined {
|
||||
let text = raw;
|
||||
for (const block of ANALYSIS_BLOCKS) {
|
||||
text = text.replace(block, "");
|
||||
}
|
||||
const tagged = taggedSummary(text);
|
||||
if (tagged !== undefined) {
|
||||
text = tagged;
|
||||
} else if (CONTINUATION_PREAMBLE.test(text)) {
|
||||
const body = text.replace(PERSISTED_PREAMBLE, "");
|
||||
if (body === text) return undefined;
|
||||
text = body;
|
||||
for (const trailer of PERSISTED_TRAILERS) {
|
||||
text = text.replace(trailer, "");
|
||||
}
|
||||
}
|
||||
text = text.trim();
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function taggedSummary(raw: string): string | undefined {
|
||||
const close = [...raw.matchAll(SUMMARY_CLOSE_LINE)].at(-1);
|
||||
if (close?.index !== undefined) {
|
||||
const open = [...raw.slice(0, close.index).matchAll(SUMMARY_OPEN_LINE)].at(-1);
|
||||
if (open?.index !== undefined) {
|
||||
return raw.slice(open.index + open[0].length, close.index);
|
||||
}
|
||||
}
|
||||
// Inline tags: the widest span, so a quoted `</summary>` inside the body
|
||||
// does not end it early. Plain index scans keep an unterminated tag linear.
|
||||
const openInline = raw.search(/<summary>/i);
|
||||
const closeInline = raw.toLowerCase().lastIndexOf("</summary>");
|
||||
if (openInline >= 0 && closeInline > openInline) {
|
||||
return raw.slice(openInline + "<summary>".length, closeInline);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True for the post-compaction user message Claude Code persists (and
|
||||
* `getSessionMessages` returns) with the summary of the dropped history. */
|
||||
export function isCompactSummaryMessage(message: unknown): boolean {
|
||||
return (
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
(message as { type?: unknown }).type === "user" &&
|
||||
(message as { isCompactSummary?: unknown }).isCompactSummary === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates Claude's compaction signals into one idempotent ACP lifecycle.
|
||||
*
|
||||
* The SDK can duplicate terminal compact_result messages and can omit the
|
||||
* opening status on replay. State therefore lives until the owning turn's
|
||||
* result (or abort), while a new compacting status after a terminal outcome
|
||||
* starts a fresh lifecycle.
|
||||
*/
|
||||
export class ContextCompactionLifecycle {
|
||||
private activeCompaction: CompactionState | undefined;
|
||||
private outputDelivered = false;
|
||||
private duplicateErrorOutput: string | undefined;
|
||||
/** Summary reported by PostCompact before the opening status was consumed.
|
||||
* Deliberately survives `reset()`: the CLI awaits the hook before emitting
|
||||
* the compaction's terminal frames, so a pending summary always belongs to
|
||||
* a compaction whose frames are still ahead of the consumer — possibly
|
||||
* queued behind a previous turn's result. */
|
||||
private pendingSummary: string | undefined;
|
||||
private readonly sessionId: string;
|
||||
readonly presentation: CompactionPresentation;
|
||||
private readonly logError: (message: string, error: unknown) => void;
|
||||
|
||||
constructor(
|
||||
private readonly sendUpdate: SendUpdate,
|
||||
options: ContextCompactionLifecycleOptions,
|
||||
) {
|
||||
this.sessionId = options.sessionId;
|
||||
this.presentation = options.presentation ?? "tool_call";
|
||||
this.logError = options.logError ?? (() => {});
|
||||
}
|
||||
|
||||
get hasDeliveredOutput(): boolean {
|
||||
return this.outputDelivered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the lifecycle at a turn boundary (result, cancel, idle-abandon).
|
||||
*
|
||||
* A `compaction_update` entity still `in_progress` here never received its
|
||||
* terminal signal from the runtime and gets its one terminal status,
|
||||
* `cancelled`. The legacy tool call is deliberately left as it was (an
|
||||
* `in_progress` call): ACP `ToolCallStatus` has no cancelled state and that
|
||||
* presentation's behavior predates this lifecycle. Never throws — a failed
|
||||
* send is logged, so callers can settle the turn unconditionally.
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
const state = this.activeCompaction;
|
||||
this.activeCompaction = undefined;
|
||||
this.outputDelivered = false;
|
||||
this.duplicateErrorOutput = undefined;
|
||||
if (!state || state.terminalStatus || this.presentation !== "compaction_update") return;
|
||||
state.terminalStatus = "cancelled";
|
||||
await this.sendQuietly(
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: state.compactionId,
|
||||
status: "cancelled",
|
||||
},
|
||||
"cancelled compaction",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude also emits a failed manual compaction's error as local-command
|
||||
* stdout. Consume that one duplicate after the lifecycle carried it,
|
||||
* without hiding unrelated command output.
|
||||
*/
|
||||
consumeDuplicateErrorOutput(content: string): boolean {
|
||||
if (
|
||||
this.duplicateErrorOutput === undefined ||
|
||||
content.trim() !== this.duplicateErrorOutput.trim()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.duplicateErrorOutput = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
async start(compactionId: string): Promise<CompactionState> {
|
||||
if (this.activeCompaction && !this.activeCompaction.terminalStatus) {
|
||||
return this.activeCompaction;
|
||||
}
|
||||
|
||||
const state = this.open(compactionId);
|
||||
if (this.presentation === "compaction_update") {
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "in_progress",
|
||||
_meta: createContextCompactionMeta(),
|
||||
});
|
||||
return state;
|
||||
}
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: compactionId,
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
status: "in_progress",
|
||||
_meta: compactionToolMeta(),
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming progress from the API's compaction content block.
|
||||
*
|
||||
* Under `compaction_update` the block's text is the retained summary and
|
||||
* streams as `compaction_summary_chunk`s — but only into an entity the CLI
|
||||
* announced with its `compacting` status. The block alone carries no
|
||||
* terminal signal, so opening an entity from it would leave nothing but the
|
||||
* turn-boundary `cancelled` to close a compaction that succeeded. The legacy
|
||||
* tool call keeps its historical behavior: open on first sight, re-assert
|
||||
* `in_progress` once.
|
||||
*/
|
||||
async heartbeat(fallbackId: string, summaryChunk?: string): Promise<void> {
|
||||
if (this.presentation === "compaction_update") {
|
||||
const state = this.activeCompaction;
|
||||
if (!state || state.terminalStatus || !summaryChunk) return;
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_summary_chunk",
|
||||
compactionId: state.compactionId,
|
||||
content: { type: "text", text: summaryChunk },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.activeCompaction ?? (await this.start(fallbackId));
|
||||
if (state.terminalStatus || state.heartbeatSent) return;
|
||||
state.heartbeatSent = true;
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: state.compactionId,
|
||||
status: "in_progress",
|
||||
_meta: compactionToolMeta(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The PostCompact hook's summary. The CLI awaits the hook before emitting
|
||||
* the compaction's terminal frames, so the summary lands while its
|
||||
* compaction is `in_progress` and rides on the terminal update. Anything
|
||||
* else — no entity yet, or the entity is a previous, already-terminal
|
||||
* compaction of the same turn whose status frame the consumer is still
|
||||
* catching up to — belongs to the compaction whose frames are ahead of the
|
||||
* consumer, and waits for it. Only the `compaction_update` presentation
|
||||
* carries the summary; the legacy tool call never exposed it. Never throws:
|
||||
* it runs detached from a hook callback. Returns whether a summary was
|
||||
* retained.
|
||||
*/
|
||||
recordSummary(rawSummary: unknown): boolean {
|
||||
if (this.presentation !== "compaction_update" || typeof rawSummary !== "string") return false;
|
||||
const summary = compactionSummaryText(rawSummary);
|
||||
if (!summary) return false;
|
||||
|
||||
const state = this.activeCompaction;
|
||||
if (state && !state.terminalStatus) {
|
||||
state.summary = summary;
|
||||
} else {
|
||||
this.pendingSummary = summary;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async finish(
|
||||
compactionId: string,
|
||||
status: CompactionStatus,
|
||||
metadata: Omit<ContextCompactionMetadata, "version"> = {},
|
||||
enrichTerminal = false,
|
||||
): Promise<void> {
|
||||
// The opening status can be missed (replay, or a terminal-only runtime):
|
||||
// the first update for the ID is then already terminal.
|
||||
const opened = this.activeCompaction === undefined;
|
||||
const firstTerminal = !this.activeCompaction?.terminalStatus;
|
||||
const state = this.activeCompaction ?? this.open(compactionId, status);
|
||||
if (firstTerminal) {
|
||||
state.terminalStatus = status;
|
||||
} else if (!enrichTerminal) {
|
||||
return;
|
||||
}
|
||||
if (status === "failed" && metadata.error) {
|
||||
this.duplicateErrorOutput = metadata.error;
|
||||
}
|
||||
const terminalStatus = state.terminalStatus ?? status;
|
||||
const hasMetadata = Object.keys(metadata).length > 0;
|
||||
|
||||
if (this.presentation === "compaction_update") {
|
||||
const summary = firstTerminal && terminalStatus === "completed" ? state.summary : undefined;
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: state.compactionId,
|
||||
status: terminalStatus,
|
||||
...(summary !== undefined ? { summary: [{ type: "text", text: summary }] } : {}),
|
||||
...(terminalStatus === "failed" && metadata.error ? { error: metadata.error } : {}),
|
||||
// `_meta` is a replace-patch: seed it with the first terminal, then
|
||||
// only re-send it when the boundary adds facts, so a status-only
|
||||
// duplicate can't wipe the token counts.
|
||||
...(firstTerminal || hasMetadata ? { _meta: createContextCompactionMeta(metadata) } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rawOutput = hasMetadata ? metadata : undefined;
|
||||
const errorContent =
|
||||
status === "failed" && metadata.error
|
||||
? { content: [compactionErrorContent(metadata.error)] }
|
||||
: {};
|
||||
if (opened) {
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: state.compactionId,
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
status,
|
||||
...errorContent,
|
||||
...(rawOutput ? { rawOutput } : {}),
|
||||
_meta: compactionToolMeta(metadata),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: state.compactionId,
|
||||
...(firstTerminal ? { status } : {}),
|
||||
...errorContent,
|
||||
...(rawOutput ? { rawOutput } : {}),
|
||||
_meta: compactionToolMeta(metadata),
|
||||
});
|
||||
}
|
||||
|
||||
/** Make `compactionId` the active entity, moving a pending hook summary onto
|
||||
* it, and mark compaction output as delivered for the owning turn. */
|
||||
private open(compactionId: string, terminalStatus?: TerminalStatus): CompactionState {
|
||||
const state: CompactionState = {
|
||||
compactionId,
|
||||
heartbeatSent: false,
|
||||
...(terminalStatus ? { terminalStatus } : {}),
|
||||
...(this.pendingSummary !== undefined ? { summary: this.pendingSummary } : {}),
|
||||
};
|
||||
this.pendingSummary = undefined;
|
||||
this.activeCompaction = state;
|
||||
this.outputDelivered = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
private send(update: SessionNotification["update"]): Promise<void> {
|
||||
return this.sendUpdate({ sessionId: this.sessionId, update });
|
||||
}
|
||||
|
||||
private async sendQuietly(update: SessionNotification["update"], what: string): Promise<void> {
|
||||
try {
|
||||
await this.send(update);
|
||||
} catch (error) {
|
||||
this.logError(`Failed to send the ${what} update for session ${this.sessionId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function contextCompactionMetadataFromBoundary(compactMetadata: {
|
||||
trigger: "manual" | "auto";
|
||||
pre_tokens: number;
|
||||
post_tokens?: number;
|
||||
duration_ms?: number;
|
||||
}): Omit<ContextCompactionMetadata, "version"> {
|
||||
return {
|
||||
trigger: compactMetadata.trigger === "auto" ? "automatic" : "manual",
|
||||
preTokens: compactMetadata.pre_tokens,
|
||||
...(compactMetadata.post_tokens !== undefined
|
||||
? { postTokens: compactMetadata.post_tokens }
|
||||
: {}),
|
||||
...(compactMetadata.duration_ms !== undefined
|
||||
? { durationMs: compactMetadata.duration_ms }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function compactionToolMeta(
|
||||
metadata: Omit<ContextCompactionMetadata, "version"> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...createContextCompactionMeta(metadata),
|
||||
claudeCode: { toolName: "compact" },
|
||||
};
|
||||
}
|
||||
|
||||
function compactionErrorContent(error: string) {
|
||||
return {
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `Compaction failed: ${error}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
158
src/diff.ts
Normal file
158
src/diff.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import { AIR_DIFF_STATS_KEY, withAirMeta } from "./air-extension.js";
|
||||
|
||||
interface DiffToolResponseHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface DiffToolResponse {
|
||||
filePath?: string;
|
||||
structuredPatch?: DiffToolResponseHunk[];
|
||||
/** FileWriteOutput only (FileEditOutput carries no `type`): whether the
|
||||
* write created the file or overwrote an existing one. */
|
||||
type?: "create" | "update";
|
||||
/** FileWriteOutput only: the content that was written. */
|
||||
content?: string;
|
||||
/** FileWriteOutput only: the pre-write content — null on create, or on an
|
||||
* update whose previous content was too large to include. */
|
||||
originalFile?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds diff ToolUpdate content from the structured toolResponse provided by
|
||||
* the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing
|
||||
* the plain unified diff string, this uses the pre-parsed structuredPatch
|
||||
* which supports multiple replacement sites (replaceAll) and always includes
|
||||
* context lines for better readability.
|
||||
*/
|
||||
export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
} {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
|
||||
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
let previousOldEnd = 0;
|
||||
let previousNewEnd = 0;
|
||||
let accumulatedDelta = 0;
|
||||
let coordinatesRemainConsistent = true;
|
||||
|
||||
for (const { lines, oldStart, newStart, oldLines, newLines } of response.structuredPatch) {
|
||||
const oldText: string[] = [];
|
||||
const newText: string[] = [];
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let validPrefixes = true;
|
||||
let validEofMarkers = true;
|
||||
const oldPosition = oldStart + (oldLines === 0 ? 1 : 0);
|
||||
const newPosition = newStart + (newLines === 0 ? 1 : 0);
|
||||
const oldEnd = oldPosition + oldLines;
|
||||
const newEnd = newPosition + newLines;
|
||||
const nextDelta = accumulatedDelta + newLines - oldLines;
|
||||
const validCoordinates: boolean =
|
||||
coordinatesRemainConsistent &&
|
||||
Number.isSafeInteger(oldStart) &&
|
||||
Number.isSafeInteger(newStart) &&
|
||||
Number.isSafeInteger(oldLines) &&
|
||||
Number.isSafeInteger(newLines) &&
|
||||
oldStart >= (oldLines === 0 ? 0 : 1) &&
|
||||
newStart >= (newLines === 0 ? 0 : 1) &&
|
||||
oldLines >= 0 &&
|
||||
newLines >= 0 &&
|
||||
Number.isSafeInteger(oldPosition) &&
|
||||
Number.isSafeInteger(newPosition) &&
|
||||
Number.isSafeInteger(oldEnd) &&
|
||||
Number.isSafeInteger(newEnd) &&
|
||||
Number.isSafeInteger(nextDelta) &&
|
||||
oldPosition >= previousOldEnd &&
|
||||
newPosition >= previousNewEnd &&
|
||||
newPosition - oldPosition === accumulatedDelta;
|
||||
coordinatesRemainConsistent = validCoordinates;
|
||||
if (validCoordinates) {
|
||||
previousOldEnd = oldEnd;
|
||||
previousNewEnd = newEnd;
|
||||
accumulatedDelta = nextDelta;
|
||||
}
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (line.startsWith("-")) {
|
||||
oldText.push(line.slice(1));
|
||||
removed++;
|
||||
} else if (line.startsWith("+")) {
|
||||
newText.push(line.slice(1));
|
||||
added++;
|
||||
} else if (line === "\\ No newline at end of file") {
|
||||
const previousLine = lines[index - 1];
|
||||
if (
|
||||
typeof previousLine !== "string" ||
|
||||
(!previousLine.startsWith("-") &&
|
||||
!previousLine.startsWith("+") &&
|
||||
!previousLine.startsWith(" "))
|
||||
) {
|
||||
validEofMarkers = false;
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
if (!line.startsWith(" ")) validPrefixes = false;
|
||||
oldText.push(line.slice(1));
|
||||
newText.push(line.slice(1));
|
||||
}
|
||||
}
|
||||
if (oldText.length > 0 || newText.length > 0) {
|
||||
locations.push({ path: response.filePath, line: newStart });
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: response.filePath,
|
||||
oldText: oldText.join("\n") || null,
|
||||
newText: newText.join("\n"),
|
||||
...(validCoordinates &&
|
||||
validPrefixes &&
|
||||
validEofMarkers &&
|
||||
oldText.length === oldLines &&
|
||||
newText.length === newLines
|
||||
? { _meta: withAirMeta(undefined, AIR_DIFF_STATS_KEY, { version: 1, added, removed }) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A Write `update` can arrive with an empty structuredPatch — nothing
|
||||
// changed, the diff timed out, or the previous content was too large to
|
||||
// diff (originalFile null; SDK 0.3.252 documents the lane). Returning `{}`
|
||||
// would leave Write's optimistic tool_use-time content standing, and that
|
||||
// was built with `oldText: null` — "creation" semantics — so an overwrite
|
||||
// of a large existing file would render as creating it. Emit a truthful
|
||||
// replacement instead. Gated on `type` so Edit (whose output carries no
|
||||
// `type` and whose optimistic old/new diff is already truthful) keeps the
|
||||
// empty-return behavior.
|
||||
if (content.length === 0 && response.type === "update" && typeof response.content === "string") {
|
||||
locations.push({ path: response.filePath });
|
||||
content.push(
|
||||
typeof response.originalFile === "string"
|
||||
? {
|
||||
type: "diff",
|
||||
path: response.filePath,
|
||||
oldText: response.originalFile,
|
||||
newText: response.content,
|
||||
}
|
||||
: {
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Updated \`${response.filePath}\` (previous content too large to diff)`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {};
|
||||
if (content.length > 0) result.content = content;
|
||||
if (locations.length > 0) result.locations = locations;
|
||||
return result;
|
||||
}
|
||||
@@ -148,6 +148,14 @@ function questionCustomFieldKey(index: number): string {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -160,9 +168,11 @@ const OPTION_META_KEY = "_claude/askUserQuestionOption";
|
||||
*
|
||||
* 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.
|
||||
* user can type their own answer instead of picking an option, add it to a
|
||||
* multi-select's picks, or attach it as a note to a single-select's pick (see
|
||||
* `applyAskElicitationResponse`), 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[],
|
||||
@@ -203,7 +213,15 @@ export function askUserQuestionsToCreateRequest(
|
||||
properties[questionCustomFieldKey(index)] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
description: "Type your own answer instead of choosing an option above (optional).",
|
||||
description: question.multiSelect
|
||||
? "Type your own answer to add to your selection above (optional)."
|
||||
: "Type your own answer, or add a note to the option you chose above (optional).",
|
||||
_meta: {
|
||||
[CUSTOM_ANSWER_META_KEY]: {
|
||||
questionId: questionFieldKey(index),
|
||||
isCustomAnswer: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -227,17 +245,36 @@ export function askUserQuestionsToCreateRequest(
|
||||
export type AskUserQuestionOutcome =
|
||||
{ action: "answered"; updatedInput: Record<string, unknown> } | { action: "cancel" };
|
||||
|
||||
/**
|
||||
* Serialize a multi-select answer the way the CLI's own AskUserQuestion UI
|
||||
* does: comma-joined, with any item that itself contains the separator (or a
|
||||
* double quote) JSON-quoted. The tool's `call()` splits the string back on the
|
||||
* same rule, so a free-text answer like `Redis, not Memcached` stays one item
|
||||
* instead of reading as two more picks.
|
||||
*/
|
||||
function joinMultiSelectAnswer(items: string[]): string {
|
||||
return items
|
||||
.map((item) => (item.includes(", ") || item.includes('"') ? JSON.stringify(item) : item))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `answers` as a `{ [questionText]: label }` map — the key shape the tool's own
|
||||
* `call()` reads — with multi-selects comma-joined in the CLI's own quoted form
|
||||
* (see `joinMultiSelectAnswer`). A non-empty per-question custom-answer field
|
||||
* (`question_<n>_custom`) joins the selection of a multi-select question, where
|
||||
* the two fields are independent and filling both means both. For a
|
||||
* single-select question it is the answer when nothing was picked (the user
|
||||
* typed their own instead), and otherwise travels beside the pick as the
|
||||
* tool's own per-question `annotations[question].notes` — the slot the CLI
|
||||
* uses for free text attached to a selection and renders to the model as
|
||||
* `"Q"="A" notes: ...` — so a client that presents the box as a notes field
|
||||
* cannot make the selection disappear. 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,
|
||||
@@ -256,27 +293,57 @@ export function applyAskElicitationResponse(
|
||||
// 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"] = {};
|
||||
const annotations: NonNullable<AskUserQuestionInput["annotations"]> = {};
|
||||
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 customText = typeof custom === "string" ? custom.trim() : "";
|
||||
|
||||
const value = content[questionFieldKey(index)];
|
||||
if (value === undefined || value === null) {
|
||||
const picks: string[] =
|
||||
value === undefined || value === null
|
||||
? []
|
||||
: Array.isArray(value)
|
||||
? value.filter((item) => item !== undefined && item !== null && item !== "").map(String)
|
||||
: [String(value)];
|
||||
|
||||
// A multi-select is additive, and the form offers the selection and the
|
||||
// custom box as independent fields — a user who fills both means both, so
|
||||
// the typed answer joins the checked options.
|
||||
if (question.multiSelect) {
|
||||
const text = joinMultiSelectAnswer(customText === "" ? picks : [...picks, customText]);
|
||||
if (text !== "") {
|
||||
answers[question.question] = text;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const text = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
if (text === "") {
|
||||
|
||||
// A single-select question is answered by exactly one thing. With no option
|
||||
// picked, the typed text is that answer (the CLI's "Other"). With an option
|
||||
// picked as well, the pick stays the answer and the text rides along as the
|
||||
// tool's per-question `notes` annotation, so neither is lost. A single-select
|
||||
// normally holds one item; the plain join only matters if a client hands
|
||||
// back an array for it, and then mirrors the old behavior.
|
||||
const picked = picks.join(", ");
|
||||
if (picked === "") {
|
||||
if (customText !== "") {
|
||||
answers[question.question] = customText;
|
||||
}
|
||||
return;
|
||||
}
|
||||
answers[question.question] = text;
|
||||
answers[question.question] = picked;
|
||||
if (customText !== "") {
|
||||
annotations[question.question] = { notes: customText };
|
||||
}
|
||||
});
|
||||
|
||||
return { action: "answered", updatedInput: { ...toolInput, answers } };
|
||||
return {
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
...toolInput,
|
||||
answers,
|
||||
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
217
src/exit-plan.ts
Normal file
217
src/exit-plan.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
type ClearContextCoordinatorHost,
|
||||
type ClearContextReset,
|
||||
type ClearContextSession,
|
||||
type ClearContextTurn,
|
||||
continuePlanInFreshContext,
|
||||
} from "./clear-context-coordinator.js";
|
||||
import { parseToolResultMeta } from "./tool-result-meta.js";
|
||||
|
||||
export function acceptedPlanToolResult(
|
||||
notification: SessionNotification,
|
||||
toolUseId: string | undefined,
|
||||
): SessionNotification {
|
||||
const update = notification.update;
|
||||
if (
|
||||
!toolUseId ||
|
||||
update.sessionUpdate !== "tool_call_update" ||
|
||||
update.toolCallId !== toolUseId
|
||||
) {
|
||||
return notification;
|
||||
}
|
||||
const completed = { ...update };
|
||||
delete completed.rawOutput;
|
||||
delete completed.content;
|
||||
return { ...notification, update: { ...completed, status: "completed" } };
|
||||
}
|
||||
|
||||
function containsToolResultFor(content: unknown, toolUseId: string): boolean {
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.some(
|
||||
(block) =>
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
(block as { type?: unknown }).type === "tool_result" &&
|
||||
(block as { tool_use_id?: unknown }).tool_use_id === toolUseId,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type ExitPlanState = {
|
||||
toolUseCache: Record<string, { name: string } | undefined>;
|
||||
pendingExitPlanModeInterruption?: { toolUseId: string; toolResultSeen: boolean };
|
||||
pendingExitPlanContextReset?: ClearContextReset;
|
||||
};
|
||||
|
||||
function rejectedExitPlanToolUseId(
|
||||
content: unknown,
|
||||
toolUseCache: ExitPlanState["toolUseCache"],
|
||||
rawToolResultMeta: unknown,
|
||||
): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const toolResultMeta = parseToolResultMeta(rawToolResultMeta);
|
||||
if (!toolResultMeta) return undefined;
|
||||
for (const block of content) {
|
||||
if (typeof block !== "object" || block === null) continue;
|
||||
const { type, tool_use_id: toolUseId, is_error: isError } = block as Record<string, unknown>;
|
||||
if (
|
||||
type === "tool_result" &&
|
||||
typeof toolUseId === "string" &&
|
||||
isError === true &&
|
||||
toolResultMeta.get(toolUseId)?.nonExecutionKind === "user-rejected" &&
|
||||
toolUseCache[toolUseId]?.name === "ExitPlanMode"
|
||||
) {
|
||||
return toolUseId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Reconcile an SDK user message with the two pending ExitPlanMode lanes and
|
||||
* return the accepted plan tool id whose rendered update must be completed. */
|
||||
export function observeExitPlanToolResults(
|
||||
message: { type: string; tool_result_meta?: unknown },
|
||||
content: unknown,
|
||||
state: ExitPlanState,
|
||||
): string | undefined {
|
||||
if (message.type !== "user") return undefined;
|
||||
|
||||
const rejectedToolUseId = rejectedExitPlanToolUseId(
|
||||
content,
|
||||
state.toolUseCache,
|
||||
message.tool_result_meta,
|
||||
);
|
||||
if (rejectedToolUseId) {
|
||||
// The stream is authoritative: resumed queries can lose the short-lived
|
||||
// marker installed by canUseTool, while metadata preserves correlation.
|
||||
state.pendingExitPlanModeInterruption = {
|
||||
toolUseId: rejectedToolUseId,
|
||||
toolResultSeen: true,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingInterruption = state.pendingExitPlanModeInterruption;
|
||||
if (pendingInterruption && containsToolResultFor(content, pendingInterruption.toolUseId)) {
|
||||
pendingInterruption.toolResultSeen = true;
|
||||
}
|
||||
|
||||
const pendingReset = state.pendingExitPlanContextReset;
|
||||
return pendingReset && containsToolResultFor(content, pendingReset.toolUseId)
|
||||
? pendingReset.toolUseId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function executionDiagnostic(message: SDKResultMessage): string | undefined {
|
||||
if (message.subtype === "success") {
|
||||
return message.result.startsWith("[ede_diagnostic]") ? message.result : undefined;
|
||||
}
|
||||
return message.errors.find((error) => error.startsWith("[ede_diagnostic]"));
|
||||
}
|
||||
|
||||
/** Claude wraps a rejected ExitPlanMode explanation in a Markdown code fence.
|
||||
* Strip exactly one complete outer fence for that tool only. */
|
||||
export function exitPlanModeRawOutput(toolName: string, content: unknown): unknown {
|
||||
if (toolName !== "ExitPlanMode" || typeof content !== "string") {
|
||||
return content;
|
||||
}
|
||||
const fenced = /^\s*```[^\r\n]*\r?\n([\s\S]*?)\r?\n```\s*$/.exec(content);
|
||||
return fenced?.[1] ?? content;
|
||||
}
|
||||
|
||||
export type ExitPlanRestartHost<
|
||||
Session extends ClearContextSession<Turn>,
|
||||
Turn extends ClearContextTurn,
|
||||
> = Omit<
|
||||
ClearContextCoordinatorHost<Session, Turn>,
|
||||
"publishSessionState" | "continuationMessage"
|
||||
> & {
|
||||
sessionUpdate(notification: SessionNotification): Promise<void>;
|
||||
destroyReplacement(sessionId: string, session: Session): void;
|
||||
settleCancelledTurn(oldSession: Session, turnSession: Session, turn: Turn): void;
|
||||
settleFailedTurn(turnSession: Session, turn: Turn, error: unknown): void;
|
||||
};
|
||||
|
||||
/** Owns the lifetime of accepted-plan context replacements. In particular, a
|
||||
* session cancellation invalidates an in-progress async restart so a late
|
||||
* restartSession result cannot recreate a closed public session. */
|
||||
export class ExitPlanCoordinator<
|
||||
Session extends ClearContextSession<Turn>,
|
||||
Turn extends ClearContextTurn,
|
||||
> {
|
||||
private readonly restarts = new Map<string, AbortController>();
|
||||
|
||||
constructor(private readonly host: ExitPlanRestartHost<Session, Turn>) {}
|
||||
|
||||
cancel(sessionId: string): void {
|
||||
this.restarts.get(sessionId)?.abort();
|
||||
}
|
||||
|
||||
async restart(sessionId: string, oldSession: Session, reset: ClearContextReset): Promise<void> {
|
||||
this.cancel(sessionId);
|
||||
const controller = new AbortController();
|
||||
this.restarts.set(sessionId, controller);
|
||||
try {
|
||||
const clearContextHost: ClearContextCoordinatorHost<Session, Turn> = {
|
||||
...this.host,
|
||||
publishSessionState: async (id, mode, configOptions) => {
|
||||
await this.host.sessionUpdate({
|
||||
sessionId: id,
|
||||
update: { sessionUpdate: "current_mode_update", currentModeId: mode },
|
||||
});
|
||||
await this.host.sessionUpdate({
|
||||
sessionId: id,
|
||||
update: { sessionUpdate: "config_option_update", configOptions },
|
||||
});
|
||||
},
|
||||
continuationMessage: (id, plan, promptUuid) => ({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `Implement the following plan:\n\n${plan}` }],
|
||||
},
|
||||
session_id: id,
|
||||
parent_tool_use_id: null,
|
||||
origin: { kind: "human" },
|
||||
uuid: promptUuid as `${string}-${string}-${string}-${string}-${string}`,
|
||||
}),
|
||||
};
|
||||
await continuePlanInFreshContext(
|
||||
sessionId,
|
||||
oldSession,
|
||||
reset,
|
||||
clearContextHost,
|
||||
controller.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
const currentSession = this.host.currentSession(sessionId);
|
||||
const replacement = currentSession !== oldSession ? currentSession : undefined;
|
||||
if (replacement) this.host.destroyReplacement(sessionId, replacement);
|
||||
|
||||
const turn = replacement?.activeTurn ?? oldSession.activeTurn;
|
||||
if (turn && !turn.settled) {
|
||||
const turnSession = replacement?.activeTurn === turn ? replacement : oldSession;
|
||||
if (controller.signal.aborted) {
|
||||
this.host.settleCancelledTurn(oldSession, turnSession, turn);
|
||||
} else {
|
||||
// A provider/session-creation failure is turn-scoped. Settling it
|
||||
// here keeps it out of the query consumer's transport-loss catch.
|
||||
this.host.settleFailedTurn(turnSession, turn, error);
|
||||
}
|
||||
oldSession.activeTurn = null;
|
||||
oldSession.turnQueue = (oldSession.turnQueue ?? []).filter((queued) => queued !== turn);
|
||||
if (replacement) {
|
||||
replacement.activeTurn = null;
|
||||
replacement.turnQueue = (replacement.turnQueue ?? []).filter((queued) => queued !== turn);
|
||||
}
|
||||
}
|
||||
oldSession.pendingExitPlanContextReset = undefined;
|
||||
} finally {
|
||||
if (this.restarts.get(sessionId) === controller) {
|
||||
this.restarts.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
335
src/file-change-audit.ts
Normal file
335
src/file-change-audit.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { Query, RewindFilesResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { airExtensionMeta, clientSupportsAirCapability, withAirMeta } from "./air-extension.js";
|
||||
|
||||
export const AGENT_FILE_CHANGE_REPORT_CAPABILITY = "agentFileChangeReport";
|
||||
|
||||
const MAX_REPORTED_PATHS = 1024;
|
||||
const MAX_REPORTED_PATH_LENGTH = 4096;
|
||||
export const AGENT_FILE_CHANGE_REPORT_MAX_BYTES = 256 * 1024;
|
||||
export const AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS = 2_000;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
|
||||
export type FileChangeReportTurnState = {
|
||||
requestId: string;
|
||||
phase: "requested" | "collecting" | "finished";
|
||||
};
|
||||
|
||||
export type NativeFileChangeReportTurn = {
|
||||
promptUuid: string;
|
||||
fileChangeReport?: FileChangeReportTurnState;
|
||||
};
|
||||
|
||||
export type FileChangeReportWorkspace = {
|
||||
cwd: string;
|
||||
additionalDirectories: string[];
|
||||
};
|
||||
|
||||
export type AgentFileChangeReportResult = {
|
||||
version: 1;
|
||||
requestId: string;
|
||||
} & (
|
||||
| {
|
||||
status: "reported";
|
||||
paths: string[];
|
||||
declaredComplete: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
| {
|
||||
status: "unavailable";
|
||||
reason: FileChangeReportUnavailableReason;
|
||||
}
|
||||
);
|
||||
|
||||
export type FileChangeReportUnavailableReason =
|
||||
"cancelled" | "timeout" | "invalidOutput" | "notReported" | "providerError";
|
||||
|
||||
type NativeFileChangeReporterOptions = {
|
||||
cwd: string;
|
||||
additionalDirectories: string[];
|
||||
publish: (result: AgentFileChangeReportResult) => Promise<void>;
|
||||
logError: (message: string) => void;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type NativeFileChangeReporter = {
|
||||
request(meta: unknown): FileChangeReportTurnState | undefined;
|
||||
report(
|
||||
turn: NativeFileChangeReportTurn | null | undefined,
|
||||
query: Pick<Query, "rewindFiles">,
|
||||
): Promise<void>;
|
||||
finish(
|
||||
state: FileChangeReportTurnState | undefined,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
): void;
|
||||
};
|
||||
|
||||
export function agentFileChangeReportRequestId(meta: unknown): string | undefined {
|
||||
const air = airExtensionMeta(meta);
|
||||
const request = air?.agentFileChangeReportRequest;
|
||||
if (!request || typeof request !== "object" || Array.isArray(request)) return undefined;
|
||||
const requestRecord = request as Record<string, unknown>;
|
||||
const requestKeys = Object.keys(requestRecord);
|
||||
if (
|
||||
requestKeys.length !== 2 ||
|
||||
!requestKeys.includes("version") ||
|
||||
!requestKeys.includes("requestId") ||
|
||||
requestRecord.version !== 1
|
||||
)
|
||||
return undefined;
|
||||
const requestId = requestRecord.requestId;
|
||||
return typeof requestId === "string" && REQUEST_ID_PATTERN.test(requestId)
|
||||
? requestId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function supportsAgentFileChangeReport(capabilities: unknown): boolean {
|
||||
return clientSupportsAirCapability(capabilities, AGENT_FILE_CHANGE_REPORT_CAPABILITY);
|
||||
}
|
||||
|
||||
export function agentFileChangeReportMeta(
|
||||
result: AgentFileChangeReportResult,
|
||||
): Record<string, unknown> {
|
||||
return withAirMeta(undefined, AGENT_FILE_CHANGE_REPORT_CAPABILITY, result);
|
||||
}
|
||||
|
||||
export function createNativeFileChangeReporter(
|
||||
options: NativeFileChangeReporterOptions,
|
||||
): NativeFileChangeReporter {
|
||||
const workspace = normalizeWorkspace(options.cwd, options.additionalDirectories);
|
||||
const timeoutMs = options.timeoutMs ?? AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS;
|
||||
const requestIds = new Set<string>();
|
||||
|
||||
const publish = async (state: FileChangeReportTurnState, result: AgentFileChangeReportResult) => {
|
||||
if (state.phase === "finished") return;
|
||||
state.phase = "finished";
|
||||
try {
|
||||
await options.publish(result);
|
||||
} catch (error) {
|
||||
options.logError(`Failed to publish file-change report ${state.requestId}: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const finishUnavailable = async (
|
||||
state: FileChangeReportTurnState,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
) => {
|
||||
await publish(state, {
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "unavailable",
|
||||
reason,
|
||||
});
|
||||
};
|
||||
|
||||
const report = async (
|
||||
turn: NativeFileChangeReportTurn | null | undefined,
|
||||
query: Pick<Query, "rewindFiles">,
|
||||
) => {
|
||||
const state = turn?.fileChangeReport;
|
||||
if (!state || state.phase !== "requested") return;
|
||||
state.phase = "collecting";
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const previewPromise = query.rewindFiles(turn.promptUuid, { dryRun: true });
|
||||
// A timed-out control request may still settle later. Observe it so a late
|
||||
// rejection cannot become unhandled, but never let it publish a second terminal.
|
||||
void previewPromise.catch(() => {});
|
||||
try {
|
||||
const preview = await Promise.race<RewindFilesResult | null>([
|
||||
previewPromise,
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), timeoutMs);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
if (preview === null) {
|
||||
await finishUnavailable(state, "timeout");
|
||||
return;
|
||||
}
|
||||
if (!preview.canRewind || !Array.isArray(preview.filesChanged)) {
|
||||
await finishUnavailable(state, "invalidOutput");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeReportedPaths(preview.filesChanged, workspace);
|
||||
await publish(
|
||||
state,
|
||||
fitReportedAgentFileChangeReport({
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "reported",
|
||||
paths: normalized.paths,
|
||||
// Checkpoints cover Claude file tools, but not every mutation source
|
||||
// (notably Bash and most subagents), so this list is never exhaustive.
|
||||
declaredComplete: false,
|
||||
truncated: normalized.truncated,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
options.logError(`Failed to inspect Claude file checkpoint ${turn.promptUuid}: ${error}`);
|
||||
await finishUnavailable(state, "providerError");
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
request(meta) {
|
||||
const requestId = agentFileChangeReportRequestId(meta);
|
||||
if (!requestId || requestIds.has(requestId)) return undefined;
|
||||
requestIds.add(requestId);
|
||||
return { requestId, phase: "requested" };
|
||||
},
|
||||
report,
|
||||
finish(state, reason) {
|
||||
if (!state) return;
|
||||
void finishUnavailable(state, reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkspace(
|
||||
cwd: string,
|
||||
additionalDirectories: string[],
|
||||
): FileChangeReportWorkspace {
|
||||
const normalizedCwd = canonicalizeWorkspaceRoot(path.resolve(cwd));
|
||||
const seen = new Set([pathKey(normalizedCwd)]);
|
||||
const normalizedAdditionalDirectories: string[] = [];
|
||||
for (const directory of additionalDirectories) {
|
||||
const normalized = canonicalizeWorkspaceRoot(path.resolve(normalizedCwd, directory));
|
||||
const key = pathKey(normalized);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalizedAdditionalDirectories.push(normalized);
|
||||
}
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
additionalDirectories: normalizedAdditionalDirectories,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReportedPaths(
|
||||
reportedPaths: string[],
|
||||
workspace: FileChangeReportWorkspace,
|
||||
): { paths: string[]; truncated: boolean } {
|
||||
const roots = [workspace.cwd, ...workspace.additionalDirectories];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
let totalBytes = 0;
|
||||
let truncated = false;
|
||||
for (const reportedPath of reportedPaths) {
|
||||
if (
|
||||
typeof reportedPath !== "string" ||
|
||||
reportedPath.trim().length === 0 ||
|
||||
reportedPath.length > MAX_REPORTED_PATH_LENGTH ||
|
||||
hasControlCharacter(reportedPath)
|
||||
) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = canonicalizeReportedPath(path.resolve(workspace.cwd, reportedPath));
|
||||
} catch {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (normalized.length > MAX_REPORTED_PATH_LENGTH || hasControlCharacter(normalized)) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (!roots.some((root) => isWithinRoot(normalized, root))) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
const key = pathKey(normalized);
|
||||
if (roots.some((root) => pathKey(root) === key)) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) continue;
|
||||
const pathBytes = Buffer.byteLength(normalized, "utf8");
|
||||
if (
|
||||
result.length >= MAX_REPORTED_PATHS ||
|
||||
totalBytes + pathBytes > AGENT_FILE_CHANGE_REPORT_MAX_BYTES
|
||||
) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(normalized);
|
||||
totalBytes += pathBytes;
|
||||
}
|
||||
return { paths: result, truncated };
|
||||
}
|
||||
|
||||
function fitReportedAgentFileChangeReport(
|
||||
report: Extract<AgentFileChangeReportResult, { status: "reported" }>,
|
||||
): Extract<AgentFileChangeReportResult, { status: "reported" }> {
|
||||
const paths = [...report.paths];
|
||||
let fitted = report;
|
||||
while (Buffer.byteLength(JSON.stringify(fitted), "utf8") > AGENT_FILE_CHANGE_REPORT_MAX_BYTES) {
|
||||
if (paths.length === 0) {
|
||||
throw new Error("Agent file-change report exceeds the wire limit without paths");
|
||||
}
|
||||
paths.pop();
|
||||
fitted = {
|
||||
...report,
|
||||
paths: [...paths],
|
||||
declaredComplete: false,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
return fitted;
|
||||
}
|
||||
|
||||
function isWithinRoot(candidate: string, root: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return !path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..";
|
||||
}
|
||||
|
||||
function canonicalizeWorkspaceRoot(value: string): string {
|
||||
return canonicalizeFromExistingAncestor(value);
|
||||
}
|
||||
|
||||
function canonicalizeReportedPath(value: string): string {
|
||||
const parent = canonicalizeFromExistingAncestor(path.dirname(value));
|
||||
return path.resolve(parent, path.basename(value));
|
||||
}
|
||||
|
||||
function canonicalizeFromExistingAncestor(value: string): string {
|
||||
const original = path.resolve(value);
|
||||
let current = original;
|
||||
const missingSegments: string[] = [];
|
||||
while (true) {
|
||||
try {
|
||||
const canonical = fs.realpathSync.native(current);
|
||||
return path.resolve(canonical, ...missingSegments.reverse());
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) return original;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return original;
|
||||
missingSegments.push(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
||||
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
||||
}
|
||||
|
||||
function pathKey(value: string): string {
|
||||
return process.platform === "win32" ? value.toLowerCase() : value;
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
235
src/fork-session.ts
Normal file
235
src/fork-session.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { ForkSessionRequest, ForkSessionResponse, RequestError } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
forkSession as forkClaudeSession,
|
||||
getSessionMessages,
|
||||
importSessionToStore,
|
||||
type SessionStore,
|
||||
type SessionStoreEntry,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createHash } from "node:crypto";
|
||||
import { assistantMessageText } from "./session-failure-extension.js";
|
||||
import { SessionTiming } from "./session-timing.js";
|
||||
|
||||
type ForkSessionMeta = {
|
||||
[key: string]: unknown;
|
||||
jetbrains?: {
|
||||
air?: {
|
||||
fork?: {
|
||||
version?: number;
|
||||
messageId?: string;
|
||||
messageFingerprint?: string;
|
||||
messageOccurrence?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ForkSessionDependencies = {
|
||||
liveMessageIdToUuid?: ReadonlyMap<string, string>;
|
||||
logger?: { log: (...args: unknown[]) => void };
|
||||
messageIdForGrouping: (message: {
|
||||
type?: string;
|
||||
uuid?: string | null;
|
||||
message?: unknown;
|
||||
}) => string | undefined;
|
||||
};
|
||||
|
||||
type ForkPoint = {
|
||||
messageId: string;
|
||||
messageFingerprint?: string;
|
||||
messageOccurrence?: number;
|
||||
};
|
||||
|
||||
function forkPoint(meta: unknown): ForkPoint | undefined {
|
||||
const fork = (meta as ForkSessionMeta | null | undefined)?.jetbrains?.air?.fork;
|
||||
if (fork?.version !== 1) return undefined;
|
||||
const messageId = fork.messageId?.trim();
|
||||
if (!messageId) return undefined;
|
||||
const messageFingerprint = fork.messageFingerprint?.trim();
|
||||
const messageOccurrence = fork.messageOccurrence;
|
||||
return {
|
||||
messageId,
|
||||
...(messageFingerprint ? { messageFingerprint } : {}),
|
||||
// AIR computes this with count(...) over the visible prefix, so it is 1-based.
|
||||
...(typeof messageOccurrence === "number" &&
|
||||
Number.isSafeInteger(messageOccurrence) &&
|
||||
messageOccurrence > 0
|
||||
? { messageOccurrence }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function forkPointMessageIdCandidates(messageId: string): string[] {
|
||||
// Older AIR builds sent their visible segment id. Prefer the exact id before its ACP source id.
|
||||
const protocolMessageId = messageId.replace(/:segment:\d+$/, "");
|
||||
return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId];
|
||||
}
|
||||
|
||||
async function loadFullSessionHistory(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
): Promise<SessionStoreEntry[]> {
|
||||
// getSessionMessages returns only the active parentUuid chain. The import keeps inactive branches that AIR can still reference.
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
const store: SessionStore = {
|
||||
append: async (_key, batch) => {
|
||||
entries.push(...batch);
|
||||
},
|
||||
load: async () => null,
|
||||
};
|
||||
await importSessionToStore(sessionId, store, { dir: cwd, includeSubagents: false });
|
||||
return entries;
|
||||
}
|
||||
|
||||
function assistantGroups(
|
||||
entries: SessionStoreEntry[],
|
||||
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
|
||||
) {
|
||||
const groups = new Map<string, { uuid?: string; text: string }>();
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.type !== "assistant" ||
|
||||
entry.isSidechain === true ||
|
||||
entry.parent_tool_use_id != null ||
|
||||
entry.parent_agent_id != null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const messageId = messageIdForGrouping(entry);
|
||||
if (!messageId) continue;
|
||||
const group = groups.get(messageId) ?? { text: "" };
|
||||
if (typeof entry.uuid === "string" && entry.uuid.length > 0) group.uuid = entry.uuid;
|
||||
group.text += assistantMessageText(entry.message) ?? "";
|
||||
groups.set(messageId, group);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function fingerprint(text: string): string {
|
||||
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
||||
}
|
||||
|
||||
function fingerprintOccurrenceOnBranch(
|
||||
targetUuid: string,
|
||||
expectedFingerprint: string,
|
||||
entries: SessionStoreEntry[],
|
||||
groups: ReturnType<typeof assistantGroups>,
|
||||
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
|
||||
): number {
|
||||
const entriesByUuid = new Map(
|
||||
entries.flatMap((entry) => (typeof entry.uuid === "string" ? [[entry.uuid, entry]] : [])),
|
||||
);
|
||||
const seenGroups = new Set<string>();
|
||||
let occurrence = 0;
|
||||
let cursor: string | undefined = targetUuid;
|
||||
while (cursor) {
|
||||
const entry = entriesByUuid.get(cursor);
|
||||
if (!entry) break;
|
||||
if (
|
||||
entry.type === "assistant" &&
|
||||
entry.isSidechain !== true &&
|
||||
entry.parent_tool_use_id == null &&
|
||||
entry.parent_agent_id == null
|
||||
) {
|
||||
const messageId = messageIdForGrouping(entry);
|
||||
if (messageId && !seenGroups.has(messageId)) {
|
||||
seenGroups.add(messageId);
|
||||
const group = groups.get(messageId);
|
||||
if (group && fingerprint(group.text) === expectedFingerprint) occurrence++;
|
||||
}
|
||||
}
|
||||
cursor = typeof entry.parentUuid === "string" ? entry.parentUuid : undefined;
|
||||
}
|
||||
return occurrence;
|
||||
}
|
||||
|
||||
function resolveFromFullHistory(
|
||||
entries: SessionStoreEntry[],
|
||||
candidates: string[],
|
||||
point: ForkPoint,
|
||||
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
|
||||
): string | undefined {
|
||||
const groups = assistantGroups(entries, messageIdForGrouping);
|
||||
const exact = candidates.map((candidate) => groups.get(candidate)?.uuid).find(Boolean);
|
||||
if (exact) return exact;
|
||||
if (!point.messageFingerprint || !point.messageOccurrence) return undefined;
|
||||
|
||||
const fingerprintMatches = [...groups.values()].filter(
|
||||
(group) => group.uuid && fingerprint(group.text) === point.messageFingerprint,
|
||||
);
|
||||
// If only one persisted message has this content, the fingerprint already
|
||||
// identifies it even when an older client supplied an incompatible index.
|
||||
if (fingerprintMatches.length === 1) return fingerprintMatches[0]?.uuid;
|
||||
|
||||
const occurrenceMatches = fingerprintMatches.filter(
|
||||
(group) =>
|
||||
group.uuid &&
|
||||
fingerprintOccurrenceOnBranch(
|
||||
group.uuid,
|
||||
point.messageFingerprint!,
|
||||
entries,
|
||||
groups,
|
||||
messageIdForGrouping,
|
||||
) === point.messageOccurrence,
|
||||
);
|
||||
return occurrenceMatches.length === 1 ? occurrenceMatches[0]?.uuid : undefined;
|
||||
}
|
||||
|
||||
export async function forkSession(
|
||||
params: ForkSessionRequest,
|
||||
dependencies: ForkSessionDependencies,
|
||||
): Promise<ForkSessionResponse> {
|
||||
const timing = new SessionTiming(dependencies.logger, "fork", params.sessionId);
|
||||
const point = forkPoint(params._meta);
|
||||
if (!point) {
|
||||
const forked = await forkClaudeSession(params.sessionId, { dir: params.cwd });
|
||||
timing.phase("sdk-fork", " resolution=latest");
|
||||
return { sessionId: forked.sessionId };
|
||||
}
|
||||
|
||||
const candidateIds = forkPointMessageIdCandidates(point.messageId);
|
||||
const liveUuid = candidateIds
|
||||
.map((candidateId) => dependencies.liveMessageIdToUuid?.get(candidateId))
|
||||
.find(Boolean);
|
||||
const history = liveUuid
|
||||
? undefined
|
||||
: await getSessionMessages(params.sessionId, { dir: params.cwd });
|
||||
timing.phase("active-history", ` resolution=${liveUuid ? "live" : "pending"}`);
|
||||
const messageUuid =
|
||||
liveUuid ??
|
||||
candidateIds
|
||||
.map(
|
||||
(candidateId) =>
|
||||
history
|
||||
?.slice()
|
||||
.reverse()
|
||||
.find((message) => dependencies.messageIdForGrouping(message) === candidateId)?.uuid,
|
||||
)
|
||||
.find(Boolean);
|
||||
const fullHistoryUuid = messageUuid
|
||||
? undefined
|
||||
: resolveFromFullHistory(
|
||||
await loadFullSessionHistory(params.sessionId, params.cwd),
|
||||
candidateIds,
|
||||
point,
|
||||
dependencies.messageIdForGrouping,
|
||||
);
|
||||
timing.phase(
|
||||
"full-history",
|
||||
` resolution=${liveUuid ? "live" : messageUuid ? "active" : fullHistoryUuid ? "full" : "missing"}`,
|
||||
);
|
||||
|
||||
if (!messageUuid && !fullHistoryUuid) {
|
||||
throw RequestError.invalidParams(
|
||||
{ messageId: point.messageId },
|
||||
`Fork point message ${point.messageId} was not found in session ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const forked = await forkClaudeSession(params.sessionId, {
|
||||
dir: params.cwd,
|
||||
upToMessageId: messageUuid ?? fullHistoryUuid,
|
||||
});
|
||||
timing.phase("sdk-fork");
|
||||
return { sessionId: forked.sessionId };
|
||||
}
|
||||
83
src/goal-extension.ts
Normal file
83
src/goal-extension.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk";
|
||||
import type { SDKActiveGoalMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const GOAL_EXTENSION_VERSION = 1 as const;
|
||||
export const GOAL_CONTROL_METHOD = "_session/goal";
|
||||
|
||||
export const GOAL_ACTIONS = ["set", "clear"] as const;
|
||||
export type GoalAction = (typeof GOAL_ACTIONS)[number];
|
||||
|
||||
export type GoalCapability = {
|
||||
version: typeof GOAL_EXTENSION_VERSION;
|
||||
controlMethod: typeof GOAL_CONTROL_METHOD;
|
||||
actions: GoalAction[];
|
||||
};
|
||||
|
||||
export type GoalStatus = "active" | "paused" | "blocked" | "limited" | "complete";
|
||||
|
||||
export type GoalSnapshot = {
|
||||
objective: string;
|
||||
status: GoalStatus;
|
||||
iterations?: number;
|
||||
lastReason?: string | null;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
tokenBudget?: number | null;
|
||||
tokensUsed?: number;
|
||||
timeUsedSeconds?: number;
|
||||
controlMethod: typeof GOAL_CONTROL_METHOD;
|
||||
};
|
||||
|
||||
export type GoalRequest =
|
||||
{ sessionId: string; action: "clear" } | { sessionId: string; action: "set"; objective: string };
|
||||
|
||||
export type GoalControlResponse = Record<string, never>;
|
||||
|
||||
export function goalUpdateFromPrompt(prompt: string): GoalSnapshot | null | undefined {
|
||||
const match = /^\/goal(?:\s+([\s\S]*))?$/.exec(prompt);
|
||||
const argument = match?.[1]?.trim();
|
||||
if (!argument) {
|
||||
return undefined;
|
||||
}
|
||||
if (argument === "clear") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
objective: argument,
|
||||
status: "active",
|
||||
controlMethod: GOAL_CONTROL_METHOD,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGoalRequest(params: unknown): GoalRequest {
|
||||
if (!params || typeof params !== "object") {
|
||||
throw RequestError.invalidParams(undefined, "goal params must be an object");
|
||||
}
|
||||
const { sessionId, action, objective } = params as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "goal params require a non-empty sessionId");
|
||||
}
|
||||
if (!GOAL_ACTIONS.includes(action as GoalAction)) {
|
||||
throw RequestError.invalidParams(undefined, 'goal action must be "set" or "clear"');
|
||||
}
|
||||
if (action === "set" && (typeof objective !== "string" || objective.trim().length === 0)) {
|
||||
throw RequestError.invalidParams(undefined, 'goal action "set" requires a non-empty objective');
|
||||
}
|
||||
return action === "set"
|
||||
? { sessionId, action, objective: (objective as string).trim() }
|
||||
: { sessionId, action: "clear" };
|
||||
}
|
||||
|
||||
export function toGoalSnapshot(message: SDKActiveGoalMessage): GoalSnapshot | null {
|
||||
if (message.value === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
objective: message.value.condition.trim(),
|
||||
status: "active",
|
||||
iterations: message.value.iterations,
|
||||
lastReason: message.value.last_reason ?? null,
|
||||
createdAt: message.value.set_at,
|
||||
controlMethod: GOAL_CONTROL_METHOD,
|
||||
};
|
||||
}
|
||||
311
src/hide-claude-auth.ts
Normal file
311
src/hide-claude-auth.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* `--hide-claude-auth`: this integration must never bill a claude.ai
|
||||
* subscription. The flag hides the claude.ai login method from `initialize`
|
||||
* and makes the agent behave like a logged-out CLI whenever a subscription
|
||||
* would pay for a turn. `acp-agent.ts` decides *when* the guard applies (flag
|
||||
* on, no provider override) and calls in here for the *what*.
|
||||
*
|
||||
* The account the CLI reports at `initialize` is the source of truth. The SDK
|
||||
* memoizes it, so it is a constant for the life of a query, and two layers
|
||||
* keep it honest:
|
||||
*
|
||||
* 1. Session creation refuses an account that holds no credential this
|
||||
* integration accepts. A logged-out session never exists, so the client's
|
||||
* sign-in always leads to a new session with a fresh `initialize`. The
|
||||
* per-turn guard repeats the check on the same cached account, which costs
|
||||
* microseconds and covers a session created under a provider override that
|
||||
* `providers/disable` later removed.
|
||||
* 2. A sign-out during the session ends the query. The next turn recreates it,
|
||||
* so the next `initialize` reports the real account and layer 1 judges it.
|
||||
* The recreation resumes the stored conversation. When the CLI never wrote
|
||||
* one, because the first turn was the one that signed out, the recreation
|
||||
* starts a fresh query under the same session id instead.
|
||||
*
|
||||
* A file-based credential replaced by a subscription between two turns is seen
|
||||
* by the CLI probe the agent fires at the START of every user prompt. That read
|
||||
* is never awaited, so it usually lands mid-turn; all it does there is mark the
|
||||
* session. The guard consumes the mark at the turn boundary — the next prompt
|
||||
* recreates the query and layer 1 judges the account the new `initialize`
|
||||
* reports — so a probe never interrupts a running turn. A swap that happens
|
||||
* after the last probe stays unseen until the next prompt reads the store
|
||||
* again. Closing that window would cost a blocking read per turn.
|
||||
*/
|
||||
|
||||
import { RequestError } from "@agentclientprotocol/sdk";
|
||||
import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionFailureController } from "./session-failure-extension.js";
|
||||
|
||||
export function shouldHideClaudeAuth(): boolean {
|
||||
return process.argv.includes("--hide-claude-auth");
|
||||
}
|
||||
|
||||
/**
|
||||
* `data.reason` carried by the `authRequired` JSON-RPC error when the flag
|
||||
* blocks a turn. From the client's point of view the agent is logged out (same
|
||||
* error code and flow as a signed-out CLI); the reason lets it render a
|
||||
* different hint. A string enum so further reasons can be added without a
|
||||
* shape change.
|
||||
*/
|
||||
export const CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON = "claude_subscription_not_supported";
|
||||
export type AuthRequiredReason = typeof CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON;
|
||||
|
||||
export const CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE =
|
||||
"This integration does not support using claude.ai subscriptions.";
|
||||
|
||||
/** The message of the plain sign-out refusal. It carries no `reason`: the
|
||||
* account holds nothing this integration can bill, which is the ordinary
|
||||
* signed-out state every ACP client already knows how to answer. */
|
||||
export const CLAUDE_LOGIN_REQUIRED_MESSAGE =
|
||||
"Sign in with an API key or a Console account to use this integration.";
|
||||
|
||||
/**
|
||||
* The `apiKeySource` values that outrank a stored subscription.
|
||||
*
|
||||
* The SDK documents its `ApiKeySource` type as the origin of the credential
|
||||
* used for API requests. `ANTHROPIC_API_KEY` is the environment variable,
|
||||
* `apiKeyHelper` is the configured helper command, and `/login managed key` is
|
||||
* a key that `/login` created with an Anthropic Console account. Each of the
|
||||
* three is an API key that pays instead of the subscription. Every other
|
||||
* member means no API key is in use: `none`, and the legacy `user`, `project`,
|
||||
* `org`, `temporary` and `oauth` that current CLIs never emit.
|
||||
*/
|
||||
const API_KEY_SOURCES_ABOVE_SUBSCRIPTION: ReadonlySet<string> = new Set([
|
||||
"ANTHROPIC_API_KEY",
|
||||
"apiKeyHelper",
|
||||
"/login managed key",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when a turn on this account is paid by a claude.ai subscription.
|
||||
*
|
||||
* The SDK types `apiKeySource` and `tokenSource` as an open `string`, so this
|
||||
* is an allowlist. An account bills the subscription only when every field
|
||||
* proves it, and an unknown value keeps the guard on.
|
||||
*
|
||||
* - `subscriptionType` must be set. Without it the CLI reports no subscription.
|
||||
* - `tokenSource` must be absent. The CLI sets it in place of the subscription
|
||||
* when a bearer or OAuth environment token pays for the turn.
|
||||
* - `apiProvider` must be absent or `firstParty`. The SDK documents that the
|
||||
* Anthropic OAuth login applies only to `firstParty`; for a third-party
|
||||
* backend such as `bedrock`, `vertex`, `foundry` or `gateway` the other
|
||||
* fields are absent and the authentication is external.
|
||||
* - `apiKeySource` must not be one of {@link API_KEY_SOURCES_ABOVE_SUBSCRIPTION}.
|
||||
* Claude Code's credential precedence puts those keys above the `/login`
|
||||
* subscription, so the key pays and the stored subscription is inert.
|
||||
*/
|
||||
export function billsClaudeSubscription(account: AccountInfo | undefined): boolean {
|
||||
if (!account?.subscriptionType) {
|
||||
return false;
|
||||
}
|
||||
if (account.tokenSource) {
|
||||
return false;
|
||||
}
|
||||
if (account.apiProvider !== undefined && account.apiProvider !== "firstParty") {
|
||||
return false;
|
||||
}
|
||||
return !API_KEY_SOURCES_ABOVE_SUBSCRIPTION.has(account.apiKeySource ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the account already holds a credential this integration accepts:
|
||||
* a third-party backend, a bearer or OAuth environment token, or one of the
|
||||
* API key sources in {@link API_KEY_SOURCES_ABOVE_SUBSCRIPTION}.
|
||||
*
|
||||
* False therefore covers both the signed-out CLI and the account whose only
|
||||
* credential is a claude.ai subscription. {@link billsClaudeSubscription} runs
|
||||
* first and separates the two, so a caller that reaches a `false` here is
|
||||
* looking at a session with no way to pay.
|
||||
*/
|
||||
export function holdsNonSubscriptionCredential(account: AccountInfo | undefined): boolean {
|
||||
if (!account) {
|
||||
return false;
|
||||
}
|
||||
if (account.apiProvider !== undefined && account.apiProvider !== "firstParty") {
|
||||
return true;
|
||||
}
|
||||
if (account.tokenSource) {
|
||||
return true;
|
||||
}
|
||||
return API_KEY_SOURCES_ABOVE_SUBSCRIPTION.has(account.apiKeySource ?? "");
|
||||
}
|
||||
|
||||
export function claudeSubscriptionNotSupportedError(): RequestError {
|
||||
return RequestError.authRequired(
|
||||
{ reason: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON satisfies AuthRequiredReason },
|
||||
CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
|
||||
);
|
||||
}
|
||||
|
||||
/** The signed-out refusal. Deliberately without `data`, so a client that reads
|
||||
* `data.reason` sees the plain ACP sign-out it already handles. */
|
||||
export function claudeLoginRequiredError(): RequestError {
|
||||
return RequestError.authRequired(undefined, CLAUDE_LOGIN_REQUIRED_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The state the guard keeps for one session. It lives on the session so that a
|
||||
* concurrent pair of turns shares one account read and one failure row, and so
|
||||
* that each degraded-guard warning is logged once.
|
||||
*/
|
||||
export type ClaudeSubscriptionGuardState = {
|
||||
/** The in-flight guard run. Set before the first `await`. */
|
||||
pendingRun?: Promise<RequestError | undefined>;
|
||||
/** True after the "no account source" warning was logged for this session. */
|
||||
degradedWarningLogged?: boolean;
|
||||
};
|
||||
|
||||
export type GuardLogger = {
|
||||
error: (...args: unknown[]) => void;
|
||||
warn?: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
function warn(logger: GuardLogger, message: string): void {
|
||||
(logger.warn ?? logger.error).call(logger, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report once per session that the guard cannot read the account at all. The
|
||||
* guard is then absent, not passing. The CLI build decides this, not the user,
|
||||
* so a message on every turn would only add noise.
|
||||
*/
|
||||
export function warnClaudeSubscriptionGuardDegraded(options: {
|
||||
sessionId: string;
|
||||
guardState: ClaudeSubscriptionGuardState;
|
||||
logger: GuardLogger;
|
||||
cause: string;
|
||||
}): void {
|
||||
const { guardState } = options;
|
||||
if (guardState.degradedWarningLogged) {
|
||||
return;
|
||||
}
|
||||
guardState.degradedWarningLogged = true;
|
||||
warn(
|
||||
options.logger,
|
||||
`Session ${options.sessionId}: the --hide-claude-auth subscription guard is degraded ` +
|
||||
`(${options.cause}). This session cannot detect a claude.ai subscription.`,
|
||||
);
|
||||
}
|
||||
|
||||
type GuardOptions = {
|
||||
sessionId: string;
|
||||
query: Pick<Query, "accountInfo">;
|
||||
guardState: ClaudeSubscriptionGuardState;
|
||||
/** Built lazily: most turns pass and never need a controller. */
|
||||
sessionFailures: () => SessionFailureController;
|
||||
logger: GuardLogger;
|
||||
/** Display hook: called with the account the guard just read, before the
|
||||
* guard decides on it. It exists so a refused turn still reports which
|
||||
* account it was refused for. Purely observational — this module knows
|
||||
* nothing about what the caller does with it, and a throw from here never
|
||||
* changes the verdict. */
|
||||
onAccount?: (account: AccountInfo) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-turn guard for a live session.
|
||||
*
|
||||
* It decides on the account the SDK cached at `initialize`, which is this
|
||||
* integration's source of truth. That account is a constant for the life of
|
||||
* the query, so the check costs microseconds. It still earns its place: it
|
||||
* covers a session created under a provider override that `providers/disable`
|
||||
* later removed, so the create-time check never ran for the routing in force
|
||||
* now. A sign-out ends the query (see `markSessionForSignOutRespawn` in
|
||||
* `acp-agent.ts`), so the next turn recreates it and `initialize` reports the
|
||||
* real account. This guard does not see the residual case the module header
|
||||
* names either.
|
||||
*
|
||||
* A turn is refused when the account bills a subscription, and also when it
|
||||
* holds nothing this integration accepts, which is the user signing out in
|
||||
* mid-session. The refusal mirrors the signed-out path exactly (see
|
||||
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
|
||||
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
|
||||
* flow, and a capable client additionally gets one session-scoped access
|
||||
* failure whose `login` action stays the way back in.
|
||||
*
|
||||
* Fails open when no account can be read at all: session creation already
|
||||
* refused every account without a usable credential, and an unavailable probe
|
||||
* must not block a session that passed that check.
|
||||
*/
|
||||
export async function refuseClaudeSubscriptionTurn(options: GuardOptions): Promise<void> {
|
||||
// Concurrent turns must not each re-read the account and each publish a row.
|
||||
// Store the run before the first await, and let the others await the same one.
|
||||
const { guardState } = options;
|
||||
const run = (guardState.pendingRun ??= evaluateGuard(options).finally(() => {
|
||||
if (guardState.pendingRun === run) {
|
||||
guardState.pendingRun = undefined;
|
||||
}
|
||||
}));
|
||||
const refusal = await run;
|
||||
if (refusal) {
|
||||
throw refusal;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the account, publish at most one failure row, and return the error the
|
||||
* turn must be rejected with. Never throws: an unreadable account fails open. */
|
||||
async function evaluateGuard(options: GuardOptions): Promise<RequestError | undefined> {
|
||||
const account = await readGuardAccount(options);
|
||||
if (!account) {
|
||||
return undefined;
|
||||
}
|
||||
// Before the decision, so the client learns the identity even when the next
|
||||
// lines refuse the turn. A display hook must never fail a guard.
|
||||
try {
|
||||
options.onAccount?.(account);
|
||||
} catch (error) {
|
||||
options.logger.error(
|
||||
`Session ${options.sessionId}: the guard's onAccount hook threw:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (billsClaudeSubscription(account)) {
|
||||
await publishRefusal(options.sessionFailures(), {
|
||||
details: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
|
||||
reason: CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON,
|
||||
});
|
||||
return claudeSubscriptionNotSupportedError();
|
||||
}
|
||||
if (!holdsNonSubscriptionCredential(account)) {
|
||||
await publishRefusal(options.sessionFailures(), { details: CLAUDE_LOGIN_REQUIRED_MESSAGE });
|
||||
return claudeLoginRequiredError();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The account the SDK cached at `initialize`. `undefined` when the query
|
||||
* cannot answer at all. */
|
||||
async function readGuardAccount(options: GuardOptions): Promise<AccountInfo | undefined> {
|
||||
const { query } = options;
|
||||
if (typeof query.accountInfo !== "function") {
|
||||
warnClaudeSubscriptionGuardDegraded({
|
||||
...options,
|
||||
cause: "the SDK query has no accountInfo method",
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await query.accountInfo();
|
||||
} catch (error) {
|
||||
options.logger.error(
|
||||
`Session ${options.sessionId}: accountInfo failed; skipping the subscription check for this turn:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The refusal state does not change between turns: publish once, and skip a
|
||||
* republish while the previous failure with this reason is still active. */
|
||||
async function publishRefusal(
|
||||
sessionFailures: SessionFailureController,
|
||||
failure: { details: string; reason?: string },
|
||||
): Promise<void> {
|
||||
if (sessionFailures.hasActiveSessionError("auth_required", failure.reason)) {
|
||||
return;
|
||||
}
|
||||
await sessionFailures.publish("auth_required", {
|
||||
sessionScoped: true,
|
||||
details: failure.details,
|
||||
...(failure.reason ? { reason: failure.reason } : {}),
|
||||
});
|
||||
}
|
||||
26
src/index.ts
26
src/index.ts
@@ -3,6 +3,8 @@
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// `--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
|
||||
@@ -61,7 +63,29 @@ if (process.argv.includes("--cli")) {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
const { connection, agent } = runAcp();
|
||||
const logDirectory = process.env.CLAUDE_AGENT_LOGS;
|
||||
const logger = logDirectory
|
||||
? (() => {
|
||||
mkdirSync(logDirectory, { recursive: true });
|
||||
const logFile = join(logDirectory, "agent.log");
|
||||
const writeLog = (...args: unknown[]) => {
|
||||
const rendered = args
|
||||
.map((arg) => (arg instanceof Error ? (arg.stack ?? arg.message) : String(arg)))
|
||||
.join(" ");
|
||||
appendFileSync(logFile, `${new Date().toISOString()} pid=${process.pid} ${rendered}\n`);
|
||||
};
|
||||
return {
|
||||
log: writeLog,
|
||||
error: (...args: unknown[]) => {
|
||||
console.error(...args);
|
||||
writeLog(...args);
|
||||
},
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
logger?.log("Claude ACP started");
|
||||
|
||||
const { connection, agent } = runAcp(logger);
|
||||
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
|
||||
@@ -18,6 +18,15 @@ export {
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
|
||||
// The `authStatus` extension's wire surface: the notification's method name and
|
||||
// the payload types needed to read the traffic. The mappers stay internal.
|
||||
export {
|
||||
AUTH_STATUS_UPDATE_METHOD,
|
||||
type AuthStatus,
|
||||
type AuthStatusAccount,
|
||||
type AuthStatusKind,
|
||||
type AuthStatusUpdateNotification,
|
||||
} from "./auth-status.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
|
||||
537
src/native-subagents.ts
Normal file
537
src/native-subagents.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
|
||||
export type NativeSubagent = {
|
||||
sessionId: string;
|
||||
parentSessionId: string;
|
||||
parentToolUseId?: string;
|
||||
name: string;
|
||||
task: string;
|
||||
announced?: boolean;
|
||||
terminalState?: SubagentState;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
announcePromise?: Promise<void>;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
terminalPromise?: Promise<void>;
|
||||
};
|
||||
|
||||
export type NativeSubagentSession = {
|
||||
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
|
||||
nativeSubagentTaskIdByToolUseId?: Map<string, string>;
|
||||
nativeSubagentParentByToolUseId?: Map<string, string>;
|
||||
};
|
||||
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
type Logger = { log(message: string): void };
|
||||
|
||||
type TaskStarted = {
|
||||
taskId: string;
|
||||
toolUseId?: string | null;
|
||||
subagentType?: unknown;
|
||||
description?: unknown;
|
||||
prompt?: unknown;
|
||||
};
|
||||
|
||||
type SubagentIdentity = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
subagentType?: string;
|
||||
};
|
||||
|
||||
const MAX_PENDING_PARENTS = 64;
|
||||
const MAX_PENDING_UPDATES = 256;
|
||||
const MAX_PENDING_UPDATES_PER_PARENT = 32;
|
||||
|
||||
/**
|
||||
* Owns the connection-local native subagent registry and all ACP lifecycle
|
||||
* ordering. The main agent only supplies SDK facts and delivers routed output.
|
||||
*/
|
||||
export class NativeSubagentRuntime {
|
||||
readonly enabled: boolean;
|
||||
|
||||
private readonly children: Map<string, NativeSubagent>;
|
||||
private readonly taskByToolUse: Map<string, string>;
|
||||
private readonly parentByToolUse: Map<string, string>;
|
||||
private readonly identityByToolUse = new Map<string, SubagentIdentity>();
|
||||
private readonly controlByToolUse = new Map<string, AcpSessionNotification>();
|
||||
private readonly childByParentToolUse = new Map<string, NativeSubagent>();
|
||||
private readonly taskFinishPromises = new Map<string, Promise<void>>();
|
||||
private readonly generationByTaskId = new Map<string, number>();
|
||||
private readonly pending = new Map<string, AcpSessionNotification[]>();
|
||||
private pendingCount = 0;
|
||||
|
||||
constructor(
|
||||
enabled: boolean,
|
||||
private readonly rootSessionId: string,
|
||||
private readonly session: NativeSubagentSession,
|
||||
private readonly publish: Publish,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.enabled = enabled;
|
||||
this.children = session.nativeSubagentsByTaskId ??= new Map();
|
||||
this.taskByToolUse = session.nativeSubagentTaskIdByToolUseId ??= new Map();
|
||||
this.parentByToolUse = session.nativeSubagentParentByToolUseId ??= new Map();
|
||||
for (const child of this.children.values()) {
|
||||
if (child.parentToolUseId) {
|
||||
this.childByParentToolUse.set(child.parentToolUseId, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async route(
|
||||
notification: AcpSessionNotification,
|
||||
deliver: Publish,
|
||||
forcedSessionId?: string,
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const { update } = notification;
|
||||
const claudeMeta = update._meta?.claudeCode as
|
||||
{ parentToolUseId?: string | null; subagent?: true; toolName?: string } | undefined;
|
||||
const isControl = isNativeSubagentControlUpdate(update);
|
||||
|
||||
if (!this.enabled) return notification;
|
||||
|
||||
if (this.enabled && isControl) {
|
||||
const toolCallId = update.toolCallId;
|
||||
if (update.sessionUpdate === "tool_call") {
|
||||
this.controlByToolUse.set(toolCallId, notification);
|
||||
}
|
||||
const identity = subagentIdentity(update.rawInput);
|
||||
if (identity) {
|
||||
this.identityByToolUse.set(
|
||||
toolCallId,
|
||||
mergeSubagentIdentity(this.identityByToolUse.get(toolCallId), identity),
|
||||
);
|
||||
}
|
||||
const parentSessionId = claudeMeta?.parentToolUseId
|
||||
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)?.sessionId
|
||||
: this.rootSessionId;
|
||||
this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
|
||||
|
||||
const child = this.childByParentToolUse.get(toolCallId);
|
||||
if (child && !child.announced) {
|
||||
child.parentSessionId = parentSessionId ?? this.rootSessionId;
|
||||
applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of this.takePending(toolCallId)) await deliver(pending);
|
||||
}
|
||||
if (!child && isFailedToolCallUpdate(update)) {
|
||||
this.takePending(toolCallId);
|
||||
const initial = this.controlByToolUse.get(toolCallId);
|
||||
this.cleanupControl(toolCallId);
|
||||
if (forcedSessionId) {
|
||||
return { ...notification, sessionId: forcedSessionId };
|
||||
}
|
||||
return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
|
||||
}
|
||||
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
|
||||
}
|
||||
|
||||
// A permission request may have had to create the tool call before native
|
||||
// child ownership was known. Keep every later update in that original ACP
|
||||
// session; moving a lifecycle after its initial call creates an orphan in
|
||||
// both transcripts.
|
||||
if (forcedSessionId) return { ...notification, sessionId: forcedSessionId };
|
||||
|
||||
if (this.enabled && claudeMeta?.parentToolUseId) {
|
||||
const child = this.childByParentToolUse.get(claudeMeta.parentToolUseId);
|
||||
if (!child || !child.announced) {
|
||||
this.buffer(claudeMeta.parentToolUseId, notification);
|
||||
return null;
|
||||
}
|
||||
if (child.terminalState !== undefined || child.terminalPromise) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { ...notification, sessionId: child.sessionId };
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
async taskStarted(task: TaskStarted, deliver: Publish): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
if (!task.subagentType) {
|
||||
if (task.toolUseId) {
|
||||
this.takePending(task.toolUseId);
|
||||
this.cleanupControl(task.toolUseId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const previous = this.children.get(task.taskId);
|
||||
if (previous && previous.terminalState === undefined) return;
|
||||
|
||||
const knownParentSessionId = task.toolUseId
|
||||
? this.parentByToolUse.get(task.toolUseId)
|
||||
: undefined;
|
||||
const identity = task.toolUseId ? this.identityByToolUse.get(task.toolUseId) : undefined;
|
||||
const child: NativeSubagent = {
|
||||
sessionId: this.nextChildSessionId(task.taskId, previous),
|
||||
parentSessionId: knownParentSessionId ?? this.rootSessionId,
|
||||
parentToolUseId: task.toolUseId ?? undefined,
|
||||
name: subagentDisplayName(
|
||||
identity?.name,
|
||||
identity?.description ?? task.description,
|
||||
identity?.subagentType ?? task.subagentType,
|
||||
task.taskId,
|
||||
),
|
||||
task: subagentDescription(
|
||||
identity?.prompt ?? task.prompt,
|
||||
identity?.description ?? task.description,
|
||||
),
|
||||
};
|
||||
this.children.set(task.taskId, child);
|
||||
if (task.toolUseId) {
|
||||
this.taskByToolUse.set(task.toolUseId, task.taskId);
|
||||
this.childByParentToolUse.set(task.toolUseId, child);
|
||||
this.controlByToolUse.delete(task.toolUseId);
|
||||
}
|
||||
|
||||
// A nested child must wait for the spawning Agent/Task frame to establish
|
||||
// its immediate parent. Root children without a tool id can be announced.
|
||||
if (knownParentSessionId || !task.toolUseId) {
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of task.toolUseId ? this.takePending(task.toolUseId) : []) {
|
||||
await deliver(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async finishTask(
|
||||
taskId: string,
|
||||
status: unknown,
|
||||
deliver: Publish,
|
||||
toolUseId?: string | null,
|
||||
): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const state = nativeSubagentState(status);
|
||||
const child = toolUseId ? this.childByParentToolUse.get(toolUseId) : this.children.get(taskId);
|
||||
if (child && toolUseId && this.taskByToolUse.get(toolUseId) !== taskId) return;
|
||||
if (!state || !child || child.terminalState !== undefined) return;
|
||||
const existing = this.taskFinishPromises.get(taskId);
|
||||
if (existing) return existing;
|
||||
|
||||
const finish = Promise.resolve().then(async () => {
|
||||
try {
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
if (child.parentToolUseId) {
|
||||
for (const pending of this.takePending(child.parentToolUseId)) await deliver(pending);
|
||||
}
|
||||
await finishNativeSubagent(this.session, taskId, state, this.publish);
|
||||
} finally {
|
||||
if (child.parentToolUseId) {
|
||||
this.cleanupControl(child.parentToolUseId);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.taskFinishPromises.set(taskId, finish);
|
||||
try {
|
||||
await finish;
|
||||
} finally {
|
||||
if (this.taskFinishPromises.get(taskId) === finish) this.taskFinishPromises.delete(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
async finishAll(state: SubagentState, deliver: Publish): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
try {
|
||||
for (const taskId of [...this.children.keys()].reverse()) {
|
||||
try {
|
||||
await this.finishTask(taskId, state, deliver);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.pending.clear();
|
||||
this.pendingCount = 0;
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.parentByToolUse.clear();
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to finish native subagents");
|
||||
}
|
||||
|
||||
discardPending(parentToolUseId: string): void {
|
||||
this.takePending(parentToolUseId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.children.clear();
|
||||
this.taskByToolUse.clear();
|
||||
this.parentByToolUse.clear();
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.childByParentToolUse.clear();
|
||||
this.taskFinishPromises.clear();
|
||||
this.generationByTaskId.clear();
|
||||
this.pending.clear();
|
||||
this.pendingCount = 0;
|
||||
}
|
||||
|
||||
private takePending(parentToolUseId: string): AcpSessionNotification[] {
|
||||
const updates = this.pending.get(parentToolUseId) ?? [];
|
||||
if (updates.length > 0) {
|
||||
this.pending.delete(parentToolUseId);
|
||||
this.pendingCount -= updates.length;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
private buffer(parentToolUseId: string, notification: AcpSessionNotification): void {
|
||||
const updates = this.pending.get(parentToolUseId);
|
||||
if (
|
||||
this.pendingCount >= MAX_PENDING_UPDATES ||
|
||||
(updates === undefined && this.pending.size >= MAX_PENDING_PARENTS) ||
|
||||
(updates?.length ?? 0) >= MAX_PENDING_UPDATES_PER_PARENT
|
||||
) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: dropping unattributed subagent update for ${parentToolUseId}; pending buffer limit reached`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (updates) updates.push(notification);
|
||||
else this.pending.set(parentToolUseId, [notification]);
|
||||
this.pendingCount++;
|
||||
}
|
||||
|
||||
private cleanupControl(toolUseId: string): void {
|
||||
this.identityByToolUse.delete(toolUseId);
|
||||
this.controlByToolUse.delete(toolUseId);
|
||||
this.parentByToolUse.delete(toolUseId);
|
||||
}
|
||||
|
||||
private nextChildSessionId(taskId: string, previous: NativeSubagent | undefined): string {
|
||||
if (!previous) {
|
||||
this.generationByTaskId.set(taskId, 1);
|
||||
return taskId;
|
||||
}
|
||||
const generation = (this.generationByTaskId.get(taskId) ?? 1) + 1;
|
||||
this.generationByTaskId.set(taskId, generation);
|
||||
return `${taskId}:generation:${generation}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function announceNativeSubagent(
|
||||
child: NativeSubagent,
|
||||
publish: Publish,
|
||||
): Promise<void> {
|
||||
if (child.announced) return;
|
||||
if (child.announcePromise) return child.announcePromise;
|
||||
const announce = Promise.resolve().then(async () => {
|
||||
await publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_spawned",
|
||||
subagentSessionId: child.sessionId,
|
||||
name: child.name,
|
||||
task: child.task,
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
child.announced = true;
|
||||
});
|
||||
child.announcePromise = announce;
|
||||
try {
|
||||
await announce;
|
||||
} finally {
|
||||
if (child.announcePromise === announce) child.announcePromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function finishNativeSubagent(
|
||||
session: NativeSubagentSession,
|
||||
taskId: string,
|
||||
state: SubagentState,
|
||||
publish: Publish,
|
||||
): Promise<void> {
|
||||
const child = session.nativeSubagentsByTaskId?.get(taskId);
|
||||
if (!child || child.terminalState !== undefined) return;
|
||||
if (child.terminalPromise) return child.terminalPromise;
|
||||
const finish = Promise.resolve().then(async () => {
|
||||
await announceNativeSubagent(child, publish);
|
||||
await publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_state_update",
|
||||
subagentSessionId: child.sessionId,
|
||||
state,
|
||||
},
|
||||
});
|
||||
child.terminalState = state;
|
||||
});
|
||||
child.terminalPromise = finish;
|
||||
try {
|
||||
await finish;
|
||||
} finally {
|
||||
if (child.terminalPromise === finish) child.terminalPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeSubagentState(status: unknown): SubagentState | undefined {
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "disconnected") return "disconnected";
|
||||
if (status === "killed" || status === "cancelled" || status === "stopped") return "cancelled";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isNativeSubagentControlUpdate(
|
||||
update: AcpSessionNotification["update"],
|
||||
): update is Extract<
|
||||
AcpSessionNotification["update"],
|
||||
{ sessionUpdate: "tool_call" | "tool_call_update" }
|
||||
> {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return false;
|
||||
}
|
||||
const claudeMeta = update._meta?.claudeCode as { subagent?: true; toolName?: string } | undefined;
|
||||
return claudeMeta?.subagent === true || isNativeSubagentControlTool(claudeMeta?.toolName);
|
||||
}
|
||||
|
||||
export function isNativeSubagentControlTool(toolName: unknown): boolean {
|
||||
return toolName === "Agent" || toolName === "Task";
|
||||
}
|
||||
|
||||
function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boolean {
|
||||
return (
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
update.status === "failed"
|
||||
);
|
||||
}
|
||||
|
||||
function failedControlFallback(
|
||||
initial: AcpSessionNotification | undefined,
|
||||
terminal: AcpSessionNotification,
|
||||
sessionId: string,
|
||||
): AcpSessionNotification {
|
||||
if (
|
||||
terminal.update.sessionUpdate !== "tool_call" &&
|
||||
terminal.update.sessionUpdate !== "tool_call_update"
|
||||
) {
|
||||
return { ...terminal, sessionId };
|
||||
}
|
||||
if (!initial || initial.update.sessionUpdate !== "tool_call") {
|
||||
const claudeMeta = terminal.update._meta?.claudeCode as { toolName?: unknown } | undefined;
|
||||
return {
|
||||
...terminal,
|
||||
sessionId,
|
||||
update: {
|
||||
...terminal.update,
|
||||
sessionUpdate: "tool_call",
|
||||
status: "failed",
|
||||
// The synthesized tool_call is this call's first report, so give it
|
||||
// the standard `name` the initial one would have carried.
|
||||
...(typeof claudeMeta?.toolName === "string" ? { name: claudeMeta.toolName } : {}),
|
||||
title:
|
||||
typeof terminal.update.title === "string" && terminal.update.title.length > 0
|
||||
? terminal.update.title
|
||||
: claudeMeta?.toolName === "Task"
|
||||
? "Task"
|
||||
: "Agent",
|
||||
_meta: ordinaryToolMeta(terminal.update._meta),
|
||||
} as AcpSessionNotification["update"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...initial,
|
||||
sessionId,
|
||||
update: {
|
||||
...initial.update,
|
||||
...terminal.update,
|
||||
sessionUpdate: "tool_call",
|
||||
status: "failed",
|
||||
title:
|
||||
typeof terminal.update.title === "string" && terminal.update.title.trim().length > 0
|
||||
? terminal.update.title
|
||||
: initial.update.title,
|
||||
_meta: {
|
||||
...initial.update._meta,
|
||||
...terminal.update._meta,
|
||||
...ordinaryToolMeta(initial.update._meta, terminal.update._meta),
|
||||
},
|
||||
} as AcpSessionNotification["update"],
|
||||
};
|
||||
}
|
||||
|
||||
function ordinaryToolMeta(
|
||||
...values: Array<Record<string, unknown> | null | undefined>
|
||||
): Record<string, unknown> {
|
||||
const merged = Object.assign({}, ...values);
|
||||
const claudeCode = Object.assign(
|
||||
{},
|
||||
...values.map(
|
||||
(value) => (value?.claudeCode as Record<string, unknown> | null | undefined) ?? {},
|
||||
),
|
||||
);
|
||||
delete claudeCode.subagent;
|
||||
return { ...merged, claudeCode };
|
||||
}
|
||||
|
||||
function subagentDisplayName(
|
||||
explicitName: unknown,
|
||||
description: unknown,
|
||||
type: unknown,
|
||||
taskId: string,
|
||||
): string {
|
||||
for (const value of [explicitName, description, type]) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
const suffix = taskId.length > 8 ? taskId.slice(-8) : taskId;
|
||||
return `Agent ${suffix}`;
|
||||
}
|
||||
|
||||
function subagentDescription(prompt: unknown, description: unknown): string {
|
||||
for (const value of [prompt, description]) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return "Delegated task";
|
||||
}
|
||||
|
||||
function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return undefined;
|
||||
const value = input as Record<string, unknown>;
|
||||
const identity: SubagentIdentity = {
|
||||
name: nonBlankString(value.name),
|
||||
description: nonBlankString(value.description),
|
||||
prompt: nonBlankString(value.prompt),
|
||||
subagentType: nonBlankString(value.subagent_type),
|
||||
};
|
||||
return Object.values(identity).some(Boolean) ? identity : undefined;
|
||||
}
|
||||
|
||||
function mergeSubagentIdentity(
|
||||
previous: SubagentIdentity | undefined,
|
||||
next: SubagentIdentity,
|
||||
): SubagentIdentity {
|
||||
return {
|
||||
name: next.name ?? previous?.name,
|
||||
description: next.description ?? previous?.description,
|
||||
prompt: next.prompt ?? previous?.prompt,
|
||||
subagentType: next.subagentType ?? previous?.subagentType,
|
||||
};
|
||||
}
|
||||
|
||||
function applySubagentIdentity(
|
||||
child: NativeSubagent,
|
||||
identity: SubagentIdentity | undefined,
|
||||
): void {
|
||||
if (!identity) return;
|
||||
if (identity.name || identity.description) {
|
||||
child.name = subagentDisplayName(
|
||||
identity.name,
|
||||
identity.description,
|
||||
identity.subagentType,
|
||||
child.sessionId,
|
||||
);
|
||||
}
|
||||
if (identity.prompt || identity.description) {
|
||||
child.task = subagentDescription(identity.prompt, identity.description);
|
||||
}
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
223
src/permissions/effects.ts
Normal file
223
src/permissions/effects.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { RequestPermissionResponse } from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
PermissionUpdate,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { DurablePermissionChangeSet } from "./normalization.js";
|
||||
import { PERMISSION_OPTION_ID } from "./options.js";
|
||||
|
||||
export interface ClaudePermissionSelection {
|
||||
optionId: string;
|
||||
contextResetMode?: PermissionMode;
|
||||
}
|
||||
|
||||
export interface ClaudePermissionEffectContext {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
toolUseID: string;
|
||||
durableChangeSet?: DurablePermissionChangeSet;
|
||||
}
|
||||
|
||||
/** Parse the ACP envelope once before dispatching to a tool-specific effect. */
|
||||
export function parseClaudePermissionSelection(
|
||||
response: RequestPermissionResponse,
|
||||
toolName: string,
|
||||
): ClaudePermissionSelection {
|
||||
if (response.outcome?.outcome !== "selected") throw new Error("Tool use aborted");
|
||||
const optionId = response.outcome.optionId;
|
||||
const contextResetMode =
|
||||
toolName === "ExitPlanMode" ? exitPlanClearContextMode(optionId) : undefined;
|
||||
return { optionId, ...(contextResetMode ? { contextResetMode } : {}) };
|
||||
}
|
||||
|
||||
function allow(
|
||||
context: ClaudePermissionEffectContext,
|
||||
updatedPermissions?: PermissionUpdate[],
|
||||
permanent = false,
|
||||
): PermissionResult {
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: context.input,
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
toolUseID: context.toolUseID,
|
||||
decisionClassification: permanent ? "user_permanent" : "user_temporary",
|
||||
};
|
||||
}
|
||||
|
||||
function deny(
|
||||
context: ClaudePermissionEffectContext,
|
||||
message = "User refused permission to run tool",
|
||||
interrupt = false,
|
||||
): PermissionResult {
|
||||
return {
|
||||
behavior: "deny",
|
||||
message,
|
||||
...(interrupt ? { interrupt: true } : {}),
|
||||
toolUseID: context.toolUseID,
|
||||
decisionClassification: "user_reject",
|
||||
};
|
||||
}
|
||||
|
||||
function skillName(input: Record<string, unknown>): string {
|
||||
const value = typeof input.skill === "string" ? input.skill.trim() : "";
|
||||
if (!value || value.length > 1_000) throw new Error("Invalid Skill permission input");
|
||||
return value;
|
||||
}
|
||||
|
||||
function localAllowRuleUpdate(toolName: string, ruleContent?: string): PermissionUpdate[] {
|
||||
return [
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName, ...(ruleContent === undefined ? {} : { ruleContent }) }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function webFetchPermissionUpdate(input: Record<string, unknown>): PermissionUpdate[] {
|
||||
if (typeof input.url !== "string") throw new Error("Invalid WebFetch permission input");
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(input.url).hostname;
|
||||
} catch {
|
||||
throw new Error("Invalid WebFetch permission input");
|
||||
}
|
||||
if (!hostname) throw new Error("Invalid WebFetch permission input");
|
||||
return localAllowRuleUpdate("WebFetch", `domain:${hostname}`);
|
||||
}
|
||||
|
||||
function applySkillSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
): PermissionResult {
|
||||
switch (selection.optionId) {
|
||||
case PERMISSION_OPTION_ID.allowSkillExact: {
|
||||
return allow(context, localAllowRuleUpdate("Skill", skillName(context.input)), true);
|
||||
}
|
||||
case PERMISSION_OPTION_ID.allowSkillPrefix: {
|
||||
const skill = skillName(context.input);
|
||||
const spaceIndex = skill.indexOf(" ");
|
||||
if (spaceIndex <= 0) throw new Error("Skill prefix permission requires arguments");
|
||||
return allow(context, localAllowRuleUpdate("Skill", `${skill.slice(0, spaceIndex)}:*`), true);
|
||||
}
|
||||
default:
|
||||
return applyCommonSelection(selection, context);
|
||||
}
|
||||
}
|
||||
|
||||
function exitPlanMode(optionId: string): PermissionMode | undefined {
|
||||
switch (optionId) {
|
||||
case PERMISSION_OPTION_ID.exitPlanBypass:
|
||||
return "bypassPermissions";
|
||||
case PERMISSION_OPTION_ID.exitPlanAuto:
|
||||
return "auto";
|
||||
case PERMISSION_OPTION_ID.exitPlanAcceptEdits:
|
||||
return "acceptEdits";
|
||||
case PERMISSION_OPTION_ID.exitPlanDefault:
|
||||
return "default";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function exitPlanClearContextMode(optionId: string): PermissionMode | undefined {
|
||||
switch (optionId) {
|
||||
case PERMISSION_OPTION_ID.exitPlanClearAuto:
|
||||
return "auto";
|
||||
case PERMISSION_OPTION_ID.exitPlanClearBypass:
|
||||
return "bypassPermissions";
|
||||
case PERMISSION_OPTION_ID.exitPlanClearAcceptEdits:
|
||||
return "acceptEdits";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function applyExitPlanModeSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
): PermissionResult {
|
||||
if (selection.contextResetMode) {
|
||||
// The adapter consumes this interrupt and continues the same ACP turn on a
|
||||
// fresh Claude query. Returning allow here would execute ExitPlanMode in
|
||||
// the old context before the handoff.
|
||||
return deny(context, "User accepted the plan and requested a fresh context", true);
|
||||
}
|
||||
const mode = exitPlanMode(selection.optionId);
|
||||
if (mode) {
|
||||
return allow(context, [{ type: "setMode", mode, destination: "session" }], mode !== "default");
|
||||
}
|
||||
if (selection.optionId === PERMISSION_OPTION_ID.reject) {
|
||||
// A regular deny lets Claude continue planning and ask another question.
|
||||
// Interrupt stops this ACP turn; the adapter maps Claude's internal
|
||||
// diagnostic for that intentional stop back to cancellation.
|
||||
return deny(context, "User chose to keep planning", true);
|
||||
}
|
||||
return applyCommonSelection(selection, context);
|
||||
}
|
||||
|
||||
function applyCommonSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
): PermissionResult {
|
||||
switch (selection.optionId) {
|
||||
case PERMISSION_OPTION_ID.allowOnce:
|
||||
return allow(context);
|
||||
case PERMISSION_OPTION_ID.allowWithUpdates:
|
||||
if (!context.durableChangeSet) throw new Error("Invalid durable permission selection");
|
||||
return allow(context, context.durableChangeSet.updates, true);
|
||||
case PERMISSION_OPTION_ID.reject:
|
||||
return deny(context);
|
||||
default:
|
||||
throw new Error(`Unknown permission option: ${selection.optionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function applyGeneratedDurableSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
updates: () => PermissionUpdate[],
|
||||
): PermissionResult {
|
||||
if (selection.optionId !== PERMISSION_OPTION_ID.allowWithUpdates) {
|
||||
return applyCommonSelection(selection, context);
|
||||
}
|
||||
return allow(context, updates(), true);
|
||||
}
|
||||
|
||||
/** Apply a parsed selection using the semantics of the tool that produced it. */
|
||||
export function applyClaudePermissionSelection(
|
||||
selection: ClaudePermissionSelection,
|
||||
context: ClaudePermissionEffectContext,
|
||||
): PermissionResult {
|
||||
switch (context.toolName) {
|
||||
case "Skill":
|
||||
return applySkillSelection(selection, context);
|
||||
case "WebFetch":
|
||||
return applyGeneratedDurableSelection(selection, context, () =>
|
||||
webFetchPermissionUpdate(context.input),
|
||||
);
|
||||
case "EnterPlanMode":
|
||||
return applyCommonSelection(selection, context);
|
||||
case "ExitPlanMode":
|
||||
return applyExitPlanModeSelection(selection, context);
|
||||
case "Read":
|
||||
case "Bash":
|
||||
case "PowerShell":
|
||||
case "Glob":
|
||||
case "Grep":
|
||||
case "Edit":
|
||||
case "Write":
|
||||
case "NotebookEdit":
|
||||
case "SandboxNetworkAccess":
|
||||
return applyCommonSelection(selection, context);
|
||||
default:
|
||||
if (context.toolName.startsWith("mcp__")) {
|
||||
return applyCommonSelection(selection, context);
|
||||
}
|
||||
return applyGeneratedDurableSelection(selection, context, () =>
|
||||
localAllowRuleUpdate(context.toolName),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
src/permissions/modes.ts
Normal file
60
src/permissions/modes.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
interface PermissionModeLogger {
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
// Bypass Permissions doesn't work if we are a root/sudo user.
|
||||
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
|
||||
export const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
||||
|
||||
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
// Settings use user-facing, case-insensitive spellings while the SDK uses
|
||||
// camel-cased wire values. Keep legacy shorthand accepted by Claude Code too.
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
// Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts
|
||||
// `"defaultMode": "manual"` in settings.json; honor the same alias here.
|
||||
manual: "default",
|
||||
acceptedits: "acceptEdits",
|
||||
dontask: "dontAsk",
|
||||
plan: "plan",
|
||||
bypasspermissions: "bypassPermissions",
|
||||
bypass: "bypassPermissions",
|
||||
};
|
||||
|
||||
/** When `allowBypass` is false, `bypassPermissions` clamps to `default` so the SDK
|
||||
* is never spawned in a mode it would reject. */
|
||||
export function resolvePermissionMode(
|
||||
defaultMode?: unknown,
|
||||
logger: PermissionModeLogger = console,
|
||||
allowBypass: boolean = ALLOW_BYPASS,
|
||||
): PermissionMode {
|
||||
if (defaultMode === undefined) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (typeof defaultMode !== "string") {
|
||||
logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
|
||||
return "default";
|
||||
}
|
||||
|
||||
const normalized = defaultMode.trim().toLowerCase();
|
||||
if (normalized === "") {
|
||||
logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
|
||||
return "default";
|
||||
}
|
||||
|
||||
const mapped = PERMISSION_MODE_ALIASES[normalized];
|
||||
if (!mapped) {
|
||||
logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (mapped === "bypassPermissions" && !allowBypass) {
|
||||
logger.error("Ignoring permission mode bypassPermissions: not available in this session.");
|
||||
return "default";
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
130
src/permissions/normalization.ts
Normal file
130
src/permissions/normalization.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
PermissionBehavior,
|
||||
PermissionMode,
|
||||
PermissionUpdate,
|
||||
PermissionUpdateDestination,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export interface DurablePermissionChangeSet {
|
||||
updates: PermissionUpdate[];
|
||||
}
|
||||
|
||||
const MAX_UPDATES = 100;
|
||||
const MAX_RULES_PER_UPDATE = 100;
|
||||
const MAX_DIRECTORIES_PER_UPDATE = 100;
|
||||
const MAX_TOOL_NAME_LENGTH = 512;
|
||||
const MAX_RULE_CONTENT_LENGTH = 10_000;
|
||||
const MAX_DIRECTORY_LENGTH = 4_096;
|
||||
|
||||
const DESTINATIONS = new Set<PermissionUpdateDestination>([
|
||||
"session",
|
||||
"cliArg",
|
||||
"userSettings",
|
||||
"projectSettings",
|
||||
"localSettings",
|
||||
]);
|
||||
const BEHAVIORS = new Set<PermissionBehavior>(["allow", "deny", "ask"]);
|
||||
const MODES = new Set<PermissionMode>([
|
||||
"default",
|
||||
"acceptEdits",
|
||||
"bypassPermissions",
|
||||
"plan",
|
||||
"dontAsk",
|
||||
"auto",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isDestination(value: unknown): value is PermissionUpdateDestination {
|
||||
return typeof value === "string" && DESTINATIONS.has(value as PermissionUpdateDestination);
|
||||
}
|
||||
|
||||
function safePlainString(value: unknown, maxLength: number): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
const normalized = value.trim();
|
||||
return (
|
||||
normalized.length > 0 &&
|
||||
value.length <= maxLength &&
|
||||
!Array.from(normalized).some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return (
|
||||
code <= 0x08 ||
|
||||
code === 0x0b ||
|
||||
code === 0x0c ||
|
||||
(code >= 0x0e && code <= 0x1f) ||
|
||||
code === 0x7f
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isRule(value: unknown): value is { toolName: string; ruleContent?: string } {
|
||||
if (!isRecord(value) || !safePlainString(value.toolName, MAX_TOOL_NAME_LENGTH)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
value.ruleContent === undefined || safePlainString(value.ruleContent, MAX_RULE_CONTENT_LENGTH)
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownUpdate(value: unknown): value is PermissionUpdate {
|
||||
if (!isRecord(value) || !isDestination(value.destination) || typeof value.type !== "string") {
|
||||
return false;
|
||||
}
|
||||
switch (value.type) {
|
||||
case "addRules":
|
||||
case "replaceRules":
|
||||
case "removeRules":
|
||||
return (
|
||||
Array.isArray(value.rules) &&
|
||||
value.rules.length <= MAX_RULES_PER_UPDATE &&
|
||||
(value.type === "replaceRules" || value.rules.length > 0) &&
|
||||
value.rules.every(isRule) &&
|
||||
typeof value.behavior === "string" &&
|
||||
BEHAVIORS.has(value.behavior as PermissionBehavior)
|
||||
);
|
||||
case "setMode":
|
||||
return typeof value.mode === "string" && MODES.has(value.mode as PermissionMode);
|
||||
case "addDirectories":
|
||||
case "removeDirectories":
|
||||
return (
|
||||
Array.isArray(value.directories) &&
|
||||
value.directories.length > 0 &&
|
||||
value.directories.length <= MAX_DIRECTORIES_PER_UPDATE &&
|
||||
value.directories.every((directory) => safePlainString(directory, MAX_DIRECTORY_LENGTH))
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDurablePermissionChangeSet(
|
||||
suggestions: unknown,
|
||||
forcedAsk = false,
|
||||
): DurablePermissionChangeSet | undefined {
|
||||
if (forcedAsk) return undefined;
|
||||
try {
|
||||
if (
|
||||
!Array.isArray(suggestions) ||
|
||||
suggestions.length === 0 ||
|
||||
suggestions.length > MAX_UPDATES ||
|
||||
!suggestions.every(isKnownUpdate)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
// The ACP prompt may stay open for an arbitrary amount of time. Snapshot the
|
||||
// provider payload so the label the user approved and the effect returned to
|
||||
// Claude cannot diverge through later mutation of the callback argument.
|
||||
updates: structuredClone(suggestions as PermissionUpdate[]),
|
||||
};
|
||||
} catch {
|
||||
// SDK data is expected to be plain structured-cloneable input. Treat an
|
||||
// accessor, function, proxy, or other non-wire value as an invalid bundle.
|
||||
// Validation belongs in this boundary too because merely reading one of those
|
||||
// values can throw before structuredClone gets a chance to reject it.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
100
src/permissions/options.ts
Normal file
100
src/permissions/options.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
buildEditPermissionOptions,
|
||||
buildGlobPermissionOptions,
|
||||
buildGrepPermissionOptions,
|
||||
buildNotebookEditPermissionOptions,
|
||||
buildReadPermissionOptions,
|
||||
buildWritePermissionOptions,
|
||||
} from "./options/filesystem.js";
|
||||
import { buildBashPermissionOptions, buildPowerShellPermissionOptions } from "./options/shell.js";
|
||||
import type { PermissionOptionContext } from "./options/shared.js";
|
||||
import {
|
||||
buildComputerUseMcpPermissionOptions,
|
||||
buildEnterPlanModePermissionOptions,
|
||||
buildExitPlanModePermissionOptions,
|
||||
buildFallbackPermissionOptions,
|
||||
buildSandboxNetworkPermissionOptions,
|
||||
buildSkillPermissionOptions,
|
||||
buildWebFetchPermissionOptions,
|
||||
isComputerUseMcpTool,
|
||||
} from "./options/tools.js";
|
||||
|
||||
export { PERMISSION_OPTION_ID } from "./options/shared.js";
|
||||
|
||||
export function buildClaudePermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
// ACP has no "pre-selected option" field, so option order is the only lever
|
||||
// for the CLI's `defaultToNo` hint ("open on the decline option"): lead with
|
||||
// the reject options instead of the approvals.
|
||||
const order = context.defaultToNo === true ? declineFirstOptionOrder : permissionOptionOrder;
|
||||
return buildUnsortedClaudePermissionOptions(context).sort(
|
||||
(left, right) => order(left) - order(right),
|
||||
);
|
||||
}
|
||||
|
||||
function buildUnsortedClaudePermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
if (isComputerUseMcpTool(context.toolName)) {
|
||||
return buildComputerUseMcpPermissionOptions(context);
|
||||
}
|
||||
switch (context.toolName) {
|
||||
case "AskUserQuestion":
|
||||
throw new Error("AskUserQuestion must be handled by ACP elicitation, not permission options");
|
||||
case "Bash":
|
||||
return buildBashPermissionOptions(context);
|
||||
case "PowerShell":
|
||||
return buildPowerShellPermissionOptions(context);
|
||||
case "Read":
|
||||
return buildReadPermissionOptions(context);
|
||||
case "Glob":
|
||||
return buildGlobPermissionOptions(context);
|
||||
case "Grep":
|
||||
return buildGrepPermissionOptions(context);
|
||||
case "Edit":
|
||||
return buildEditPermissionOptions(context);
|
||||
case "Write":
|
||||
return buildWritePermissionOptions(context);
|
||||
case "NotebookEdit":
|
||||
return buildNotebookEditPermissionOptions(context);
|
||||
case "WebFetch":
|
||||
return buildWebFetchPermissionOptions(context);
|
||||
case "Skill":
|
||||
return buildSkillPermissionOptions(context);
|
||||
case "EnterPlanMode":
|
||||
return buildEnterPlanModePermissionOptions();
|
||||
case "ExitPlanMode":
|
||||
return buildExitPlanModePermissionOptions(context);
|
||||
case "SandboxNetworkAccess":
|
||||
return buildSandboxNetworkPermissionOptions(context);
|
||||
case "WebSearch":
|
||||
case "Agent":
|
||||
case "Task":
|
||||
default:
|
||||
return buildFallbackPermissionOptions(context);
|
||||
}
|
||||
}
|
||||
|
||||
function declineFirstOptionOrder(option: PermissionOption): number {
|
||||
switch (option.kind) {
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return 0;
|
||||
default:
|
||||
return 1 + permissionOptionOrder(option);
|
||||
}
|
||||
}
|
||||
|
||||
function permissionOptionOrder(option: PermissionOption): number {
|
||||
switch (option.kind) {
|
||||
case "allow_once":
|
||||
return 0;
|
||||
case "allow_always":
|
||||
return 1;
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return 3;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
186
src/permissions/options/filesystem.ts
Normal file
186
src/permissions/options/filesystem.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DurablePermissionChangeSet } from "../normalization.js";
|
||||
import { plainString, type PermissionOptionContext, withOptionalUpdate } from "./shared.js";
|
||||
|
||||
function isFileSessionChangeSet(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
operation: "read" | "write",
|
||||
): boolean {
|
||||
return (
|
||||
!!changeSet &&
|
||||
changeSet.updates.every((update) => {
|
||||
if (update.destination !== "session") return false;
|
||||
if (update.type === "setMode") {
|
||||
return operation === "write" && update.mode === "acceptEdits";
|
||||
}
|
||||
if (update.type === "addDirectories") return operation === "write";
|
||||
if (update.type !== "addRules" || update.behavior !== "allow") return false;
|
||||
return update.rules.every((rule) =>
|
||||
operation === "read" ? rule.toolName === "Read" : rule.toolName === "Edit",
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function inputPath(context: PermissionOptionContext, key: string): string | undefined {
|
||||
return plainString(context.input[key]);
|
||||
}
|
||||
|
||||
function isInside(directory: string, candidate: string): boolean {
|
||||
const relative = path.relative(path.resolve(directory), path.resolve(candidate));
|
||||
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
|
||||
}
|
||||
|
||||
function hasSessionAllowRuleFor(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
toolName: string,
|
||||
): boolean {
|
||||
return !!changeSet?.updates.some(
|
||||
(update) =>
|
||||
update.type === "addRules" &&
|
||||
update.destination === "session" &&
|
||||
update.behavior === "allow" &&
|
||||
update.rules.some((rule) => rule.toolName === toolName),
|
||||
);
|
||||
}
|
||||
|
||||
function hasBroadSessionAllowRuleFor(
|
||||
changeSet: DurablePermissionChangeSet,
|
||||
toolName: string,
|
||||
): boolean {
|
||||
return changeSet.updates.some(
|
||||
(update) =>
|
||||
update.type === "addRules" &&
|
||||
update.destination === "session" &&
|
||||
update.behavior === "allow" &&
|
||||
update.rules.some((rule) => rule.toolName === toolName && rule.ruleContent === undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function hasAcceptEditsMode(changeSet: DurablePermissionChangeSet): boolean {
|
||||
return changeSet.updates.some(
|
||||
(update) =>
|
||||
update.type === "setMode" &&
|
||||
update.destination === "session" &&
|
||||
update.mode === "acceptEdits",
|
||||
);
|
||||
}
|
||||
|
||||
function permissionPath(ruleContent: string | undefined): string | undefined {
|
||||
const value = plainString(ruleContent);
|
||||
if (!value) return undefined;
|
||||
return value.replace(/[/\\]\*\*$/, "");
|
||||
}
|
||||
|
||||
function effectCoversFilePath(
|
||||
changeSet: DurablePermissionChangeSet,
|
||||
filePath: string,
|
||||
operation: "read" | "write",
|
||||
cwd: string,
|
||||
): boolean {
|
||||
const directory = path.dirname(filePath);
|
||||
const comparable = (candidate: string): boolean => {
|
||||
const resolved = path.resolve(cwd, candidate);
|
||||
return resolved === directory || isInside(resolved, filePath);
|
||||
};
|
||||
const paths = changeSet.updates.flatMap((update) => {
|
||||
if (update.type === "addDirectories") return update.directories;
|
||||
if (update.type !== "addRules" || update.behavior !== "allow") return [];
|
||||
return update.rules
|
||||
.filter((rule) =>
|
||||
operation === "read" ? rule.toolName === "Read" : rule.toolName === "Edit",
|
||||
)
|
||||
.map((rule) => permissionPath(rule.ruleContent))
|
||||
.filter((value): value is string => value !== undefined);
|
||||
});
|
||||
return paths.some(comparable);
|
||||
}
|
||||
|
||||
function fileSessionLabel(
|
||||
context: PermissionOptionContext,
|
||||
filePath: string | undefined,
|
||||
operation: "read" | "write",
|
||||
): string | undefined {
|
||||
if (!isFileSessionChangeSet(context.durableChangeSet, operation)) return undefined;
|
||||
const resolvedFilePath = filePath ? path.resolve(context.cwd, filePath) : undefined;
|
||||
const isClaudePath =
|
||||
operation === "write" &&
|
||||
!!resolvedFilePath &&
|
||||
(isInside(path.join(context.cwd, ".claude"), resolvedFilePath) ||
|
||||
isInside(path.join(os.homedir(), ".claude"), resolvedFilePath));
|
||||
const broadRule = hasBroadSessionAllowRuleFor(
|
||||
context.durableChangeSet!,
|
||||
operation === "read" ? "Read" : "Edit",
|
||||
);
|
||||
if (resolvedFilePath) {
|
||||
const coversCurrentPath =
|
||||
effectCoversFilePath(context.durableChangeSet!, resolvedFilePath, operation, context.cwd) ||
|
||||
broadRule ||
|
||||
(operation === "write" && !isClaudePath && hasAcceptEditsMode(context.durableChangeSet!));
|
||||
if (!coversCurrentPath) return undefined;
|
||||
} else if (
|
||||
!broadRule &&
|
||||
!(operation === "write" && hasAcceptEditsMode(context.durableChangeSet!))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
operation === "write" &&
|
||||
resolvedFilePath &&
|
||||
isClaudePath &&
|
||||
hasSessionAllowRuleFor(context.durableChangeSet, "Edit")
|
||||
) {
|
||||
return "Yes, and allow Claude to edit its own settings for this session";
|
||||
}
|
||||
if (!resolvedFilePath || isInside(context.cwd, resolvedFilePath)) {
|
||||
return operation === "read"
|
||||
? "Yes, during this session"
|
||||
: "Yes, allow all edits during this session";
|
||||
}
|
||||
const directory = path.dirname(resolvedFilePath);
|
||||
const directoryName = path.basename(directory) || "this directory";
|
||||
return operation === "read"
|
||||
? `Yes, allow reading from ${directoryName}${path.sep} during this session`
|
||||
: `Yes, allow all edits in ${directoryName}${path.sep} during this session`;
|
||||
}
|
||||
|
||||
function buildFilePermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
filePath: string | undefined,
|
||||
operation: "read" | "write",
|
||||
): PermissionOption[] {
|
||||
return withOptionalUpdate(
|
||||
context.durableChangeSet,
|
||||
fileSessionLabel(context, filePath, operation),
|
||||
"Yes",
|
||||
"No",
|
||||
);
|
||||
}
|
||||
|
||||
export function buildReadPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "file_path"), "read");
|
||||
}
|
||||
|
||||
export function buildGlobPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "path") ?? context.cwd, "read");
|
||||
}
|
||||
|
||||
export function buildGrepPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "path") ?? context.cwd, "read");
|
||||
}
|
||||
|
||||
export function buildEditPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "file_path"), "write");
|
||||
}
|
||||
|
||||
export function buildWritePermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "file_path"), "write");
|
||||
}
|
||||
|
||||
export function buildNotebookEditPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
return buildFilePermissionOptions(context, inputPath(context, "notebook_path"), "write");
|
||||
}
|
||||
108
src/permissions/options/shared.ts
Normal file
108
src/permissions/options/shared.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import type { DurablePermissionChangeSet } from "../normalization.js";
|
||||
|
||||
export const PERMISSION_OPTION_ID = {
|
||||
allowOnce: "allow-once",
|
||||
allowWithUpdates: "allow-with-updates",
|
||||
allowSkillExact: "allow-skill-exact",
|
||||
allowSkillPrefix: "allow-skill-prefix",
|
||||
exitPlanBypass: "exit-plan-bypass",
|
||||
exitPlanAuto: "exit-plan-auto",
|
||||
exitPlanAcceptEdits: "exit-plan-accept-edits",
|
||||
exitPlanDefault: "exit-plan-default",
|
||||
exitPlanClearAuto: "exit-plan-clear-auto",
|
||||
exitPlanClearBypass: "exit-plan-clear-bypass",
|
||||
exitPlanClearAcceptEdits: "exit-plan-clear-accept-edits",
|
||||
reject: "reject",
|
||||
} as const;
|
||||
|
||||
export interface PermissionOptionContext {
|
||||
toolName: string;
|
||||
displayName?: string;
|
||||
input: Record<string, unknown>;
|
||||
cwd: string;
|
||||
durableChangeSet?: DurablePermissionChangeSet;
|
||||
allowPersistentOptions?: boolean;
|
||||
/** The CLI's `defaultToNo` hint (SDK 0.3.268+): the ask must not be
|
||||
* approvable by a stray keystroke, so the decline option leads. */
|
||||
defaultToNo?: boolean;
|
||||
availableModes?: readonly string[];
|
||||
contextUsedPercent?: number;
|
||||
}
|
||||
|
||||
export function allowOnce(name = "Yes"): PermissionOption {
|
||||
return { optionId: PERMISSION_OPTION_ID.allowOnce, name, kind: "allow_once" };
|
||||
}
|
||||
|
||||
export function allowWithUpdates(name: string): PermissionOption {
|
||||
return { optionId: PERMISSION_OPTION_ID.allowWithUpdates, name, kind: "allow_always" };
|
||||
}
|
||||
|
||||
export function reject(name = "No"): PermissionOption {
|
||||
return { optionId: PERMISSION_OPTION_ID.reject, name, kind: "reject_once" };
|
||||
}
|
||||
|
||||
export function withOptionalUpdate(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
updateName: string | undefined,
|
||||
allowName = "Yes",
|
||||
rejectName = "No",
|
||||
): PermissionOption[] {
|
||||
const options = [allowOnce(allowName)];
|
||||
if (changeSet && updateName) options.push(allowWithUpdates(updateName));
|
||||
options.push(reject(rejectName));
|
||||
return options;
|
||||
}
|
||||
|
||||
export function plainString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function exactLocalAllowRule(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
toolName: string,
|
||||
ruleContent?: string,
|
||||
): boolean {
|
||||
if (!changeSet || changeSet.updates.length !== 1) return false;
|
||||
const update = changeSet.updates[0];
|
||||
if (
|
||||
update?.type !== "addRules" ||
|
||||
update.behavior !== "allow" ||
|
||||
update.destination !== "localSettings" ||
|
||||
update.rules.length !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const rule = update.rules[0];
|
||||
return (
|
||||
rule?.toolName === toolName &&
|
||||
(ruleContent === undefined
|
||||
? rule.ruleContent === undefined
|
||||
: plainString(rule.ruleContent) === ruleContent)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An MCP "don't ask again" option is only truthful when every provider update
|
||||
* adds an unrestricted allow rule for the tool currently being prompted.
|
||||
*/
|
||||
export function isMcpAllowChangeSet(
|
||||
changeSet: DurablePermissionChangeSet | undefined,
|
||||
toolName: string,
|
||||
): boolean {
|
||||
return (
|
||||
!!changeSet &&
|
||||
changeSet.updates.length > 0 &&
|
||||
changeSet.updates.every(
|
||||
(update) =>
|
||||
update.type === "addRules" &&
|
||||
update.behavior === "allow" &&
|
||||
update.rules.length > 0 &&
|
||||
update.rules.every((rule) => rule.toolName === toolName && rule.ruleContent === undefined),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function withGeneratedUpdate(name: string, rejectName = "No"): PermissionOption[] {
|
||||
return [allowOnce(), allowWithUpdates(name), reject(rejectName)];
|
||||
}
|
||||
121
src/permissions/options/shell.ts
Normal file
121
src/permissions/options/shell.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import path from "node:path";
|
||||
import type { DurablePermissionChangeSet } from "../normalization.js";
|
||||
import { plainString, type PermissionOptionContext, withOptionalUpdate } from "./shared.js";
|
||||
|
||||
function permissionRulePrefix(value: string): string {
|
||||
return value.endsWith(":*") ? value.slice(0, -2) : value;
|
||||
}
|
||||
|
||||
function displayList(values: string[]): string {
|
||||
if (values.join(", ").length > 50) return "similar";
|
||||
if (values.length === 1) return values[0]!;
|
||||
if (values.length === 2) return `${values[0]} and ${values[1]}`;
|
||||
return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
|
||||
}
|
||||
|
||||
/** Shorten paths only as much as needed to distinguish them in prompt text. */
|
||||
function displayPaths(paths: string[]): string {
|
||||
const normalizedPaths = [...new Set(paths.map((value) => path.normalize(value)))];
|
||||
const segments = normalizedPaths.map((value) => value.split(path.sep).filter(Boolean));
|
||||
|
||||
// Compare equally deep suffixes so duplicate basenames gain the smallest
|
||||
// useful parent prefix (for example, codex-acp/src/ vs claude-acp/src/).
|
||||
const suffix = (index: number, depth: number): string =>
|
||||
segments[index]!.slice(-depth).join(path.sep) || normalizedPaths[index]!;
|
||||
const names = normalizedPaths.map((value, index) => {
|
||||
const parts = segments[index]!;
|
||||
let name = path.basename(value) || value;
|
||||
for (let depth = 1; depth <= parts.length; depth++) {
|
||||
const candidate = suffix(index, depth);
|
||||
if (
|
||||
normalizedPaths.every((_, other) => other === index || suffix(other, depth) !== candidate)
|
||||
) {
|
||||
name = candidate;
|
||||
break;
|
||||
}
|
||||
if (depth === parts.length) name = value;
|
||||
}
|
||||
return name.endsWith(path.sep) ? name : `${name}${path.sep}`;
|
||||
});
|
||||
|
||||
// Native Claude labels enumerate two paths and summarize larger sets.
|
||||
if (names.length <= 2) return displayList(names);
|
||||
return `${names[0]}, ${names[1]} and ${names.length - 2} more`;
|
||||
}
|
||||
|
||||
/** Plain-text port of Claude Code's generateShellSuggestionsLabel. */
|
||||
function shellSuggestionsLabel(
|
||||
toolName: "Bash" | "PowerShell",
|
||||
changeSet: DurablePermissionChangeSet,
|
||||
): string | undefined {
|
||||
const representable = changeSet.updates.every((update) => {
|
||||
if (update.type === "addDirectories") return update.destination === "session";
|
||||
if (update.type !== "addRules" || update.behavior !== "allow") return false;
|
||||
return update.rules.every(
|
||||
(rule) =>
|
||||
(rule.toolName === toolName || rule.toolName === "Read") &&
|
||||
plainString(rule.ruleContent) !== undefined,
|
||||
);
|
||||
});
|
||||
if (!representable) return undefined;
|
||||
|
||||
const rules = changeSet.updates
|
||||
.filter((update) => update.type === "addRules")
|
||||
.flatMap((update) => update.rules);
|
||||
const readPaths = rules
|
||||
.filter((rule) => rule.toolName === "Read")
|
||||
.map((rule) => rule.ruleContent?.replace(/\/\*\*$/, ""))
|
||||
.filter((value): value is string => !!value);
|
||||
const commands = [
|
||||
...new Set(
|
||||
rules
|
||||
.filter((rule) => rule.toolName === toolName && rule.ruleContent)
|
||||
.map((rule) => permissionRulePrefix(rule.ruleContent!)),
|
||||
),
|
||||
];
|
||||
const directories = changeSet.updates
|
||||
.filter((update) => update.type === "addDirectories")
|
||||
.flatMap((update) => update.directories);
|
||||
const hasPaths = readPaths.length > 0 || directories.length > 0;
|
||||
|
||||
if (readPaths.length > 0 && directories.length === 0 && commands.length === 0) {
|
||||
return `Yes, allow reading from ${displayPaths(readPaths)} from this project`;
|
||||
}
|
||||
if (directories.length > 0 && readPaths.length === 0 && commands.length === 0) {
|
||||
return `Yes, and always allow access to ${displayPaths(directories)} from this project`;
|
||||
}
|
||||
if (commands.length > 0 && !hasPaths) {
|
||||
return `Yes, and don't ask again for ${displayList(commands)} commands`;
|
||||
}
|
||||
if (hasPaths && commands.length === 0) {
|
||||
return `Yes, and always allow access to ${displayPaths([...directories, ...readPaths])} from this project`;
|
||||
}
|
||||
if (hasPaths && commands.length > 0) {
|
||||
const paths = [...directories, ...readPaths];
|
||||
return paths.length === 1 && commands.length === 1
|
||||
? `Yes, and allow access to ${displayPaths(paths)} and ${displayList(commands)} commands`
|
||||
: `Yes, and allow ${displayPaths(paths)} access and ${displayList(commands)} commands`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildShellPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
toolName: "Bash" | "PowerShell",
|
||||
): PermissionOption[] {
|
||||
const name = context.durableChangeSet
|
||||
? shellSuggestionsLabel(toolName, context.durableChangeSet)
|
||||
: undefined;
|
||||
return withOptionalUpdate(context.durableChangeSet, name, "Yes", "No");
|
||||
}
|
||||
|
||||
export function buildBashPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
return buildShellPermissionOptions(context, "Bash");
|
||||
}
|
||||
|
||||
export function buildPowerShellPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
return buildShellPermissionOptions(context, "PowerShell");
|
||||
}
|
||||
170
src/permissions/options/tools.ts
Normal file
170
src/permissions/options/tools.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
allowOnce,
|
||||
exactLocalAllowRule,
|
||||
isMcpAllowChangeSet,
|
||||
PERMISSION_OPTION_ID,
|
||||
plainString,
|
||||
type PermissionOptionContext,
|
||||
reject,
|
||||
withGeneratedUpdate,
|
||||
withOptionalUpdate,
|
||||
} from "./shared.js";
|
||||
|
||||
export function buildWebFetchPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const url = plainString(context.input.url);
|
||||
if (url && context.allowPersistentOptions !== false) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
if (hostname) {
|
||||
return withGeneratedUpdate(`Yes, and don't ask again for ${hostname}`);
|
||||
}
|
||||
} catch {
|
||||
// Invalid input cannot produce Claude Code's domain-specific option.
|
||||
}
|
||||
}
|
||||
return [allowOnce(), reject("No")];
|
||||
}
|
||||
|
||||
export function buildSkillPermissionOptions(context: PermissionOptionContext): PermissionOption[] {
|
||||
const skill = plainString(context.input.skill);
|
||||
const options = [allowOnce()];
|
||||
if (skill && context.allowPersistentOptions !== false) {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.allowSkillExact,
|
||||
name: `Yes, and don't ask again for ${skill}`,
|
||||
kind: "allow_always",
|
||||
});
|
||||
const spaceIndex = skill.indexOf(" ");
|
||||
if (spaceIndex > 0) {
|
||||
const prefix = `${skill.slice(0, spaceIndex)}:*`;
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.allowSkillPrefix,
|
||||
name: `Yes, and don't ask again for ${prefix} commands`,
|
||||
kind: "allow_always",
|
||||
});
|
||||
}
|
||||
}
|
||||
options.push(reject("No"));
|
||||
return options;
|
||||
}
|
||||
|
||||
export function buildEnterPlanModePermissionOptions(): PermissionOption[] {
|
||||
return [allowOnce("Yes, enter plan mode"), reject("No, start implementing now")];
|
||||
}
|
||||
|
||||
export function buildExitPlanModePermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const modes = new Set(context.availableModes);
|
||||
const options: PermissionOption[] = [];
|
||||
const elevatedMode = modes.has("auto")
|
||||
? "auto"
|
||||
: modes.has("bypassPermissions")
|
||||
? "bypassPermissions"
|
||||
: "acceptEdits";
|
||||
if (plainString(context.input.plan)) {
|
||||
const usage =
|
||||
context.contextUsedPercent === undefined ? "" : ` (${context.contextUsedPercent}% used)`;
|
||||
if (elevatedMode === "auto") {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearAuto,
|
||||
name: `Yes, clear context${usage} and use auto mode`,
|
||||
kind: "allow_always",
|
||||
});
|
||||
} else if (elevatedMode === "bypassPermissions") {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearBypass,
|
||||
name: `Yes, clear context${usage} and bypass permissions`,
|
||||
kind: "allow_always",
|
||||
});
|
||||
} else {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearAcceptEdits,
|
||||
name: `Yes, clear context${usage} and auto-accept edits`,
|
||||
kind: "allow_always",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (elevatedMode === "auto") {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanAuto,
|
||||
name: "Yes, and use auto mode",
|
||||
kind: "allow_always",
|
||||
});
|
||||
} else if (elevatedMode === "bypassPermissions") {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanBypass,
|
||||
name: "Yes, and bypass permissions",
|
||||
kind: "allow_always",
|
||||
});
|
||||
} else {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanAcceptEdits,
|
||||
name: "Yes, auto-accept edits",
|
||||
kind: "allow_always",
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanDefault,
|
||||
name: "Yes, manually approve edits",
|
||||
kind: "allow_once",
|
||||
});
|
||||
options.push(reject("No, keep planning"));
|
||||
return options;
|
||||
}
|
||||
|
||||
export function buildSandboxNetworkPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const host = plainString(context.input.host);
|
||||
const name =
|
||||
host && exactLocalAllowRule(context.durableChangeSet, context.toolName, host)
|
||||
? `Yes, and don't ask again for ${host}`
|
||||
: undefined;
|
||||
return withOptionalUpdate(context.durableChangeSet, name, "Yes", "No");
|
||||
}
|
||||
|
||||
export function buildFallbackPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const toolLabel = plainString(context.displayName) ?? context.toolName;
|
||||
if (context.toolName.startsWith("mcp__")) {
|
||||
const changeSet =
|
||||
context.allowPersistentOptions !== false &&
|
||||
isMcpAllowChangeSet(context.durableChangeSet, context.toolName)
|
||||
? context.durableChangeSet
|
||||
: undefined;
|
||||
return withOptionalUpdate(
|
||||
changeSet,
|
||||
changeSet ? `Yes, and don't ask again for ${toolLabel} commands` : undefined,
|
||||
);
|
||||
}
|
||||
if (context.allowPersistentOptions !== false) {
|
||||
return withGeneratedUpdate(`Yes, and don't ask again for ${toolLabel} commands`);
|
||||
}
|
||||
return [allowOnce(), reject("No")];
|
||||
}
|
||||
|
||||
const COMPUTER_USE_MCP_TOOL_PREFIX = "mcp__computer-use__";
|
||||
|
||||
export function isComputerUseMcpTool(toolName: string): boolean {
|
||||
return toolName.startsWith(COMPUTER_USE_MCP_TOOL_PREFIX);
|
||||
}
|
||||
|
||||
export function buildComputerUseMcpPermissionOptions(
|
||||
context: PermissionOptionContext,
|
||||
): PermissionOption[] {
|
||||
const toolLabel = plainString(context.displayName) ?? context.toolName;
|
||||
const changeSet =
|
||||
context.allowPersistentOptions !== false &&
|
||||
isMcpAllowChangeSet(context.durableChangeSet, context.toolName)
|
||||
? context.durableChangeSet
|
||||
: undefined;
|
||||
return withOptionalUpdate(
|
||||
changeSet,
|
||||
changeSet ? `Yes, and don't ask again for ${toolLabel}` : undefined,
|
||||
);
|
||||
}
|
||||
118
src/permissions/presentation.ts
Normal file
118
src/permissions/presentation.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { RequestPermissionRequest, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import { toolInfoFromToolUse } from "../tools.js";
|
||||
|
||||
export interface ClaudePermissionPresentationInput {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
toolUseID: string;
|
||||
cwd?: string;
|
||||
supportsTerminalOutput?: boolean;
|
||||
blockedPath?: string;
|
||||
title?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
decisionReason?: string;
|
||||
defaultToNo?: boolean;
|
||||
}
|
||||
|
||||
function humanText(value: unknown, maxLength: number, singleLine = false): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const withoutControls = Array.from(value)
|
||||
.filter((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return !(
|
||||
code <= 0x08 ||
|
||||
code === 0x0b ||
|
||||
code === 0x0c ||
|
||||
(code >= 0x0e && code <= 0x1f) ||
|
||||
code === 0x7f
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
const normalized = singleLine
|
||||
? withoutControls.replace(/\s+/gu, " ").trim()
|
||||
: withoutControls.trim();
|
||||
return normalized && normalized.length <= maxLength ? normalized : undefined;
|
||||
}
|
||||
|
||||
function compactText(value: unknown): string | undefined {
|
||||
return humanText(value, 160, true);
|
||||
}
|
||||
|
||||
function withBlockedPath(
|
||||
locations: ToolCallLocation[] | undefined,
|
||||
blockedPath: unknown,
|
||||
): ToolCallLocation[] | undefined {
|
||||
const path = humanText(blockedPath, 4_096, true);
|
||||
if (!path) return locations;
|
||||
const result = [...(locations ?? [])];
|
||||
if (!result.some((location) => location.path === path)) result.push({ path });
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildClaudePermissionPresentation(
|
||||
value: ClaudePermissionPresentationInput,
|
||||
): Pick<RequestPermissionRequest, "toolCall" | "_meta"> {
|
||||
const info = toolInfoFromToolUse(
|
||||
{ id: value.toolUseID, name: value.toolName, input: value.input },
|
||||
value.supportsTerminalOutput ?? false,
|
||||
value.cwd,
|
||||
);
|
||||
const host =
|
||||
value.toolName === "SandboxNetworkAccess" ? compactText(value.input.host) : undefined;
|
||||
const isComputerUse = value.toolName.startsWith("mcp__computer-use__");
|
||||
const subjectTitle = host ?? (isComputerUse ? compactText(value.displayName) : undefined);
|
||||
const subjectContent =
|
||||
(host || isComputerUse) && info.content.length === 0
|
||||
? [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `\`\`\`json\n${JSON.stringify(value.input, null, 2)}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
]
|
||||
: info.content;
|
||||
// Reuse the exact standard tool-call heading as the permission heading so
|
||||
// the approval never maintains a second, divergent name for the operation.
|
||||
// decisionReason is temporarily exposed as the permission description so
|
||||
// its actual SDK values can be inspected; it remains diagnostic policy text.
|
||||
const toolCallTitle = subjectTitle ?? info.title;
|
||||
const permissionTitle = value.toolName === "ExitPlanMode" ? "Ready to code?" : toolCallTitle;
|
||||
// Shell titles are executable input: compacting whitespace changes quoted
|
||||
// arguments and comment boundaries, and length limits can hide the command.
|
||||
const title =
|
||||
value.toolName === "Bash" || value.toolName === "PowerShell"
|
||||
? permissionTitle
|
||||
: (humanText(permissionTitle, 4_000, true) ?? "Use tool?");
|
||||
const decisionReason = humanText(value.decisionReason, 4_000);
|
||||
const description = decisionReason ? `Reason: ${decisionReason}` : undefined;
|
||||
return {
|
||||
toolCall: {
|
||||
toolCallId: value.toolUseID,
|
||||
name: value.toolName,
|
||||
status: "pending",
|
||||
rawInput: value.input,
|
||||
...info,
|
||||
title: toolCallTitle,
|
||||
content: subjectContent,
|
||||
locations: withBlockedPath(info.locations, value.blockedPath),
|
||||
},
|
||||
...(title
|
||||
? {
|
||||
_meta: {
|
||||
permission: {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// The CLI's own hint, forwarded so a client that can pre-select
|
||||
// an option keeps the decline focused; the option order already
|
||||
// leads with the reject options when this is set.
|
||||
...(value.defaultToNo === true ? { defaultToNo: true } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
36
src/permissions/response.ts
Normal file
36
src/permissions/response.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { RequestPermissionResponse } from "@agentclientprotocol/sdk";
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk";
|
||||
import type { PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { DurablePermissionChangeSet } from "./normalization.js";
|
||||
import { applyClaudePermissionSelection, parseClaudePermissionSelection } from "./effects.js";
|
||||
|
||||
export interface ClaudePermissionDecision {
|
||||
permissionResult: PermissionResult;
|
||||
contextResetMode?: PermissionMode;
|
||||
}
|
||||
|
||||
/** Decode, validate, and interpret an ACP response exactly once. */
|
||||
export function decodeClaudePermissionResponse(
|
||||
response: RequestPermissionResponse,
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
toolUseID: string,
|
||||
offeredOptions: readonly PermissionOption[],
|
||||
durableChangeSet?: DurablePermissionChangeSet,
|
||||
): ClaudePermissionDecision {
|
||||
const selection = parseClaudePermissionSelection(response, toolName);
|
||||
const offeredOption = offeredOptions.find((option) => option.optionId === selection.optionId);
|
||||
if (!offeredOption) {
|
||||
throw new Error(`Permission option was not offered: ${selection.optionId}`);
|
||||
}
|
||||
const permissionResult = applyClaudePermissionSelection(selection, {
|
||||
toolName,
|
||||
input,
|
||||
toolUseID,
|
||||
durableChangeSet,
|
||||
});
|
||||
return {
|
||||
permissionResult,
|
||||
...(selection.contextResetMode ? { contextResetMode: selection.contextResetMode } : {}),
|
||||
};
|
||||
}
|
||||
58
src/resumed-session.ts
Normal file
58
src/resumed-session.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { getSessionMessages, type SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { SessionTiming } from "./session-timing.js";
|
||||
|
||||
type ResumeLogger = {
|
||||
log: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
export type ResumedSessionSnapshot = {
|
||||
messages?: SessionMessage[];
|
||||
model?: string;
|
||||
};
|
||||
|
||||
/** Return the concrete model recorded by the last real assistant response.
|
||||
* Claude Code restores a resumed query from this same transcript field.
|
||||
* Synthetic assistant records use angle-bracket placeholders and do not
|
||||
* describe a model the resumed query can run. */
|
||||
export function resumedModelFromTranscript(messages: SessionMessage[]): string | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const entry = messages[index];
|
||||
if (
|
||||
entry?.type !== "assistant" ||
|
||||
entry.parent_tool_use_id != null ||
|
||||
entry.parent_agent_id != null ||
|
||||
!entry.message ||
|
||||
typeof entry.message !== "object"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const model = (entry.message as { model?: unknown }).model;
|
||||
if (typeof model === "string" && model.trim().length > 0 && !/^<[^>]+>$/.test(model.trim())) {
|
||||
return model.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Read the resume model from the local transcript without starting a Claude
|
||||
* control request. This is intentionally on the load critical path. */
|
||||
export async function readResumedSession(
|
||||
sessionId: string,
|
||||
logger?: ResumeLogger,
|
||||
): Promise<ResumedSessionSnapshot> {
|
||||
const timing = new SessionTiming(logger, "models", sessionId);
|
||||
try {
|
||||
// Deliberately search all project directories, matching replaySessionHistory.
|
||||
// A client may reopen a session from a worktree or normalized path that is
|
||||
// different from the directory under which Claude persisted the transcript.
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
const model = resumedModelFromTranscript(messages);
|
||||
timing.phase("read-transcript", ` messages=${messages.length} model=${model ?? "unknown"}`);
|
||||
return { messages, model };
|
||||
} catch (error) {
|
||||
timing.phase("read-transcript", " outcome=error");
|
||||
logger?.error(`Failed to read transcript for resumed session ${sessionId}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
3
src/session-config-ids.ts
Normal file
3
src/session-config-ids.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/** Shared sentinels and ids for session preferences managed by the adapter. */
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
118
src/session-effort.ts
Normal file
118
src/session-effort.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import type { EffortLevel, ModelInfo, Settings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
import { EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
export { EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
// The SDK drops `undefined` during JSON transport and only clears a flag-layer
|
||||
// setting when it receives an explicit `null`. Map both an absent picker and
|
||||
// the legacy "default" row to null so a previously applied effort is cleared.
|
||||
export function toSdkEffortLevel(value: string | undefined): EffortLevel | null {
|
||||
return value === undefined || value === "default" ? null : (value as EffortLevel);
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/-(\d+m)$/i, "[$1]");
|
||||
}
|
||||
|
||||
/** Resolve the effort the CLI will use: per-model settings first, then the
|
||||
* legacy top-level effort setting. Model settings are keyed by canonical
|
||||
* model name, so try the resolved SDK model before picker and raw IDs. */
|
||||
export function settingsEffortForModel(
|
||||
settings: Settings,
|
||||
modelInfo: ModelInfo | undefined,
|
||||
modelId?: string,
|
||||
): string | undefined {
|
||||
const modelSettings = settings.modelSettings;
|
||||
if (modelSettings) {
|
||||
for (const key of [modelInfo?.resolvedModel, modelInfo?.value, modelId]) {
|
||||
if (key === undefined) continue;
|
||||
const exact = modelSettings[key]?.effortLevel;
|
||||
if (typeof exact === "string") return exact;
|
||||
const canonicalKey = canonicalizeModelSettingsKey(key);
|
||||
const matchingEntry = Object.entries(modelSettings).find(
|
||||
([candidate]) => canonicalizeModelSettingsKey(candidate) === canonicalKey,
|
||||
);
|
||||
const perModel = matchingEntry?.[1]?.effortLevel;
|
||||
if (typeof perModel === "string") return perModel;
|
||||
}
|
||||
}
|
||||
return settings.effortLevel;
|
||||
}
|
||||
|
||||
/** Programmatic query settings have higher priority than file-backed settings,
|
||||
* matching the SDK. Keep unrelated per-model entries from lower tiers while
|
||||
* replacing entries supplied at the programmatic tier. */
|
||||
export function mergeEffortSettings(base: Settings, override: Settings | undefined): Settings {
|
||||
if (!override) return base;
|
||||
const overriddenModelKeys = new Set(
|
||||
Object.keys(override.modelSettings ?? {}).map(canonicalizeModelSettingsKey),
|
||||
);
|
||||
const unshadowedBaseModelSettings = Object.fromEntries(
|
||||
Object.entries(base.modelSettings ?? {}).filter(
|
||||
([key]) => !overriddenModelKeys.has(canonicalizeModelSettingsKey(key)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
modelSettings:
|
||||
base.modelSettings || override.modelSettings
|
||||
? { ...unshadowedBaseModelSettings, ...override.modelSettings }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEffortConfigOption(
|
||||
modelInfos: ModelInfo[],
|
||||
currentModelId: string,
|
||||
currentEffortLevel: string | undefined,
|
||||
useRecommendedValue: boolean,
|
||||
): SessionConfigOption | undefined {
|
||||
const currentModelInfo = modelInfos.find((model) => model.value === currentModelId);
|
||||
const supportedLevels = currentModelInfo?.supportsEffort
|
||||
? (currentModelInfo.supportedEffortLevels ?? [])
|
||||
: [];
|
||||
if (supportedLevels.length === 0) return undefined;
|
||||
|
||||
const recommendedEffort = (supportedLevels as string[]).includes("medium")
|
||||
? "medium"
|
||||
: supportedLevels[0];
|
||||
const options = [
|
||||
...(useRecommendedValue ? [] : [{ value: "default", name: "Default" }]),
|
||||
...supportedLevels.map((level) => ({
|
||||
value: level,
|
||||
name: level
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
})),
|
||||
];
|
||||
const includes = (level: string) =>
|
||||
(!useRecommendedValue && level === "default") || (supportedLevels as string[]).includes(level);
|
||||
const currentValue =
|
||||
currentEffortLevel && includes(currentEffortLevel)
|
||||
? currentEffortLevel
|
||||
: useRecommendedValue
|
||||
? recommendedEffort
|
||||
: "default";
|
||||
|
||||
return {
|
||||
id: EFFORT_CONFIG_ID,
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue,
|
||||
options,
|
||||
...(useRecommendedValue
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommendedEffort),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
486
src/session-failure-extension.ts
Normal file
486
src/session-failure-extension.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
getSessionMessages,
|
||||
type SDKAssistantMessageError,
|
||||
USAGE_LIMIT_ERROR_PREFIXES,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
AIR_SESSION_FAILURE_CAPABILITY,
|
||||
airCapabilityMeta,
|
||||
clientSupportsAirCapability,
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
|
||||
export function airSessionFailureCapabilityMeta(...additionalCapabilities: string[]) {
|
||||
return airCapabilityMeta(AIR_SESSION_FAILURE_CAPABILITY, ...additionalCapabilities);
|
||||
}
|
||||
|
||||
export type ClaudeFailureKind =
|
||||
| "access_denied"
|
||||
| "advisory"
|
||||
| "auth_required"
|
||||
| "bad_request"
|
||||
| "budget_exhausted"
|
||||
| "context_exhausted"
|
||||
| "internal_error"
|
||||
| "overloaded"
|
||||
| "provider_error"
|
||||
| "quota_exhausted"
|
||||
| "rate_limited"
|
||||
| "transport_lost"
|
||||
| "worker_shutdown";
|
||||
|
||||
type AirSessionFailureCategory =
|
||||
"connection" | "access" | "limit" | "request" | "service" | "unknown";
|
||||
|
||||
type AirSessionFailureSeverity = "warning" | "error";
|
||||
type AirSessionFailureAction = "retry" | "login" | "new_session";
|
||||
|
||||
type SessionFailureRecoveryPolicy =
|
||||
"never" | "next_attempt" | "real_model_success" | "auth_status" | "runtime_rebind";
|
||||
|
||||
export type PublishedSessionFailure = {
|
||||
id: string;
|
||||
revision: number;
|
||||
kind: ClaudeFailureKind;
|
||||
category: AirSessionFailureCategory;
|
||||
turnId?: string;
|
||||
severity: AirSessionFailureSeverity;
|
||||
title: string;
|
||||
details?: string;
|
||||
/** A machine-readable refinement of `kind`, e.g. the `--hide-claude-auth`
|
||||
* subscription guard's reason on an `auth_required` failure. Absent for a
|
||||
* plain sign-out. */
|
||||
reason?: string;
|
||||
actions: AirSessionFailureAction[];
|
||||
recoveryPolicy: SessionFailureRecoveryPolicy;
|
||||
};
|
||||
|
||||
type SessionFailureOptions = {
|
||||
turnId?: string;
|
||||
sessionScoped?: boolean;
|
||||
title?: string;
|
||||
details?: string;
|
||||
severity?: AirSessionFailureSeverity;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type SessionFailureState = {
|
||||
epoch: string;
|
||||
revisions: Map<string, number>;
|
||||
active: Map<string, PublishedSessionFailure>;
|
||||
nextIncident?: number;
|
||||
lastNotice?: { title: string; id: string };
|
||||
};
|
||||
|
||||
type Logger = {
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
export function createSessionFailureState(): SessionFailureState {
|
||||
return {
|
||||
epoch: randomUUID(),
|
||||
revisions: new Map(),
|
||||
active: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const AIR_FAILURE_POLICY: Record<
|
||||
ClaudeFailureKind,
|
||||
{
|
||||
category: AirSessionFailureCategory;
|
||||
actions: AirSessionFailureAction[];
|
||||
fallbackTitle: string;
|
||||
}
|
||||
> = {
|
||||
// Account or credential blocks that signing in again cannot fix: the
|
||||
// organization must complete verification, or the cloud provider's
|
||||
// credentials must be refreshed outside Claude. No `login` action, and not
|
||||
// sticky like `auth_required` (nothing clears at `auth_status`).
|
||||
access_denied: {
|
||||
category: "access",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude could not access the account or provider credentials.",
|
||||
},
|
||||
advisory: {
|
||||
category: "unknown",
|
||||
actions: [],
|
||||
fallbackTitle: "Claude reported a notice.",
|
||||
},
|
||||
auth_required: {
|
||||
category: "access",
|
||||
actions: ["login"],
|
||||
fallbackTitle: "Sign in to continue using Claude.",
|
||||
},
|
||||
bad_request: {
|
||||
category: "request",
|
||||
actions: [],
|
||||
fallbackTitle: "Claude could not process this request.",
|
||||
},
|
||||
budget_exhausted: {
|
||||
category: "limit",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "This Claude session reached its configured budget.",
|
||||
},
|
||||
context_exhausted: {
|
||||
category: "limit",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "This Claude turn reached its configured limit.",
|
||||
},
|
||||
internal_error: {
|
||||
category: "service",
|
||||
actions: ["retry", "new_session"],
|
||||
fallbackTitle: "Claude Agent encountered an internal error.",
|
||||
},
|
||||
overloaded: {
|
||||
category: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude is temporarily overloaded.",
|
||||
},
|
||||
provider_error: {
|
||||
category: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "The model provider reported an error.",
|
||||
},
|
||||
quota_exhausted: {
|
||||
category: "limit",
|
||||
actions: [],
|
||||
fallbackTitle: "The Claude account has no available quota.",
|
||||
},
|
||||
rate_limited: {
|
||||
category: "limit",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude is temporarily rate limited.",
|
||||
},
|
||||
transport_lost: {
|
||||
category: "connection",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "The connection to Claude was lost.",
|
||||
},
|
||||
worker_shutdown: {
|
||||
category: "connection",
|
||||
actions: ["new_session"],
|
||||
fallbackTitle: "The Claude worker is shutting down.",
|
||||
},
|
||||
};
|
||||
|
||||
export function sessionFailureMeta(failure: PublishedSessionFailure) {
|
||||
return withAirMeta(undefined, AIR_SESSION_FAILURE_CAPABILITY, {
|
||||
id: failure.id,
|
||||
revision: failure.revision,
|
||||
category: failure.category,
|
||||
severity: failure.severity,
|
||||
title: failure.title,
|
||||
...(failure.details ? { details: failure.details } : {}),
|
||||
...(failure.reason ? { reason: failure.reason } : {}),
|
||||
actions: failure.actions,
|
||||
});
|
||||
}
|
||||
|
||||
/** `getSessionMessages` deliberately exposes only the API message and strips
|
||||
* transcript-level `error` / `isApiErrorMessage` fields. The SDK exports the
|
||||
* exact stable prefixes used by its synthetic usage-limit errors, so replay
|
||||
* can recover this one typed failure without matching arbitrary model prose. */
|
||||
export function assistantMessageText(apiMessage: unknown): string | undefined {
|
||||
if (!apiMessage || typeof apiMessage !== "object") return undefined;
|
||||
const { content } = apiMessage as { content?: unknown };
|
||||
if (typeof content === "string") return content || undefined;
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
const text = content
|
||||
.map((block) =>
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
block.type === "text" &&
|
||||
"text" in block &&
|
||||
typeof block.text === "string"
|
||||
? block.text
|
||||
: "",
|
||||
)
|
||||
.join("");
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
export function isSyntheticUsageLimitMessage(apiMessage: unknown): boolean {
|
||||
if (!apiMessage || typeof apiMessage !== "object") return false;
|
||||
const { model } = apiMessage as { model?: unknown };
|
||||
if (model !== "<synthetic>") return false;
|
||||
const text = assistantMessageText(apiMessage);
|
||||
if (!text) return false;
|
||||
return USAGE_LIMIT_ERROR_PREFIXES.some((prefix) => text.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function activeUsageLimitMessage(
|
||||
messages: Awaited<ReturnType<typeof getSessionMessages>>,
|
||||
): { uuid: string; title: string } | undefined {
|
||||
let active: { uuid: string; title: string } | undefined;
|
||||
for (const message of messages) {
|
||||
if (message.type !== "assistant" || message.parent_tool_use_id !== null) continue;
|
||||
if (isSyntheticUsageLimitMessage(message.message)) {
|
||||
const title = assistantMessageText(message.message);
|
||||
if (title) active = { uuid: message.uuid, title };
|
||||
continue;
|
||||
}
|
||||
const model =
|
||||
message.message && typeof message.message === "object" && "model" in message.message
|
||||
? message.message.model
|
||||
: undefined;
|
||||
// A later real model answer proves the account recovered. Synthetic
|
||||
// local-command/interruption messages do not: they can be appended while
|
||||
// the provider remains unavailable.
|
||||
if (model !== undefined && model !== "<synthetic>") active = undefined;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
export function supportsAirSessionFailures(capabilities?: ClientCapabilities): boolean {
|
||||
return clientSupportsAirCapability(capabilities, AIR_SESSION_FAILURE_CAPABILITY);
|
||||
}
|
||||
|
||||
function sessionFailureRecoveryPolicy(
|
||||
kind: ClaudeFailureKind,
|
||||
turnId?: string,
|
||||
): SessionFailureRecoveryPolicy {
|
||||
switch (kind) {
|
||||
case "advisory":
|
||||
return "never";
|
||||
case "auth_required":
|
||||
return "auth_status";
|
||||
case "transport_lost":
|
||||
case "worker_shutdown":
|
||||
return "runtime_rebind";
|
||||
case "quota_exhausted":
|
||||
return "real_model_success";
|
||||
default:
|
||||
return turnId ? "next_attempt" : "real_model_success";
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns the AIR failure lifecycle for both the live consumer and history replay.
|
||||
* Published revisions become active only after delivery succeeds; recovery only
|
||||
* removes internal active state because transcript records are historical. */
|
||||
export class SessionFailureController {
|
||||
private readonly sessionId: string;
|
||||
private readonly state: SessionFailureState;
|
||||
private readonly capabilities?: ClientCapabilities;
|
||||
private readonly isCurrent: () => boolean;
|
||||
private readonly sendUpdate: (notification: SessionNotification) => Promise<void>;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: {
|
||||
sessionId: string;
|
||||
state: SessionFailureState;
|
||||
capabilities?: ClientCapabilities;
|
||||
isCurrent: () => boolean;
|
||||
sendUpdate: (notification: SessionNotification) => Promise<void>;
|
||||
logger: Logger;
|
||||
}) {
|
||||
this.sessionId = options.sessionId;
|
||||
this.state = options.state;
|
||||
this.capabilities = options.capabilities;
|
||||
this.isCurrent = options.isCurrent;
|
||||
this.sendUpdate = options.sendUpdate;
|
||||
this.logger = options.logger;
|
||||
}
|
||||
|
||||
private isSupported(): boolean {
|
||||
return supportsAirSessionFailures(this.capabilities);
|
||||
}
|
||||
|
||||
private async emit(failure: PublishedSessionFailure): Promise<boolean> {
|
||||
if (!this.isCurrent()) {
|
||||
this.logger.error(
|
||||
`Session ${this.sessionId}: ignored AIR session failure from a stale consumer`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this.sendUpdate({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: sessionFailureMeta(failure),
|
||||
},
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Session ${this.sessionId}: failed to publish AIR session failure: ${error}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a session-scoped error of `kind` is active. A turn-scoped
|
||||
* warning of the same kind (an `api_retry` notice) does not count: it
|
||||
* reports a retry in progress, not the state the error would publish.
|
||||
*
|
||||
* `reason` refines `kind`, so the active record must carry the same one,
|
||||
* and an omitted `reason` matches only a record with no reason. A plain
|
||||
* sign-out and the `--hide-claude-auth` subscription refusal are both
|
||||
* `auth_required`, but they tell the user different things. Neither may
|
||||
* suppress the other. */
|
||||
hasActiveSessionError(kind: ClaudeFailureKind, reason?: string): boolean {
|
||||
for (const failure of this.state.active.values()) {
|
||||
if (
|
||||
failure.kind === kind &&
|
||||
failure.severity === "error" &&
|
||||
failure.turnId === undefined &&
|
||||
failure.reason === reason
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
recordActive(failure: PublishedSessionFailure): void {
|
||||
this.state.revisions.set(failure.id, failure.revision);
|
||||
this.state.active.set(failure.id, failure);
|
||||
this.state.lastNotice =
|
||||
failure.kind === "advisory" ? { title: failure.title, id: failure.id } : undefined;
|
||||
}
|
||||
|
||||
async clear(
|
||||
shouldClear: (failure: PublishedSessionFailure) => boolean = () => true,
|
||||
): Promise<boolean> {
|
||||
if (!this.isSupported()) return true;
|
||||
this.state.lastNotice = undefined;
|
||||
for (const failure of [...this.state.active.values()]) {
|
||||
if (!shouldClear(failure)) continue;
|
||||
this.state.active.delete(failure.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async prepare(
|
||||
kind: ClaudeFailureKind,
|
||||
failureOptions: SessionFailureOptions = {},
|
||||
): Promise<PublishedSessionFailure | undefined> {
|
||||
if (!this.isSupported() || !this.isCurrent()) return undefined;
|
||||
const policy = AIR_FAILURE_POLICY[kind];
|
||||
const title = failureOptions.title || policy.fallbackTitle;
|
||||
if (kind === "advisory") {
|
||||
const id =
|
||||
this.state.lastNotice?.title === title
|
||||
? this.state.lastNotice.id
|
||||
: `${this.sessionId}:notice:${this.state.epoch}:${this.state.nextIncident ?? 1}`;
|
||||
if (this.state.lastNotice?.title !== title) {
|
||||
this.state.nextIncident = (this.state.nextIncident ?? 1) + 1;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
revision: (this.state.revisions.get(id) ?? 0) + 1,
|
||||
kind,
|
||||
category: policy.category,
|
||||
severity: "warning",
|
||||
title,
|
||||
...(failureOptions.details ? { details: failureOptions.details } : {}),
|
||||
actions: policy.actions,
|
||||
recoveryPolicy: sessionFailureRecoveryPolicy(kind),
|
||||
};
|
||||
}
|
||||
const id =
|
||||
failureOptions.turnId && !failureOptions.sessionScoped
|
||||
? `${failureOptions.turnId}:error`
|
||||
: `${this.sessionId}:session-error:${this.state.epoch}:${this.state.nextIncident ?? 1}`;
|
||||
if (!failureOptions.turnId || failureOptions.sessionScoped) {
|
||||
this.state.nextIncident = (this.state.nextIncident ?? 1) + 1;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
revision: (this.state.revisions.get(id) ?? 0) + 1,
|
||||
kind,
|
||||
category: policy.category,
|
||||
severity: failureOptions.severity ?? "error",
|
||||
title,
|
||||
...(failureOptions.details ? { details: failureOptions.details } : {}),
|
||||
...(failureOptions.reason ? { reason: failureOptions.reason } : {}),
|
||||
actions: failureOptions.severity === "warning" ? [] : policy.actions,
|
||||
recoveryPolicy: sessionFailureRecoveryPolicy(
|
||||
kind,
|
||||
failureOptions.turnId && !failureOptions.sessionScoped ? failureOptions.turnId : undefined,
|
||||
),
|
||||
...(failureOptions.turnId && !failureOptions.sessionScoped
|
||||
? { turnId: failureOptions.turnId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async publish(
|
||||
kind: ClaudeFailureKind,
|
||||
failureOptions: SessionFailureOptions = {},
|
||||
): Promise<void> {
|
||||
const failure = await this.prepare(kind, failureOptions);
|
||||
if (!failure) return;
|
||||
if (await this.emit(failure)) this.recordActive(failure);
|
||||
}
|
||||
|
||||
async restore(
|
||||
id: string,
|
||||
kind: ClaudeFailureKind,
|
||||
title: string,
|
||||
active = true,
|
||||
): Promise<boolean> {
|
||||
if (!this.isSupported() || !this.isCurrent()) return false;
|
||||
const policy = AIR_FAILURE_POLICY[kind];
|
||||
const failure: PublishedSessionFailure = {
|
||||
id,
|
||||
revision: (this.state.revisions.get(id) ?? 0) + 1,
|
||||
kind,
|
||||
category: policy.category,
|
||||
severity: "error",
|
||||
title,
|
||||
actions: policy.actions,
|
||||
recoveryPolicy: sessionFailureRecoveryPolicy(kind),
|
||||
};
|
||||
if (!(await this.emit(failure))) return false;
|
||||
this.state.revisions.set(failure.id, failure.revision);
|
||||
if (active) {
|
||||
this.state.active.set(failure.id, failure);
|
||||
} else {
|
||||
this.state.active.delete(failure.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function providerFailureCategory(
|
||||
errorKind?: SDKAssistantMessageError,
|
||||
isUsageLimit = false,
|
||||
): ClaudeFailureKind {
|
||||
if (isUsageLimit) return "quota_exhausted";
|
||||
switch (errorKind) {
|
||||
case "authentication_failed":
|
||||
case "oauth_org_not_allowed":
|
||||
return "auth_required";
|
||||
case "billing_error":
|
||||
case "account_on_hold":
|
||||
return "quota_exhausted";
|
||||
// 403 `permission_error` with error_code `verification_required` (the
|
||||
// organization must verify at the console), and a Bedrock/Vertex/Foundry
|
||||
// credential the CLI could not load (transient: it retries, and asks the
|
||||
// user to check or refresh the credentials). Both are access failures
|
||||
// `/login` does not repair, so they get their own lane rather than
|
||||
// `auth_required`'s login action and sticky auth_status recovery.
|
||||
case "verification_required":
|
||||
case "cloud_credential_error":
|
||||
return "access_denied";
|
||||
case "rate_limit":
|
||||
return "rate_limited";
|
||||
case "overloaded":
|
||||
return "overloaded";
|
||||
case "invalid_request":
|
||||
case "model_not_found":
|
||||
return "bad_request";
|
||||
case "max_output_tokens":
|
||||
return "context_exhausted";
|
||||
case "server_error":
|
||||
case "unknown":
|
||||
case undefined:
|
||||
return "provider_error";
|
||||
default:
|
||||
return "provider_error";
|
||||
}
|
||||
}
|
||||
335
src/session-mode.ts
Normal file
335
src/session-mode.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
RequestError,
|
||||
type SessionConfigOption,
|
||||
type SessionModeState,
|
||||
type SessionNotification,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type {
|
||||
ModelInfo,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
Query,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export const MODE_CONFIG_ID = "mode";
|
||||
export const AUTO_MODE_FALLBACK: PermissionMode = "acceptEdits";
|
||||
|
||||
const AUTO_MODE_FALLBACK_NOTICE =
|
||||
"**Auto mode unavailable:** the selected model does not support Auto mode; using Accept edits instead.";
|
||||
|
||||
export type SessionMode = {
|
||||
query: Pick<Query, "setPermissionMode">;
|
||||
queryClosed?: boolean;
|
||||
modes: SessionModeState;
|
||||
models: { currentModelId: string };
|
||||
modelInfos: ModelInfo[];
|
||||
/** Prevents the model-specific Auto fallback from spamming the transcript. */
|
||||
autoModeFallbackWarningShown?: boolean;
|
||||
/** Initial mode fallback is reported after session/new, on the first prompt. */
|
||||
autoModeFallbackWarningPending?: boolean;
|
||||
};
|
||||
|
||||
export type SessionModeManagerOptions<S extends SessionMode> = {
|
||||
getSession(sessionId: string): S | undefined;
|
||||
sessionEndedMessage: string;
|
||||
updateConfigOption(sessionId: string, configId: string, value: string): Promise<void>;
|
||||
sessionUpdate(params: SessionNotification): Promise<void>;
|
||||
logError(...args: unknown[]): void;
|
||||
};
|
||||
|
||||
type ModeConfigSession = SessionMode & {
|
||||
configOptions: SessionConfigOption[];
|
||||
};
|
||||
|
||||
type InitializeSessionModeParams = {
|
||||
query: Pick<Query, "setPermissionMode">;
|
||||
requestedMode: PermissionMode;
|
||||
currentModelInfo?: ModelInfo;
|
||||
currentModelId: string;
|
||||
/** When false, bypassPermissions is omitted from the mode catalog. */
|
||||
allowBypass: boolean;
|
||||
};
|
||||
|
||||
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
|
||||
export class SessionModeManager<S extends SessionMode> {
|
||||
constructor(private readonly options: SessionModeManagerOptions<S>) {}
|
||||
|
||||
async initialize({
|
||||
query,
|
||||
requestedMode,
|
||||
currentModelInfo,
|
||||
currentModelId,
|
||||
allowBypass,
|
||||
}: InitializeSessionModeParams): Promise<{
|
||||
modes: SessionModeState;
|
||||
autoModeFallbackWarningPending: boolean;
|
||||
}> {
|
||||
const availableModes = this.buildAvailableModes(allowBypass);
|
||||
let effectiveMode = requestedMode;
|
||||
let autoModeFallbackWarningPending = false;
|
||||
|
||||
if (
|
||||
effectiveMode === "auto" &&
|
||||
currentModelInfo !== undefined &&
|
||||
currentModelInfo.supportsAutoMode !== true
|
||||
) {
|
||||
this.options.logError(
|
||||
`permissions.defaultMode "auto" is not available for model ` +
|
||||
`"${currentModelId}"; falling back to "${AUTO_MODE_FALLBACK}".`,
|
||||
);
|
||||
effectiveMode = AUTO_MODE_FALLBACK;
|
||||
autoModeFallbackWarningPending = true;
|
||||
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
|
||||
} else if (!this.isAvailable(availableModes, effectiveMode)) {
|
||||
this.options.logError(
|
||||
`permissions.defaultMode "${effectiveMode}" is not available in ` +
|
||||
`this session; falling back to "default".`,
|
||||
);
|
||||
effectiveMode = "default";
|
||||
await this.trySyncMode(query, effectiveMode, "Failed to sync clamped permissionMode to SDK:");
|
||||
}
|
||||
|
||||
return {
|
||||
modes: { currentModeId: effectiveMode, availableModes },
|
||||
autoModeFallbackWarningPending,
|
||||
};
|
||||
}
|
||||
|
||||
static configOption(modes: SessionModeState): SessionConfigOption {
|
||||
return {
|
||||
id: MODE_CONFIG_ID,
|
||||
name: "Mode",
|
||||
description: "Session permission mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: modes.currentModeId,
|
||||
options: modes.availableModes.map((mode) => ({
|
||||
value: mode.id,
|
||||
name: mode.name,
|
||||
description: mode.description,
|
||||
_meta: mode._meta,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
syncConfig(session: ModeConfigSession, mode: string): void {
|
||||
session.modes = { ...session.modes, currentModeId: mode };
|
||||
session.configOptions = session.configOptions.map((option) =>
|
||||
option.id === MODE_CONFIG_ID && typeof option.currentValue === "string"
|
||||
? { ...option, currentValue: mode }
|
||||
: option,
|
||||
);
|
||||
}
|
||||
|
||||
availableModeIds(modes: SessionModeState): string[] {
|
||||
return modes.availableModes.map((mode) => mode.id);
|
||||
}
|
||||
|
||||
effectiveMode(session: SessionMode, requestedMode: PermissionMode): PermissionMode {
|
||||
return requestedMode === "auto" && this.isAutoUnavailable(session)
|
||||
? AUTO_MODE_FALLBACK
|
||||
: requestedMode;
|
||||
}
|
||||
|
||||
applyPermissionFallback(
|
||||
session: SessionMode,
|
||||
permissionResult: PermissionResult,
|
||||
): { permissionResult: PermissionResult; fallbackApplied: boolean } {
|
||||
const fallbackApplied =
|
||||
this.isAutoUnavailable(session) &&
|
||||
permissionResult.behavior === "allow" &&
|
||||
permissionResult.updatedPermissions?.some(
|
||||
(update) => update.type === "setMode" && update.mode === "auto",
|
||||
) === true;
|
||||
if (!fallbackApplied || permissionResult.behavior !== "allow") {
|
||||
return { permissionResult, fallbackApplied: false };
|
||||
}
|
||||
|
||||
return {
|
||||
permissionResult: {
|
||||
...permissionResult,
|
||||
updatedPermissions: permissionResult.updatedPermissions?.map((update) =>
|
||||
update.type === "setMode" && update.mode === "auto"
|
||||
? { ...update, mode: AUTO_MODE_FALLBACK }
|
||||
: update,
|
||||
),
|
||||
},
|
||||
fallbackApplied: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reconcile mode after a model switch. The caller rebuilds config options
|
||||
* before publishing the returned state change. */
|
||||
async reconcileForModel(session: SessionMode, model: ModelInfo | undefined) {
|
||||
if (
|
||||
session.modes.currentModeId !== "auto" ||
|
||||
model === undefined ||
|
||||
model.supportsAutoMode === true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
session.modes = {
|
||||
availableModes: session.modes.availableModes,
|
||||
currentModeId: AUTO_MODE_FALLBACK,
|
||||
};
|
||||
await this.trySyncMode(
|
||||
session.query,
|
||||
AUTO_MODE_FALLBACK,
|
||||
`Failed to sync permissionMode to "${AUTO_MODE_FALLBACK}" after model switch invalidated "auto":`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async selectMode(sessionId: string, modeId: string): Promise<PermissionMode> {
|
||||
const session = this.requireOpenSession(sessionId);
|
||||
const requestedMode = this.parseMode(modeId);
|
||||
if (!this.isAvailable(session.modes.availableModes, requestedMode)) {
|
||||
throw new Error(`Mode ${modeId} is not available in this session`);
|
||||
}
|
||||
|
||||
const effectiveMode = this.effectiveMode(session, requestedMode);
|
||||
try {
|
||||
await session.query.setPermissionMode(effectiveMode);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (!error.message) error.message = "Invalid Mode";
|
||||
throw error;
|
||||
}
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error("Invalid Mode");
|
||||
}
|
||||
|
||||
if (effectiveMode !== requestedMode) {
|
||||
await this.publishFallbackWarning(sessionId, session);
|
||||
}
|
||||
return effectiveMode;
|
||||
}
|
||||
|
||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
||||
const effectiveMode = await this.selectMode(params.sessionId, params.modeId);
|
||||
if (effectiveMode !== params.modeId) {
|
||||
await this.publishCurrent(params.sessionId, effectiveMode);
|
||||
}
|
||||
await this.options.updateConfigOption(params.sessionId, MODE_CONFIG_ID, effectiveMode);
|
||||
return {};
|
||||
}
|
||||
|
||||
async publishCurrent(sessionId: string, mode: string): Promise<void> {
|
||||
await this.options.sessionUpdate({
|
||||
sessionId,
|
||||
update: { sessionUpdate: "current_mode_update", currentModeId: mode },
|
||||
});
|
||||
}
|
||||
|
||||
async publishFallbackWarning(sessionId: string, session: SessionMode): Promise<void> {
|
||||
session.autoModeFallbackWarningPending = false;
|
||||
if (session.autoModeFallbackWarningShown) return;
|
||||
session.autoModeFallbackWarningShown = true;
|
||||
try {
|
||||
await this.options.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: AUTO_MODE_FALLBACK_NOTICE },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// The fallback has already been applied; a failed advisory must not turn
|
||||
// the successful mode change into a failed request.
|
||||
this.options.logError(`Failed to publish Auto mode fallback warning: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async publishFallbackState(sessionId: string, session: SessionMode): Promise<void> {
|
||||
await this.publishCurrent(sessionId, AUTO_MODE_FALLBACK);
|
||||
await this.publishFallbackWarning(sessionId, session);
|
||||
}
|
||||
|
||||
private requireOpenSession(sessionId: string): S {
|
||||
const session = this.options.getSession(sessionId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
if (session.queryClosed) {
|
||||
throw RequestError.internalError(undefined, this.options.sessionEndedMessage);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private isAutoUnavailable(
|
||||
session: SessionMode,
|
||||
modelId = session.models.currentModelId,
|
||||
): boolean {
|
||||
const modelInfo = session.modelInfos.find((model) => model.value === modelId);
|
||||
return modelInfo !== undefined && modelInfo.supportsAutoMode !== true;
|
||||
}
|
||||
|
||||
private isAvailable(availableModes: SessionModeState["availableModes"], modeId: string): boolean {
|
||||
return availableModes.some((mode) => mode.id === modeId);
|
||||
}
|
||||
|
||||
private parseMode(modeId: string): PermissionMode {
|
||||
switch (modeId) {
|
||||
case "auto":
|
||||
case "default":
|
||||
case "acceptEdits":
|
||||
case "bypassPermissions":
|
||||
case "dontAsk":
|
||||
case "plan":
|
||||
return modeId;
|
||||
default:
|
||||
throw new Error("Invalid Mode");
|
||||
}
|
||||
}
|
||||
|
||||
private buildAvailableModes(allowBypass: boolean): SessionModeState["availableModes"] {
|
||||
const modes: SessionModeState["availableModes"] = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
_meta: { kind: "standard" },
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
_meta: { kind: "standard" },
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
_meta: { kind: "plan" },
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
_meta: { kind: "auto_review" },
|
||||
},
|
||||
];
|
||||
if (allowBypass) {
|
||||
modes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
_meta: { kind: "full_access" },
|
||||
});
|
||||
}
|
||||
return modes;
|
||||
}
|
||||
|
||||
private async trySyncMode(
|
||||
query: Pick<Query, "setPermissionMode">,
|
||||
mode: PermissionMode,
|
||||
errorMessage: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await query.setPermissionMode(mode);
|
||||
} catch (error) {
|
||||
this.options.logError(errorMessage, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
466
src/session-model.ts
Normal file
466
src/session-model.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo, Query, Settings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
|
||||
export const MODEL_CONFIG_ID = "model";
|
||||
|
||||
export type SessionModelState = {
|
||||
availableModels: Array<{ modelId: string; name: string; description?: string }>;
|
||||
currentModelId: string;
|
||||
};
|
||||
|
||||
type ModelLogger = {
|
||||
log: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
type ModelSettingsSource = { getSettings(): Settings };
|
||||
|
||||
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
|
||||
const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
|
||||
const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
|
||||
const VERSIONABLE_MODEL_DISPLAY_PATTERN =
|
||||
/^(?:Claude\s+)?([a-z][a-z0-9-]*)(?:\s+\d+(?:\.\d+)?)?(?:\s+\(\d+[mk]\s+context\))?$/i;
|
||||
const DISPLAY_CONTEXT_SUFFIX_PATTERN = /\s+\((\d+[mk])\s+context\)$/i;
|
||||
|
||||
function stripContextHints(value: string): string {
|
||||
return value.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
|
||||
}
|
||||
|
||||
function canonicalizeModelId(value: string): string {
|
||||
return value.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
|
||||
}
|
||||
|
||||
function contextHintOf(value: string): string | null {
|
||||
return canonicalizeModelId(value).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function extractModelFamilyVersion(value: string): string | null {
|
||||
const match = stripContextHints(value).match(MODEL_FAMILY_VERSION_PATTERN);
|
||||
if (!match) return null;
|
||||
return match[2] ? `${match[1]}.${match[2]}` : match[1];
|
||||
}
|
||||
|
||||
function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean {
|
||||
const preferred = extractModelFamilyVersion(preference);
|
||||
if (!preferred) return true;
|
||||
const candidateVersion =
|
||||
extractModelFamilyVersion(candidate.value) ??
|
||||
extractModelFamilyVersion(candidate.displayName) ??
|
||||
extractModelFamilyVersion(candidate.description);
|
||||
return candidateVersion ? preferred === candidateVersion : true;
|
||||
}
|
||||
|
||||
function displayModelVersion(model: ModelInfo, family: string): string | undefined {
|
||||
const familyVersion = new RegExp(`\\b${family}[-\\s]+(\\d+)(?:[-.](\\d+))?`, "i");
|
||||
// The display name is the value we are deciding whether to rewrite, so it
|
||||
// cannot validate its own normalization. Require independent SDK metadata.
|
||||
for (const source of [model.resolvedModel, model.description, model.value]) {
|
||||
const match = source?.match(familyVersion);
|
||||
if (match) return match[2] ? `${match[1]}.${match[2]}` : match[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Add concrete versions when a terse SDK label names the same family as the
|
||||
* underlying model metadata. Multi-word/custom labels remain untouched.
|
||||
* Context size remains in the option description; if normalization would
|
||||
* collide, callers fall back to the original labels. */
|
||||
function versionedModelDisplayName(model: ModelInfo): string {
|
||||
const versionable = model.displayName.match(VERSIONABLE_MODEL_DISPLAY_PATTERN);
|
||||
if (!versionable) return model.displayName;
|
||||
const version = displayModelVersion(model, versionable[1]);
|
||||
if (!version) return model.displayName;
|
||||
const contextSuffix = model.displayName.match(DISPLAY_CONTEXT_SUFFIX_PATTERN);
|
||||
const descriptionKeepsContext =
|
||||
contextSuffix !== null &&
|
||||
new RegExp(`\\b${contextSuffix[1]}\\s+context\\b`, "i").test(model.description);
|
||||
const withoutContext = model.displayName.replace(DISPLAY_CONTEXT_SUFFIX_PATTERN, "");
|
||||
if (/\b\d+(?:\.\d+)?\b/.test(withoutContext)) {
|
||||
return descriptionKeepsContext ? withoutContext : model.displayName;
|
||||
}
|
||||
if (descriptionKeepsContext || contextSuffix === null) return `${withoutContext} ${version}`;
|
||||
// Keep the suffix in its original spelling and insert the version before it.
|
||||
return `${withoutContext} ${version}${contextSuffix[0]}`;
|
||||
}
|
||||
|
||||
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
|
||||
const lower = model.trim().toLowerCase();
|
||||
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
|
||||
const tokens = lower
|
||||
.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ")
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
if (token === "opusplan") return "opus";
|
||||
if (token === "best" || token === "default") return "";
|
||||
return token;
|
||||
})
|
||||
.filter((token) => token && token !== "claude")
|
||||
.filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
|
||||
return { tokens, contextHint };
|
||||
}
|
||||
|
||||
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
|
||||
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
|
||||
let score = 0;
|
||||
let nonHintMatched = false;
|
||||
for (const token of tokens) {
|
||||
if (!haystack.includes(token)) continue;
|
||||
if (token !== contextHint) nonHintMatched = true;
|
||||
score += token === contextHint ? 3 : 1;
|
||||
}
|
||||
return contextHint && !nonHintMatched ? 0 : score;
|
||||
}
|
||||
|
||||
export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null {
|
||||
const trimmed = preference.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
const canonicalPreference = canonicalizeModelId(trimmed);
|
||||
const directMatch = models.find(
|
||||
(model) =>
|
||||
model.value === trimmed ||
|
||||
canonicalizeModelId(model.value) === canonicalPreference ||
|
||||
model.displayName.toLowerCase() === lower,
|
||||
);
|
||||
if (directMatch) return directMatch;
|
||||
|
||||
const matchesResolved = (model: ModelInfo) =>
|
||||
model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
|
||||
const resolvedMatch =
|
||||
models.find((model) => model.value !== "default" && matchesResolved(model)) ??
|
||||
models.find(matchesResolved);
|
||||
if (resolvedMatch) return resolvedMatch;
|
||||
|
||||
const preferenceHint = contextHintOf(trimmed);
|
||||
const includesMatch = models.find((model) => {
|
||||
if (!modelVersionsCompatible(trimmed, model)) return false;
|
||||
if (contextHintOf(model.value) !== preferenceHint) return false;
|
||||
const value = model.value.toLowerCase();
|
||||
const display = model.displayName.toLowerCase();
|
||||
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
||||
});
|
||||
if (includesMatch) return includesMatch;
|
||||
|
||||
const { tokens, contextHint } = tokenizeModelPreference(trimmed);
|
||||
if (tokens.length === 0) return null;
|
||||
let bestMatch: ModelInfo | null = null;
|
||||
let bestScore = 0;
|
||||
for (const model of models) {
|
||||
if (!modelVersionsCompatible(trimmed, model)) continue;
|
||||
const score = scoreModelMatch(model, tokens, contextHint);
|
||||
if (score > 0 && (!bestMatch || score > bestScore)) {
|
||||
bestMatch = model;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {
|
||||
const live = canonicalizeModelId(liveModel);
|
||||
const defaultEntry = models.find((model) => model.value === "default");
|
||||
const defaultResolved = defaultEntry?.resolvedModel
|
||||
? canonicalizeModelId(defaultEntry.resolvedModel)
|
||||
: undefined;
|
||||
if (defaultEntry && defaultResolved === live) return defaultEntry;
|
||||
const exactMatch = models.find(
|
||||
(model) => model.resolvedModel && canonicalizeModelId(model.resolvedModel) === live,
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
if (
|
||||
defaultEntry &&
|
||||
defaultResolved &&
|
||||
stripContextHints(defaultResolved) === stripContextHints(live)
|
||||
) {
|
||||
return defaultEntry;
|
||||
}
|
||||
return (
|
||||
resolveModelPreference(models, liveModel) ?? {
|
||||
value: liveModel,
|
||||
displayName: liveModel,
|
||||
description: "",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function buildModelConfigOption(
|
||||
models: SessionModelState,
|
||||
modelInfos: ModelInfo[],
|
||||
useRecommendedValue: boolean,
|
||||
): SessionConfigOption {
|
||||
const defaultInfo = modelInfos.find((model) => model.value === "default");
|
||||
const selectableIds = new Set(models.availableModels.map((model) => model.modelId));
|
||||
const concreteInfos = modelInfos.filter(
|
||||
(model) => model.value !== "default" && selectableIds.has(model.value),
|
||||
);
|
||||
// Recommendations describe the running default, so fuzzy family matching
|
||||
// must never substitute a different generation or context window.
|
||||
const defaultResolved = defaultInfo?.resolvedModel;
|
||||
const recommended = defaultResolved
|
||||
? (concreteInfos.find(
|
||||
(model) =>
|
||||
canonicalizeModelId(model.resolvedModel ?? model.value) ===
|
||||
canonicalizeModelId(defaultResolved),
|
||||
) ?? null)
|
||||
: null;
|
||||
const concreteRecommendation = useRecommendedValue && recommended !== null;
|
||||
const available = concreteRecommendation
|
||||
? models.availableModels.filter((model) => model.modelId !== "default")
|
||||
: models.availableModels;
|
||||
|
||||
return {
|
||||
id: MODEL_CONFIG_ID,
|
||||
name: "Model",
|
||||
description: "AI model to use",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue:
|
||||
concreteRecommendation && models.currentModelId === "default"
|
||||
? recommended.value
|
||||
: models.currentModelId,
|
||||
...(concreteRecommendation
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, recommended.value),
|
||||
}
|
||||
: {}),
|
||||
options: available.map((model) => {
|
||||
if (model.modelId === "default" && defaultInfo?.resolvedModel) {
|
||||
const named = modelInfos.find(
|
||||
(info) => info.value !== "default" && info.resolvedModel === defaultInfo.resolvedModel,
|
||||
);
|
||||
return {
|
||||
value: model.modelId,
|
||||
name: model.name,
|
||||
description: named?.displayName ?? defaultInfo.resolvedModel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: model.modelId,
|
||||
name: model.name,
|
||||
description: model.description ?? undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSettingsModel(
|
||||
models: ModelInfo[],
|
||||
settingsModel: unknown,
|
||||
logger: ModelLogger,
|
||||
): ModelInfo | null {
|
||||
if (settingsModel === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof settingsModel !== "string") {
|
||||
const typeLabel = settingsModel === null ? "null" : typeof settingsModel;
|
||||
logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`);
|
||||
return null;
|
||||
}
|
||||
return resolveModelPreference(models, settingsModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the SDK's model list to the user's `availableModels` allowlist
|
||||
* (already merged-and-deduped across settings sources by `SettingsManager`).
|
||||
* The user's exact entries become the model IDs surfaced via configOptions
|
||||
* and passed to `setModel`, which prevents Claude Code from silently
|
||||
* substituting a date-pinned variant (e.g. `haiku` →
|
||||
* `claude-haiku-4-5-20251001`) that the user may not have access to.
|
||||
*
|
||||
* Display info and capability flags are copied from the closest SDK match so
|
||||
* the UI still renders sensible names and effort levels.
|
||||
*
|
||||
* Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
|
||||
* - `undefined` is handled by the caller (no allowlist applied).
|
||||
* - The Default option is unaffected by `availableModels` — it always remains
|
||||
* available, even when the allowlist is `[]`.
|
||||
*/
|
||||
export function applyAvailableModelsAllowlist(
|
||||
sdkModels: ModelInfo[],
|
||||
allowlist: string[],
|
||||
settingsModelOverrides?: Record<string, string>,
|
||||
): ModelInfo[] {
|
||||
// Default is always preserved per the docs. Synthesize one if the SDK
|
||||
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
|
||||
// `models[0]` as a fallback) still has something to work with.
|
||||
const defaultModel = sdkModels.find((m) => m.value === "default") ?? {
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
};
|
||||
const result: ModelInfo[] = [defaultModel];
|
||||
const seen = new Set<string>([defaultModel.value]);
|
||||
|
||||
const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
|
||||
|
||||
// Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6")
|
||||
// in availableModels but require provider-specific IDs at the API. We still
|
||||
// resolve `sdkMatch` against the alias (`trimmed`) — that's what the
|
||||
// matching heuristics above are built for, and override targets (ARNs,
|
||||
// opaque provider IDs) often won't textually resemble anything in
|
||||
// `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
|
||||
// override target, so it's what `setModel` ends up passing to the API.
|
||||
for (const entry of allowlist) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
|
||||
const overridden = settingsModelOverrides?.[trimmed];
|
||||
const effective = overridden ?? trimmed;
|
||||
if (seen.has(effective)) continue;
|
||||
|
||||
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
|
||||
if (sdkMatch) {
|
||||
result.push({ ...sdkMatch, value: effective });
|
||||
} else {
|
||||
result.push({ value: effective, displayName: trimmed, description: "" });
|
||||
}
|
||||
seen.add(effective);
|
||||
}
|
||||
|
||||
// The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
|
||||
// allowlist, the same way Default is. Per the model-config docs it adds an
|
||||
// entry "without replacing the built-in aliases" and "appears at the bottom of
|
||||
// the /model picker", so we append it last and skip the allowlist filter; this
|
||||
// keeps a slim alias allowlist from hiding the custom model row.
|
||||
// https://code.claude.com/docs/en/model-config#add-a-custom-model-option
|
||||
const customModelOption = process.env.ANTHROPIC_CUSTOM_MODEL_OPTION?.trim();
|
||||
if (customModelOption && !seen.has(customModelOption)) {
|
||||
const customModel = sdkModels.find((m) => m.value === customModelOption);
|
||||
if (customModel) {
|
||||
result.push(customModel);
|
||||
seen.add(customModel.value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Whether a rejected `setModel` was vetoed by a user-configured
|
||||
* PreModelSwitch hook. The CLI's rejection message is the stable,
|
||||
* distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
|
||||
* <reason>"); a format change makes this stop matching and the failure
|
||||
* falls back to the ordinary fail-loud path, no worse than before. */
|
||||
function isPreModelSwitchHookBlock(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes("blocked by a PreModelSwitch hook");
|
||||
}
|
||||
|
||||
export async function getAvailableModels(
|
||||
query: Query,
|
||||
models: ModelInfo[],
|
||||
sdkModels: ModelInfo[],
|
||||
settingsManager: ModelSettingsSource,
|
||||
logger: ModelLogger,
|
||||
isResumedSession: boolean,
|
||||
sessionId: string,
|
||||
resumedModelHint?: string,
|
||||
): Promise<SessionModelState> {
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
let currentModel = models[0];
|
||||
let resolvedFromInput: string | undefined;
|
||||
// Model priority (highest to lowest):
|
||||
// 1. ANTHROPIC_MODEL environment variable
|
||||
// 2. settings.model (user configuration)
|
||||
// 3. the resumed session's live model (resumed sessions only)
|
||||
// 4. models[0] (default first model)
|
||||
if (process.env.ANTHROPIC_MODEL) {
|
||||
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
resolvedFromInput = process.env.ANTHROPIC_MODEL;
|
||||
}
|
||||
} else if (typeof settings.model === "string") {
|
||||
const match = resolveSettingsModel(models, settings.model, logger);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
resolvedFromInput = settings.model;
|
||||
}
|
||||
}
|
||||
|
||||
// A resumed session restores the model from its last real assistant
|
||||
// transcript record. Use that same local record instead of getContextUsage:
|
||||
// the latter is a control request to the live CLI process and can add tens
|
||||
// of seconds to session/load before its first turn. No `setModel` here: the
|
||||
// SDK is already running this model, and pushing a picker alias back (e.g.
|
||||
// "opus[1m]") could change the live model rather than describe it.
|
||||
if (resolvedFromInput === undefined && isResumedSession) {
|
||||
currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : currentModel;
|
||||
}
|
||||
|
||||
// Skip the setModel round-trip when we can prove the SDK has already landed
|
||||
// on the same model. Two cases qualify:
|
||||
// (a) No override applied — currentModel is the SDK's own default (or, on
|
||||
// resume, the live model read back from the SDK above); nothing to sync.
|
||||
// (b) The resolver returned the user's input verbatim AND that value exists
|
||||
// in the SDK's original model list — meaning no fuzzy match or
|
||||
// allowlist rewrite was involved, and the SDK (which reads the same
|
||||
// ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
|
||||
// This only holds for fresh sessions: a resumed session lands on the
|
||||
// transcript's model regardless of env/settings, so the override must
|
||||
// be re-asserted to keep the reported model truthful.
|
||||
// Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
|
||||
// setModel call so we don't drift from the user's intended pin.
|
||||
const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
|
||||
const skipSetModel =
|
||||
resolvedFromInput === undefined ||
|
||||
(!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
|
||||
if (!skipSetModel) {
|
||||
const setModelStartedAt = performance.now();
|
||||
try {
|
||||
await query.setModel(currentModel.value);
|
||||
logger.log(
|
||||
`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=success`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.log(
|
||||
`[session/models] sessionId=${sessionId} phase=set-model durationMs=${Math.round(performance.now() - setModelStartedAt)} model=${currentModel.value} outcome=error`,
|
||||
);
|
||||
// On a fresh session the pin is a defining option — fail loudly. A
|
||||
// resumed session already runs fine on the transcript's model, so
|
||||
// failing the whole session/load over the re-assert would be worse
|
||||
// than loading with the pin unapplied (mirrors the setPermissionMode
|
||||
// containment in createSession). The SDK then stayed on the
|
||||
// transcript's model, so read that back rather than reporting the
|
||||
// pin the session isn't running.
|
||||
//
|
||||
// One fresh-session failure is advisory, not defining: a
|
||||
// user-configured PreModelSwitch hook can veto the pin (CLI 2.1.251+;
|
||||
// 'deny', or 'ask' — which headless sessions refuse), and the SDK
|
||||
// rejects setModel with "Model switch blocked by a PreModelSwitch
|
||||
// hook: …". Terminal Claude Code never lets a hook veto its startup
|
||||
// model (the spawn model isn't a switch), so failing session/new here
|
||||
// would make the same hook config fatal only over ACP. The session
|
||||
// stays on the SDK's own default — report that. We can't read the
|
||||
// live model back on this path: getContextUsage isn't serviced on a
|
||||
// fresh session until the first prompt turn (issues #886/#880).
|
||||
if (!isResumedSession) {
|
||||
if (!isPreModelSwitchHookBlock(error)) throw error;
|
||||
logger.error(
|
||||
`Model pin "${currentModel.value}" was vetoed by a PreModelSwitch hook; staying on the default model:`,
|
||||
error,
|
||||
);
|
||||
currentModel = models[0];
|
||||
} else {
|
||||
logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
|
||||
currentModel = resumedModelHint ? matchResumedModel(models, resumedModelHint) : models[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const displayNames = models.map(versionedModelDisplayName);
|
||||
const displayNameCounts = new Map<string, number>();
|
||||
for (const name of displayNames) {
|
||||
displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
availableModels: models.map((model, index) => ({
|
||||
modelId: model.value,
|
||||
name:
|
||||
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
|
||||
description: model.description,
|
||||
})),
|
||||
currentModelId: currentModel.value,
|
||||
};
|
||||
}
|
||||
27
src/session-timing.ts
Normal file
27
src/session-timing.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
type TimingLogger = {
|
||||
log: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
/** Small phase timer for session lifecycle diagnostics. */
|
||||
export class SessionTiming {
|
||||
private readonly startedAt: number;
|
||||
private phaseStartedAt: number;
|
||||
|
||||
constructor(
|
||||
private readonly logger: TimingLogger | undefined,
|
||||
private readonly operation: string,
|
||||
private readonly sessionId: string,
|
||||
startedAt = performance.now(),
|
||||
) {
|
||||
this.startedAt = startedAt;
|
||||
this.phaseStartedAt = startedAt;
|
||||
}
|
||||
|
||||
phase(name: string, detail = ""): void {
|
||||
const finishedAt = performance.now();
|
||||
this.logger?.log(
|
||||
`[session/${this.operation}] sessionId=${this.sessionId} phase=${name} durationMs=${Math.round(finishedAt - this.phaseStartedAt)} totalMs=${Math.round(finishedAt - this.startedAt)}${detail}`,
|
||||
);
|
||||
this.phaseStartedAt = finishedAt;
|
||||
}
|
||||
}
|
||||
246
src/session-titles.ts
Normal file
246
src/session-titles.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* AI-generated session titles.
|
||||
*
|
||||
* Claude Code's own auto-title generation never runs under the Agent SDK — the
|
||||
* latch that arms it is pre-set in the headless path — so `SDKSessionInfo.summary`
|
||||
* degrades to the raw first prompt. This module asks the CLI for a real title
|
||||
* instead, via the `generate_session_title` control request, and publishes it
|
||||
* over ACP as a `session_info_update`.
|
||||
*
|
||||
* {@link SessionTitles} holds the per-session state and is owned by `Session`.
|
||||
* `acp-agent.ts` drives it from four places: {@link SessionTitles.onPrompt} and
|
||||
* {@link SessionTitles.onAssistantText} to collect text,
|
||||
* {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`, and
|
||||
* {@link SessionTitles.reset} on `conversation_reset`.
|
||||
*/
|
||||
|
||||
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk";
|
||||
import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent, Session } from "./acp-agent.js";
|
||||
|
||||
const MAX_TITLE_LENGTH = 256;
|
||||
|
||||
/** How much session text the title is generated from. The CLI caps its own
|
||||
* title input at the same 1000 trailing characters. */
|
||||
const MAX_TITLE_CONTEXT_LENGTH = 1000;
|
||||
|
||||
/** Below this the generator returns null without calling the model, so there is
|
||||
* nothing to gain by asking yet. */
|
||||
const MIN_TITLE_CONTEXT_LENGTH = 10;
|
||||
|
||||
/** `generateSessionTitle` is present on the SDK's runtime `Query` class but is
|
||||
* not declared in `sdk.d.ts` (0.3.220), so it is reached through this optional
|
||||
* shape rather than the published interface. */
|
||||
type TitleCapableQuery = Query & {
|
||||
generateSessionTitle?: (
|
||||
description: string,
|
||||
options?: { persist?: boolean },
|
||||
) => Promise<string | null>;
|
||||
};
|
||||
|
||||
/** A title already stored for the session, published when generation is
|
||||
* unavailable or yields nothing. */
|
||||
type TitleFallback = { title: string; lastModified: number };
|
||||
|
||||
export function sanitizeTitle(text: string): string {
|
||||
// Replace newlines and collapse whitespace
|
||||
const sanitized = text
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (sanitized.length <= MAX_TITLE_LENGTH) {
|
||||
return sanitized;
|
||||
}
|
||||
return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
||||
}
|
||||
|
||||
/** Rolling text a title is generated from: `previous` plus `text`, keeping only
|
||||
* the trailing {@link MAX_TITLE_CONTEXT_LENGTH} characters. */
|
||||
export function appendTitleContext(previous: string | undefined, text: string): string {
|
||||
const next = (previous ?? "") + text;
|
||||
return next.length > MAX_TITLE_CONTEXT_LENGTH ? next.slice(-MAX_TITLE_CONTEXT_LENGTH) : next;
|
||||
}
|
||||
|
||||
/** Whether this SDK still offers on-demand title generation. */
|
||||
function supportsTitleGeneration(query: Query): boolean {
|
||||
return typeof (query as TitleCapableQuery).generateSessionTitle === "function";
|
||||
}
|
||||
|
||||
/** One session's title: the text a generated title is derived from, whether a
|
||||
* title has been settled on, and the last one published. Created with the
|
||||
* session and reachable as `session.titles`. */
|
||||
export class SessionTitles {
|
||||
/** Last title pushed to the client via `session_info_update`, so an unchanged
|
||||
* title is never re-notified. Undefined until the first push. */
|
||||
private lastTitle?: string;
|
||||
|
||||
/** Rolling tail of this session's own user + assistant text, capped at
|
||||
* {@link MAX_TITLE_CONTEXT_LENGTH}. Dropped once a title exists. */
|
||||
private context?: string;
|
||||
|
||||
/** Set once this session has a title or has asked for one. A title is
|
||||
* generated at most once per session: the SDK reports a generated title in
|
||||
* the same field as a user `/rename`, so re-titling could silently overwrite
|
||||
* one. Released by {@link reset}, and when generation yields nothing. */
|
||||
private settled = false;
|
||||
|
||||
constructor(
|
||||
private readonly agent: ClaudeAcpAgent,
|
||||
private readonly sessionId: string,
|
||||
) {}
|
||||
|
||||
/** Collect a prompt's own text for the title, skipping openers not worth
|
||||
* titling after. No-op once the title is settled. */
|
||||
onPrompt(prompt: PromptRequest["prompt"]): void {
|
||||
if (this.settled) {
|
||||
return;
|
||||
}
|
||||
const promptText = prompt
|
||||
.flatMap((chunk) => (chunk.type === "text" ? [chunk.text] : []))
|
||||
.join("\n");
|
||||
|
||||
this.context = appendTitleContext(this.context, `\n${promptText}\n`);
|
||||
}
|
||||
|
||||
/** Collect the assistant's own answer for the title. Chunks are appended
|
||||
* verbatim so streamed text reassembles. No-op once the title is settled. */
|
||||
onAssistantText(content: ContentBlock): void {
|
||||
if (this.settled || content.type !== "text") {
|
||||
return;
|
||||
}
|
||||
this.context = appendTitleContext(this.context, content.text);
|
||||
}
|
||||
|
||||
/** Drop the title state so the next turn re-evaluates. Used on
|
||||
* `conversation_reset`, which mounts a fresh transcript. */
|
||||
reset(): void {
|
||||
this.settled = false;
|
||||
this.context = undefined;
|
||||
this.lastTitle = undefined;
|
||||
}
|
||||
|
||||
/** Turn-end title handling. `idle` is the SDK's turn-over signal, so it is
|
||||
* when a title may have landed or become generatable.
|
||||
*
|
||||
* `info.customTitle` is non-empty only once the session file holds a real
|
||||
* title — a user `/rename` or one generated earlier; the SDK folds both into
|
||||
* that one field. Adopt it and latch, so neither is ever titled over.
|
||||
*
|
||||
* With no title yet, ask the SDK to generate one in the background: it is a
|
||||
* ~2s small-model call and turn-end must not wait on it. Only when that is
|
||||
* not possible do we fall back to `summary`, which for an SDK-driven session
|
||||
* is just the raw first prompt. */
|
||||
async onTurnEnd(session: Session): Promise<void> {
|
||||
const info = await this.readSessionInfo(session);
|
||||
|
||||
if (info?.customTitle) {
|
||||
this.settled = true;
|
||||
this.context = undefined;
|
||||
await this.publish(info.customTitle, info.lastModified);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = info?.summary
|
||||
? { title: info.summary, lastModified: info.lastModified }
|
||||
: undefined;
|
||||
|
||||
if (this.canRequest(session)) {
|
||||
this.settled = true;
|
||||
|
||||
void this.requestGenerateTitle(session, fallback).catch((error) => {
|
||||
this.agent.logger.error(`Session ${this.sessionId}: session title update failed: ${error}`);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
// Only while this session has no title of its own: `summary` degrades to the
|
||||
// raw first prompt, which must never overwrite a generated title if the
|
||||
// persisted one is slow to show up in `info`.
|
||||
if (fallback && !this.settled) {
|
||||
await this.publish(fallback.title, fallback.lastModified);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the SDK's stored info for this session. A missing session file or read
|
||||
* error is non-fatal: the title is best-effort and another turn will retry. */
|
||||
private async readSessionInfo(session: Session): Promise<SDKSessionInfo | undefined> {
|
||||
try {
|
||||
return await getSessionInfo(this.sessionId, { dir: session.cwd });
|
||||
} catch (error) {
|
||||
this.agent.logger.error(`Session ${this.sessionId}: failed to read session info: ${error}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Notify the client of a title, unless it is the one we last sent. */
|
||||
private async publish(rawTitle: string, lastModified: number): Promise<void> {
|
||||
const title = sanitizeTitle(rawTitle);
|
||||
if (title === this.lastTitle) {
|
||||
return;
|
||||
}
|
||||
this.lastTitle = title;
|
||||
await this.agent.client.sessionUpdate({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
title,
|
||||
updatedAt: new Date(lastModified).toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether to ask the SDK for a generated title now: once per session, on a
|
||||
* live query, with enough collected text for the generator to work with. */
|
||||
private canRequest(session: Session): boolean {
|
||||
if (this.settled || session.queryClosed || session.cancelled) {
|
||||
return false;
|
||||
}
|
||||
if (!supportsTitleGeneration(session.query)) {
|
||||
return false;
|
||||
}
|
||||
return (this.context?.trim().length ?? 0) >= MIN_TITLE_CONTEXT_LENGTH;
|
||||
}
|
||||
|
||||
/** Generate a title from the session's own text and publish it. `persist`
|
||||
* writes it to the session file, which is what carries it into
|
||||
* `session/list` and `session/load`. A null or failed generation releases the
|
||||
* latch so a later turn retries, and publishes `fallback` (the stored
|
||||
* summary) so a backend that can't generate titles is no worse off than
|
||||
* before. */
|
||||
private async requestGenerateTitle(
|
||||
session: Session,
|
||||
fallback: TitleFallback | undefined,
|
||||
): Promise<void> {
|
||||
const description = this.context?.trim() ?? "";
|
||||
let title: string | null = null;
|
||||
|
||||
try {
|
||||
title =
|
||||
(await (session.query as TitleCapableQuery).generateSessionTitle?.(description, {
|
||||
persist: true,
|
||||
})) ?? null;
|
||||
} catch (error) {
|
||||
this.agent.logger.error(
|
||||
`Session ${this.sessionId}: failed to generate a session title: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// A session torn down or replaced while the title was in flight must not
|
||||
// adopt it.
|
||||
if (this.agent.sessions[this.sessionId] !== session) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
this.settled = false;
|
||||
|
||||
if (fallback) {
|
||||
await this.publish(fallback.title, fallback.lastModified);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.context = undefined;
|
||||
await this.publish(title, Date.now());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({
|
||||
querySpy: vi.fn(),
|
||||
@@ -34,7 +35,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
@@ -45,8 +46,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
return { getCapturedOptions: () => capturedOptions, setModelSpy };
|
||||
}
|
||||
@@ -94,7 +94,119 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("dontAsk");
|
||||
expect(getCapturedOptions().settingSources).toEqual(["user", "project", "local"]);
|
||||
expect(response.modes.currentModeId).toBe("dontAsk");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
}, 15_000);
|
||||
|
||||
it.each([
|
||||
{ recommended: false, setting: undefined, explicit: undefined, expected: "default" },
|
||||
{ recommended: false, setting: "low", explicit: "high", expected: "high" },
|
||||
{ recommended: true, setting: undefined, explicit: undefined, expected: "medium" },
|
||||
{ recommended: true, setting: "high", explicit: undefined, expected: "high" },
|
||||
{ recommended: true, setting: "high", explicit: "max", expected: "max" },
|
||||
])(
|
||||
"initial effort reflects SDK state: %j",
|
||||
async ({ recommended, setting, explicit, expected }) => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ effortLevel: setting }),
|
||||
);
|
||||
const applyFlagSettings = vi.fn();
|
||||
querySpy.mockReturnValue(
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
if (recommended) {
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
}
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true, claudeCode: { options: { effort: explicit } } },
|
||||
});
|
||||
expect(
|
||||
response.configOptions.find((option: any) => option.id === "effort").currentValue,
|
||||
).toBe(expected);
|
||||
if (recommended) {
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ effortLevel: expected });
|
||||
} else {
|
||||
expect(applyFlagSettings).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(agent.sessions[response.sessionId].effortPinnedLevel).toBe(explicit);
|
||||
expect(agent.sessions[response.sessionId].appliedEffortLevel).toBe(
|
||||
recommended ? expected : explicit,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("seeds effort from higher-priority programmatic model settings", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
effortLevel: "high",
|
||||
modelSettings: { "claude-sonnet-5[1m]": { effortLevel: "high" } },
|
||||
}),
|
||||
);
|
||||
const applyFlagSettings = vi.fn();
|
||||
querySpy.mockReturnValue(
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
resolvedModel: "claude-sonnet-5[1m]",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
claudeCode: {
|
||||
options: {
|
||||
settings: {
|
||||
effortLevel: "medium",
|
||||
modelSettings: { "claude-sonnet-5-1m": { effortLevel: "low" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.configOptions.find((option: any) => option.id === "effort").currentValue).toBe(
|
||||
"low",
|
||||
);
|
||||
expect(applyFlagSettings).toHaveBeenCalledWith({ effortLevel: "low" });
|
||||
expect(agent.sessions[response.sessionId].effortSettingsOverride?.effortLevel).toBe("medium");
|
||||
});
|
||||
|
||||
it("supports acceptEdits mode defaults", async () => {
|
||||
@@ -150,6 +262,37 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("honors the host opt-out from bypassPermissions", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "bypassPermissions" } }),
|
||||
);
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
claudeCode: { options: { allowDangerouslySkipPermissions: false } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().allowDangerouslySkipPermissions).toBe(false);
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
expect(response.modes.availableModes.map((mode: { id: string }) => mode.id)).not.toContain(
|
||||
"bypassPermissions",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
@@ -240,12 +383,11 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
const setPermissionModeSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
setPermissionMode: setPermissionModeSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
return {
|
||||
getCapturedOptions: () => capturedOptions,
|
||||
@@ -254,7 +396,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("omits `auto` from availableModes when the resolved model lacks supportsAutoMode", async () => {
|
||||
it("advertises `auto` when the resolved model lacks supportsAutoMode", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
@@ -277,10 +419,8 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
|
||||
expect(modeIds).not.toContain("auto");
|
||||
expect(modeIds).toEqual(
|
||||
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
|
||||
);
|
||||
expect(modeIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan", "auto"]));
|
||||
expect(modeIds).not.toContain("dontAsk");
|
||||
});
|
||||
|
||||
it("includes `auto` when the resolved model has supportsAutoMode: true", async () => {
|
||||
@@ -306,10 +446,45 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
|
||||
expect(modeIds).toContain("auto");
|
||||
expect(response.modes.availableModes.slice(0, 4)).toEqual([
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
_meta: { kind: "standard" },
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
_meta: { kind: "standard" },
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
_meta: { kind: "plan" },
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
_meta: { kind: "auto_review" },
|
||||
},
|
||||
]);
|
||||
const bypass = response.modes.availableModes[4];
|
||||
if (bypass) {
|
||||
expect(bypass).toEqual({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
_meta: { kind: "full_access" },
|
||||
});
|
||||
}
|
||||
expect(modeIds).not.toContain("dontAsk");
|
||||
});
|
||||
|
||||
it("clamps permissions.defaultMode='auto' to 'default' on a model that lacks supportsAutoMode", async () => {
|
||||
it("falls back permissions.defaultMode='auto' to Accept edits on an unsupported model", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "auto" } }),
|
||||
@@ -343,9 +518,11 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
// carries the user-typed value; the SDK was synced via
|
||||
// setPermissionMode after we discovered the model can't honor it.
|
||||
expect(getCapturedOptions().permissionMode).toBe("auto");
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
expect(response.modes.availableModes.map((m: any) => m.id)).not.toContain("auto");
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("acceptEdits");
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
expect(response.modes.availableModes.map((m: any) => m.id)).toContain("auto");
|
||||
const session = (agent as any).sessions[response.sessionId];
|
||||
expect(session.autoModeFallbackWarningPending).toBe(true);
|
||||
|
||||
// A descriptive warning was logged so operators see the clamp.
|
||||
const messages = errorSpy.mock.calls.map((c) => c.join(" "));
|
||||
@@ -396,11 +573,10 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
} {
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
return { setModelSpy };
|
||||
}
|
||||
@@ -875,7 +1051,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options: _options }: any) => {
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
@@ -891,8 +1067,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
@@ -923,7 +1098,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
@@ -932,8 +1107,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -977,7 +1151,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
@@ -985,8 +1159,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
}) as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,30 +7,30 @@ import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-age
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: ({ options }: { options: Options }) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
},
|
||||
}));
|
||||
);
|
||||
const { makeMockQuery } = await import("./helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
query: ({ options }: { options: Options }) => {
|
||||
capturedOptions = options;
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
707
src/tests/async-tasks.test.ts
Normal file
707
src/tests/async-tasks.test.ts
Normal file
@@ -0,0 +1,707 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import {
|
||||
AsyncTaskRuntime,
|
||||
backgroundBashTaskFromToolResult,
|
||||
clientSupportsAsyncTasks,
|
||||
} from "../async-tasks.js";
|
||||
|
||||
describe("AsyncTaskRuntime", () => {
|
||||
it("recovers a background Bash task from its structured tool result", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "root", async (notification) => {
|
||||
updates.push(notification);
|
||||
});
|
||||
const task = backgroundBashTaskFromToolResult(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "bash-tool",
|
||||
content:
|
||||
"Command running in background with ID: bpux8xmfg. Output is being written to: /private/tmp/claude/tasks/bpux8xmfg.output. You will be notified when it completes.",
|
||||
},
|
||||
],
|
||||
{ backgroundTaskId: "bpux8xmfg", stdout: "", stderr: "" },
|
||||
{
|
||||
"bash-tool": {
|
||||
name: "Bash",
|
||||
input: { command: "npm run build", run_in_background: true },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(task).toEqual({
|
||||
taskId: "bpux8xmfg",
|
||||
taskType: "local_bash",
|
||||
description: "npm run build",
|
||||
isBackgrounded: true,
|
||||
outputFilePath: "/private/tmp/claude/tasks/bpux8xmfg.output",
|
||||
toolCallId: "bash-tool",
|
||||
});
|
||||
// The SDK can report local_bash before the Bash result proves that it was
|
||||
// backgrounded. The structured result must promote that existing task.
|
||||
await runtime.taskStarted({
|
||||
taskId: "bpux8xmfg",
|
||||
taskType: "local_bash",
|
||||
description: "Shell",
|
||||
});
|
||||
await runtime.taskBackgrounded(task!);
|
||||
await runtime.taskNotification("bpux8xmfg", "completed", "Build finished");
|
||||
|
||||
expect(updates.map((notification) => notification.update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
expect(updates[0].update).toMatchObject({
|
||||
asyncTaskId: "bpux8xmfg",
|
||||
name: "npm run build",
|
||||
taskType: "shell",
|
||||
description: "npm run build",
|
||||
showInTranscript: true,
|
||||
canStop: true,
|
||||
outputFilePath: "/private/tmp/claude/tasks/bpux8xmfg.output",
|
||||
toolCallId: "bash-tool",
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes a stopped terminal after a task-specific stop", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
taskId: "task-1",
|
||||
taskType: "local_workflow",
|
||||
description: "Build generated assets",
|
||||
});
|
||||
expect(runtime.canStop("task-1")).toBe(true);
|
||||
expect(runtime.claimStop("task-1")).toBe(true);
|
||||
expect(runtime.claimStop("task-1")).toBe(false);
|
||||
|
||||
await runtime.taskStopped("task-1");
|
||||
await runtime.taskStopped("task-1");
|
||||
|
||||
expect(runtime.canStop("task-1")).toBe(false);
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
"agent_message_chunk",
|
||||
]);
|
||||
expect(published[1]?.update).toMatchObject({
|
||||
asyncTaskId: "task-1",
|
||||
state: "stopped",
|
||||
});
|
||||
// The panel drops a stopped task immediately; a summary there is unread.
|
||||
expect(published[1]?.update).not.toHaveProperty("summary");
|
||||
expect(published.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "**Task stopped by user:** Build generated assets." },
|
||||
});
|
||||
});
|
||||
|
||||
it("still announces a stop whose SDK notification landed first", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
taskId: "task-1",
|
||||
taskType: "local_bash",
|
||||
description: "npm run build",
|
||||
isBackgrounded: true,
|
||||
});
|
||||
expect(runtime.claimStop("task-1")).toBe(true);
|
||||
// The SDK kills the process and reports it before `stopTask` resolves, so
|
||||
// the task is already terminal by the time the stop path resumes. The
|
||||
// acknowledgement is still owed to the user who clicked.
|
||||
await runtime.taskNotification({ taskId: "task-1", status: "killed" });
|
||||
await runtime.taskStopped("task-1");
|
||||
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
"agent_message_chunk",
|
||||
]);
|
||||
expect(published.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "**Task stopped by user:** npm run build." },
|
||||
});
|
||||
});
|
||||
|
||||
it("announces a stop for a panel-only task too", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
// A background Bash task is panel-only because its tool call already draws
|
||||
// it in the transcript. That must not swallow the stop acknowledgement --
|
||||
// it is the only signal the user gets that their click landed.
|
||||
await runtime.taskStarted({
|
||||
taskId: "task-1",
|
||||
taskType: "local_bash",
|
||||
description: "npm run build",
|
||||
isBackgrounded: true,
|
||||
skipTranscript: true,
|
||||
});
|
||||
expect(runtime.claimStop("task-1")).toBe(true);
|
||||
await runtime.taskStopped("task-1");
|
||||
|
||||
expect(published[0]?.update).toMatchObject({
|
||||
sessionUpdate: "async_task_spawned",
|
||||
showInTranscript: false,
|
||||
});
|
||||
expect(published.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "**Task stopped by user:** npm run build." },
|
||||
});
|
||||
});
|
||||
|
||||
it("correlates a background Bash result inside a batched structured message", () => {
|
||||
const toolUseResult = { backgroundTaskId: "task-1" };
|
||||
const tools = {
|
||||
read: { name: "Read", input: { file_path: "package.json" } },
|
||||
bash: { name: "Bash", input: { command: "npm run build" } },
|
||||
};
|
||||
|
||||
expect(
|
||||
backgroundBashTaskFromToolResult(
|
||||
[
|
||||
{ type: "tool_result", tool_use_id: "read", content: "package" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "bash",
|
||||
content: [
|
||||
{ type: "text", text: "Command running. Output is being written to: " },
|
||||
{
|
||||
type: "text",
|
||||
text: "/private/tmp/claude/tasks/task-1.output. You will be notified when done.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolUseResult,
|
||||
tools,
|
||||
),
|
||||
).toMatchObject({
|
||||
taskId: "task-1",
|
||||
toolCallId: "bash",
|
||||
description: "npm run build",
|
||||
outputFilePath: "/private/tmp/claude/tasks/task-1.output",
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers a background Bash task when the structured result omits its id", () => {
|
||||
const tools = {
|
||||
bash: { name: "Bash", input: { command: "npm run build" } },
|
||||
};
|
||||
|
||||
expect(
|
||||
backgroundBashTaskFromToolResult(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "bash",
|
||||
content:
|
||||
"Command running in background with ID: task-1. Output is being written to: " +
|
||||
"/private/tmp/claude/tasks/task-1.output. You will be notified when done.",
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
tools,
|
||||
),
|
||||
).toMatchObject({
|
||||
taskId: "task-1",
|
||||
toolCallId: "bash",
|
||||
description: "npm run build",
|
||||
outputFilePath: "/private/tmp/claude/tasks/task-1.output",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer a background task without an unambiguous Bash result", () => {
|
||||
const toolUseResult = { backgroundTaskId: "task-1" };
|
||||
const bash = { bash: { name: "Bash", input: { command: "npm run build" } } };
|
||||
|
||||
expect(backgroundBashTaskFromToolResult([], toolUseResult, bash)).toBeUndefined();
|
||||
expect(
|
||||
backgroundBashTaskFromToolResult(
|
||||
[
|
||||
{ type: "tool_result", tool_use_id: "bash-1" },
|
||||
{ type: "tool_result", tool_use_id: "bash-2" },
|
||||
],
|
||||
toolUseResult,
|
||||
{
|
||||
"bash-1": { name: "Bash", input: { command: "first" } },
|
||||
"bash-2": { name: "Bash", input: { command: "second" } },
|
||||
},
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
backgroundBashTaskFromToolResult(
|
||||
[{ type: "tool_result", tool_use_id: "read" }],
|
||||
toolUseResult,
|
||||
{ read: { name: "Read", input: {} } },
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
backgroundBashTaskFromToolResult(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "bash",
|
||||
content:
|
||||
"Output is being written to: /private/tmp/claude/tasks/not-task-1.output. You will be notified",
|
||||
},
|
||||
],
|
||||
toolUseResult,
|
||||
bash,
|
||||
)?.outputFilePath,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
backgroundBashTaskFromToolResult(
|
||||
[{ type: "tool_result", tool_use_id: "bash" }],
|
||||
[{ backgroundTaskId: "task-1" }, { backgroundTaskId: "task-2" }],
|
||||
bash,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses a structured result tool id to disambiguate batched Bash results", () => {
|
||||
expect(
|
||||
backgroundBashTaskFromToolResult(
|
||||
[
|
||||
{ type: "tool_result", tool_use_id: "bash-1", content: "first" },
|
||||
{ type: "tool_result", tool_use_id: "bash-2", content: "second" },
|
||||
],
|
||||
{ tool_use_id: "bash-2", background_task_id: "task-2" },
|
||||
{
|
||||
"bash-1": { name: "Bash", input: { command: "first" } },
|
||||
"bash-2": { name: "Bash", input: { command: "second" } },
|
||||
},
|
||||
),
|
||||
).toMatchObject({ taskId: "task-2", toolCallId: "bash-2", description: "second" });
|
||||
|
||||
expect(
|
||||
backgroundBashTaskFromToolResult(
|
||||
[{ type: "tool_result", tool_use_id: "bash-1", content: "first" }],
|
||||
{ tool_use_id: "read", background_task_id: "task-2" },
|
||||
{
|
||||
"bash-1": { name: "Bash", input: { command: "first" } },
|
||||
read: { name: "Read", input: {} },
|
||||
},
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects the negotiated AIR capability", () => {
|
||||
expect(
|
||||
clientSupportsAsyncTasks({
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(clientSupportsAsyncTasks({})).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes one durable lifecycle with progress and a terminal state", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
taskId: "task-1",
|
||||
taskType: "local_workflow",
|
||||
description: "Build generated assets",
|
||||
workflowName: "assets",
|
||||
});
|
||||
await runtime.taskProgress({
|
||||
taskId: "task-1",
|
||||
summary: "Generated 3 files",
|
||||
lastToolName: "Write",
|
||||
usage: { total_tokens: 12, tool_uses: 3, duration_ms: 500 },
|
||||
});
|
||||
await runtime.taskUpdated("task-1", { status: "paused" });
|
||||
await runtime.taskUpdated("task-1", { status: "running" });
|
||||
await runtime.taskNotification("task-1", "completed", "Done");
|
||||
await runtime.taskProgress({ taskId: "task-1", summary: "late" });
|
||||
await runtime.taskNotification("task-1", "failed", "duplicate terminal");
|
||||
|
||||
expect(published.map((notification) => notification.update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_progress",
|
||||
"async_task_state_update",
|
||||
"async_task_state_update",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
expect(published[0]?.update).toMatchObject({
|
||||
asyncTaskId: "task-1",
|
||||
name: "assets",
|
||||
taskType: "workflow",
|
||||
showInTranscript: true,
|
||||
});
|
||||
expect(published[1]?.update).toMatchObject({
|
||||
summary: "Generated 3 files",
|
||||
usage: { totalTokens: 12, toolUses: 3, durationMs: 500 },
|
||||
});
|
||||
expect(published.at(-1)?.update).toMatchObject({ state: "completed", summary: "Done" });
|
||||
});
|
||||
|
||||
it("waits until foreground shell work is backgrounded and excludes subagents", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
taskId: "foreground-shell",
|
||||
taskType: "local_bash",
|
||||
description: "Read one file",
|
||||
});
|
||||
await runtime.taskNotification("foreground-shell", "completed", "Done");
|
||||
await runtime.taskStarted({
|
||||
taskId: "shell",
|
||||
taskType: "local_bash",
|
||||
description: "Run tests",
|
||||
});
|
||||
await runtime.taskStarted({
|
||||
taskId: "agent",
|
||||
taskType: "local_agent",
|
||||
description: "Research",
|
||||
subagentType: "Explore",
|
||||
});
|
||||
await runtime.taskStarted({
|
||||
task_id: "agent-without-subtype",
|
||||
task_type: "local_agent",
|
||||
description: "Research",
|
||||
is_backgrounded: true,
|
||||
});
|
||||
expect(published).toEqual([]);
|
||||
|
||||
await runtime.taskUpdated("shell", { isBackgrounded: true });
|
||||
expect(published).toHaveLength(1);
|
||||
expect(published[0]?.update).toMatchObject({ asyncTaskId: "shell", taskType: "shell" });
|
||||
});
|
||||
|
||||
it("normalizes snake_case SDK task events and propagates a late output file", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
task_id: "shell",
|
||||
task_type: "local_bash",
|
||||
description: "Run build",
|
||||
is_backgrounded: true,
|
||||
tool_use_id: "bash-tool",
|
||||
});
|
||||
await runtime.taskProgress({
|
||||
task_id: "shell",
|
||||
last_tool_name: "Bash",
|
||||
usage: { totalTokens: 2, toolUses: 1, durationMs: 50 },
|
||||
});
|
||||
await runtime.taskNotification({
|
||||
task_id: "shell",
|
||||
status: "completed",
|
||||
summary: "Done",
|
||||
output_file: "/tmp/tasks/shell.output",
|
||||
});
|
||||
|
||||
expect(published[0]?.update).toMatchObject({
|
||||
asyncTaskId: "shell",
|
||||
taskType: "shell",
|
||||
toolCallId: "bash-tool",
|
||||
});
|
||||
expect(published[1]?.update).toMatchObject({
|
||||
lastToolName: "Bash",
|
||||
usage: { totalTokens: 2, toolUses: 1, durationMs: 50 },
|
||||
});
|
||||
expect(published[2]?.update).toMatchObject({
|
||||
state: "completed",
|
||||
summary: "Done",
|
||||
outputFilePath: "/tmp/tasks/shell.output",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a terminal tombstone until a late Bash result proves the task was backgrounded", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskNotification({
|
||||
task_id: "fast-shell",
|
||||
status: "completed",
|
||||
summary: "Already done",
|
||||
output_file: "/tmp/tasks/fast-shell.output",
|
||||
});
|
||||
expect(published).toEqual([]);
|
||||
|
||||
await runtime.taskBackgrounded({
|
||||
taskId: "fast-shell",
|
||||
taskType: "local_bash",
|
||||
description: "Fast build",
|
||||
isBackgrounded: true,
|
||||
toolCallId: "bash-tool",
|
||||
});
|
||||
await runtime.taskUpdated("fast-shell", { status: "running" });
|
||||
|
||||
expect(published.map((notification) => notification.update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
expect(published[0]?.update).toMatchObject({
|
||||
description: "Fast build",
|
||||
outputFilePath: "/tmp/tasks/fast-shell.output",
|
||||
});
|
||||
expect(published[1]?.update).toMatchObject({
|
||||
state: "completed",
|
||||
summary: "Already done",
|
||||
outputFilePath: "/tmp/tasks/fast-shell.output",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains a terminal task_updated tombstone until background promotion", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskUpdated({
|
||||
task_id: "fast-shell",
|
||||
patch: { status: "failed", error: "boom" },
|
||||
});
|
||||
await runtime.taskBackgrounded({
|
||||
task_id: "fast-shell",
|
||||
task_type: "local_bash",
|
||||
description: "Fast build",
|
||||
is_backgrounded: true,
|
||||
});
|
||||
|
||||
expect(published.map((notification) => notification.update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
expect(published[1]?.update).toMatchObject({ state: "failed", summary: "boom" });
|
||||
});
|
||||
|
||||
it("publishes a mutable output path after an already announced task", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
taskId: "shell",
|
||||
taskType: "local_bash",
|
||||
description: "Build",
|
||||
isBackgrounded: true,
|
||||
});
|
||||
await runtime.taskUpdated("shell", { output_file: "/tmp/tasks/one.output" });
|
||||
await runtime.taskUpdated("shell", { outputFilePath: "/tmp/tasks/two.output" });
|
||||
|
||||
expect(published.slice(1).map((notification) => notification.update)).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "async_task_progress",
|
||||
outputFilePath: "/tmp/tasks/one.output",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "async_task_progress",
|
||||
outputFilePath: "/tmp/tasks/two.output",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes a tool id discovered after spawn", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
task_id: "shell",
|
||||
task_type: "local_bash",
|
||||
description: "Build",
|
||||
is_backgrounded: true,
|
||||
});
|
||||
await runtime.taskBackgrounded({
|
||||
task_id: "shell",
|
||||
task_type: "local_bash",
|
||||
description: "Build",
|
||||
is_backgrounded: true,
|
||||
tool_use_id: "bash-tool",
|
||||
});
|
||||
|
||||
expect(published[0]?.update).not.toHaveProperty("toolCallId");
|
||||
expect(published[1]?.update).toMatchObject({
|
||||
sessionUpdate: "async_task_progress",
|
||||
asyncTaskId: "shell",
|
||||
toolCallId: "bash-tool",
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles foreground promotion and a lost terminal edge from the live task level", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
task_id: "shell",
|
||||
task_type: "local_bash",
|
||||
description: "Build",
|
||||
is_backgrounded: false,
|
||||
});
|
||||
await runtime.backgroundTasksChanged({
|
||||
tasks: [{ task_id: "shell", task_type: "local_bash", description: "Build" }],
|
||||
});
|
||||
await runtime.backgroundTasksChanged([]);
|
||||
|
||||
expect(published.map((notification) => notification.update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
expect(published[1]?.update).toMatchObject({ state: "stopped" });
|
||||
|
||||
// The level is deliberately best-effort: an authoritative edge that was
|
||||
// merely ordered after it may correct the terminal state.
|
||||
await runtime.taskNotification("shell", "completed", "Done");
|
||||
expect(published[2]?.update).toMatchObject({ state: "completed", summary: "Done" });
|
||||
});
|
||||
|
||||
it("lets the terminal edge win when the live level is ordered before it", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
task_id: "shell",
|
||||
task_type: "local_bash",
|
||||
description: "Build",
|
||||
is_backgrounded: true,
|
||||
});
|
||||
await runtime.backgroundTasksChanged([]);
|
||||
await runtime.taskNotification("shell", "completed", "Done");
|
||||
|
||||
expect(published.map((notification) => notification.update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
expect(published[1]?.update).toMatchObject({ state: "stopped" });
|
||||
expect(published[2]?.update).toMatchObject({ state: "completed", summary: "Done" });
|
||||
});
|
||||
|
||||
it("heals a lone lost terminal edge at the replace-level boundary", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.taskStarted({
|
||||
task_id: "shell",
|
||||
task_type: "local_bash",
|
||||
description: "Build",
|
||||
is_backgrounded: true,
|
||||
});
|
||||
await runtime.backgroundTasksChanged([]);
|
||||
|
||||
expect(published.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: "shell",
|
||||
state: "stopped",
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers a live task whose task_started edge was lost", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.backgroundTasksChanged([
|
||||
{ task_id: "lost-start", task_type: "local_bash", description: "Build" },
|
||||
]);
|
||||
|
||||
expect(published[0]?.update).toMatchObject({
|
||||
sessionUpdate: "async_task_spawned",
|
||||
asyncTaskId: "lost-start",
|
||||
taskType: "shell",
|
||||
description: "Build",
|
||||
showInTranscript: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps level-only recovery panel-only when task_started arrives late", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
published.push(notification);
|
||||
});
|
||||
|
||||
await runtime.backgroundTasksChanged([
|
||||
{ task_id: "lost-start", task_type: "local_workflow", description: "Build assets" },
|
||||
]);
|
||||
await runtime.taskStarted({
|
||||
task_id: "lost-start",
|
||||
task_type: "local_workflow",
|
||||
workflow_name: "assets",
|
||||
description: "Build generated assets",
|
||||
skip_transcript: true,
|
||||
is_backgrounded: true,
|
||||
});
|
||||
|
||||
expect(published).toHaveLength(1);
|
||||
expect(published[0]?.update).toMatchObject({
|
||||
sessionUpdate: "async_task_spawned",
|
||||
asyncTaskId: "lost-start",
|
||||
name: "Build assets",
|
||||
showInTranscript: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("finishes remaining tasks and can retry a task whose terminal publication failed", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
let failFirstTask = true;
|
||||
const runtime = new AsyncTaskRuntime(true, "session", async (notification) => {
|
||||
if (
|
||||
failFirstTask &&
|
||||
notification.update.sessionUpdate === "async_task_state_update" &&
|
||||
notification.update.asyncTaskId === "first"
|
||||
) {
|
||||
failFirstTask = false;
|
||||
throw new Error("client disconnected");
|
||||
}
|
||||
published.push(notification);
|
||||
});
|
||||
for (const taskId of ["first", "second"]) {
|
||||
await runtime.taskStarted({
|
||||
task_id: taskId,
|
||||
task_type: "local_bash",
|
||||
description: taskId,
|
||||
is_backgrounded: true,
|
||||
});
|
||||
}
|
||||
|
||||
await expect(runtime.finishAll("failed")).rejects.toThrow("client disconnected");
|
||||
expect(published).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: "second",
|
||||
state: "failed",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(runtime.finishAll("failed")).resolves.toBeUndefined();
|
||||
const terminalIds = published.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "async_task_state_update" ? [update.asyncTaskId] : [],
|
||||
);
|
||||
expect(terminalIds).toEqual(["second", "first"]);
|
||||
});
|
||||
});
|
||||
947
src/tests/auth-status.test.ts
Normal file
947
src/tests/auth-status.test.ts
Normal file
@@ -0,0 +1,947 @@
|
||||
import type { Mock } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType, Logger } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { AuthStatus } from "../auth-status.js";
|
||||
import { fromAccountInfo, fromCliStatus, mergeAuthStatus, sameIdentity } from "../auth-status.js";
|
||||
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const mockQuery = vi.hoisted(() => vi.fn());
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
/** Shared spy for both `claude auth status --json` and `claude auth logout`.
|
||||
* Tests set `execFileResults` per invoked subcommand. */
|
||||
const execFileSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return { ...actual, execFile: execFileSpy };
|
||||
});
|
||||
|
||||
/** CLI outputs verified against the real binary (see the extension spec). */
|
||||
const CLI_SUBSCRIPTION = JSON.stringify({
|
||||
loggedIn: true,
|
||||
authMethod: "claude.ai",
|
||||
apiProvider: "firstParty",
|
||||
email: "user@example.com",
|
||||
orgId: "org-1",
|
||||
orgName: "ACME",
|
||||
subscriptionType: "max",
|
||||
});
|
||||
const CLI_API_KEY = JSON.stringify({
|
||||
loggedIn: true,
|
||||
authMethod: "none",
|
||||
apiProvider: "firstParty",
|
||||
apiKeySource: "apiKeyHelper",
|
||||
});
|
||||
/** An `apiKeyHelper` in settings.json next to a stored claude.ai login: the CLI
|
||||
* reports both identities at once, and the helper key is what actually bills. */
|
||||
const CLI_KEY_HELPER_AND_SUBSCRIPTION = JSON.stringify({
|
||||
loggedIn: true,
|
||||
authMethod: "claude.ai",
|
||||
apiProvider: "firstParty",
|
||||
apiKeySource: "apiKeyHelper",
|
||||
email: "user@example.com",
|
||||
orgName: "ACME",
|
||||
subscriptionType: "max",
|
||||
});
|
||||
const CLI_LOGGED_OUT = JSON.stringify({
|
||||
loggedIn: false,
|
||||
authMethod: "none",
|
||||
apiProvider: "firstParty",
|
||||
});
|
||||
/** The same login as CLI_SUBSCRIPTION, read from a source that omits the org. */
|
||||
const CLI_SUBSCRIPTION_NO_ORG = JSON.stringify({
|
||||
loggedIn: true,
|
||||
authMethod: "claude.ai",
|
||||
apiProvider: "firstParty",
|
||||
email: "user@example.com",
|
||||
subscriptionType: "max",
|
||||
});
|
||||
/** A different person logged in since the last read. */
|
||||
const CLI_OTHER_ACCOUNT = JSON.stringify({
|
||||
loggedIn: true,
|
||||
authMethod: "claude.ai",
|
||||
apiProvider: "firstParty",
|
||||
email: "other@example.com",
|
||||
subscriptionType: "pro",
|
||||
});
|
||||
/** A 3P backend: no claude.ai login, yet the agent is authenticated through
|
||||
* credentials it does not own (AWS). `loggedIn` is false here. */
|
||||
const CLI_BEDROCK = JSON.stringify({
|
||||
loggedIn: false,
|
||||
authMethod: "none",
|
||||
apiProvider: "bedrock",
|
||||
});
|
||||
|
||||
const MOCK_MODELS = [
|
||||
{ value: "id", displayName: "name", description: "description", supportsAutoMode: true },
|
||||
];
|
||||
|
||||
/** One scripted SDK query: it echoes each pushed user message and answers it
|
||||
* with a successful result, so a real `newSession` + `prompt` round trip runs
|
||||
* against it and the turn-boundary probes fire. */
|
||||
function scriptedTurnQuery(args: any, account: Record<string, unknown>) {
|
||||
const input = args.prompt;
|
||||
async function* messages() {
|
||||
const iterator = input[Symbol.asyncIterator]();
|
||||
for (;;) {
|
||||
const { value, done } = await iterator.next();
|
||||
if (done || !value) return;
|
||||
yield {
|
||||
type: "user",
|
||||
message: value.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: value.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "done",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
}
|
||||
const query: any = messages();
|
||||
query.initializationResult = async () => ({ models: MOCK_MODELS, account });
|
||||
query.accountInfo = async () => account;
|
||||
query.setModel = async () => {};
|
||||
query.setPermissionMode = async () => {};
|
||||
query.supportedCommands = async () => [];
|
||||
query.mcpServerStatus = async () => [];
|
||||
query.getContextUsage = async () => DEFAULT_CONTEXT_USAGE;
|
||||
query.close = vi.fn();
|
||||
query.interrupt = vi.fn(async () => {});
|
||||
query.stopTask = vi.fn(async () => {});
|
||||
return query;
|
||||
}
|
||||
|
||||
describe("auth status mappers", () => {
|
||||
it("maps a first-party subscription account", () => {
|
||||
expect(
|
||||
fromAccountInfo({
|
||||
apiProvider: "firstParty",
|
||||
subscriptionType: "max",
|
||||
email: "user@example.com",
|
||||
organization: "ACME",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "account",
|
||||
label: "Claude Max",
|
||||
account: { plan: "max", email: "user@example.com", organization: "ACME" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["max", "Claude Max"],
|
||||
["Claude Max", "Claude Max"],
|
||||
["claude max", "Claude Max"],
|
||||
["Team Premium", "Claude Team Premium"],
|
||||
])("names the plan %j once, as %j", (subscriptionType, label) => {
|
||||
// Regression: a newer CLI reports the plan already prefixed, and the label
|
||||
// read "Claude Claude Max". The plan itself stays the raw vendor string.
|
||||
expect(fromAccountInfo({ apiProvider: "firstParty", subscriptionType })).toEqual({
|
||||
kind: "account",
|
||||
label,
|
||||
account: { plan: subscriptionType },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps an API key source", () => {
|
||||
expect(
|
||||
fromAccountInfo({ apiProvider: "firstParty", apiKeySource: "ANTHROPIC_API_KEY" }),
|
||||
).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "ANTHROPIC_API_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the API key over a stored subscription when both are reported", () => {
|
||||
// Claude Code's authentication precedence ranks an apiKeyHelper key above
|
||||
// the `/login` subscription, so the key is the identity that pays.
|
||||
expect(fromCliStatus(CLI_KEY_HELPER_AND_SUBSCRIPTION)).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
expect(
|
||||
fromAccountInfo({
|
||||
apiProvider: "firstParty",
|
||||
apiKeySource: "apiKeyHelper",
|
||||
subscriptionType: "max",
|
||||
email: "user@example.com",
|
||||
organization: "ACME",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps third-party backends to external auth", () => {
|
||||
expect(fromAccountInfo({ apiProvider: "bedrock" })).toEqual({
|
||||
kind: "external",
|
||||
label: "AWS Bedrock",
|
||||
});
|
||||
expect(fromAccountInfo({ apiProvider: "vertex" })).toEqual({
|
||||
kind: "external",
|
||||
label: "Google Vertex AI",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the gateway backend", () => {
|
||||
expect(fromAccountInfo({ apiProvider: "gateway" })).toEqual({
|
||||
kind: "gateway",
|
||||
label: "Custom model gateway",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an account with no identity signal as no information", () => {
|
||||
// The SDK always fills apiProvider; under an apiKeyHelper that is all a
|
||||
// session account carries. Mapping it to `none` would claim "logged out".
|
||||
expect(fromAccountInfo({ apiProvider: "firstParty" })).toBeUndefined();
|
||||
expect(fromAccountInfo({})).toBeUndefined();
|
||||
expect(fromAccountInfo(undefined)).toBeUndefined();
|
||||
// `tokenSource` names the OAuth token's origin, never a key source.
|
||||
expect(
|
||||
fromAccountInfo({ apiProvider: "firstParty", tokenSource: "CLAUDE_CODE_OAUTH_TOKEN" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps CLI probe output", () => {
|
||||
expect(fromCliStatus(CLI_SUBSCRIPTION)).toEqual({
|
||||
kind: "account",
|
||||
label: "Claude Max",
|
||||
account: { plan: "max", email: "user@example.com", organization: "ACME" },
|
||||
});
|
||||
expect(fromCliStatus(CLI_API_KEY)).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
expect(fromCliStatus(CLI_LOGGED_OUT)).toEqual({
|
||||
kind: "none",
|
||||
label: "Not logged in",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a third-party backend outrank the logged-out flag", () => {
|
||||
// `loggedIn` tracks the claude.ai login only; on Bedrock the credentials
|
||||
// are external and the agent is authenticated all the same.
|
||||
expect(fromCliStatus(CLI_BEDROCK)).toEqual({ kind: "external", label: "AWS Bedrock" });
|
||||
});
|
||||
|
||||
it("reports unparseable CLI output as not known", () => {
|
||||
expect(fromCliStatus("")).toBeUndefined();
|
||||
expect(fromCliStatus("Usage: claude auth status")).toBeUndefined();
|
||||
expect(fromCliStatus("[]")).toBeUndefined();
|
||||
expect(fromCliStatus('{"foo": 1}')).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges a poorer read into the same identity and replaces a different one", () => {
|
||||
const stored = {
|
||||
kind: "account" as const,
|
||||
label: "Claude Max",
|
||||
account: { plan: "max", email: "user@example.com", organization: "ACME" },
|
||||
};
|
||||
// Same login, read from a source without the organization: it survives.
|
||||
expect(
|
||||
mergeAuthStatus(stored, {
|
||||
kind: "account",
|
||||
label: "Claude Max",
|
||||
account: { plan: "max", email: "user@example.com" },
|
||||
}),
|
||||
).toEqual(stored);
|
||||
// A different login replaces everything, organization included.
|
||||
expect(
|
||||
mergeAuthStatus(stored, {
|
||||
kind: "account",
|
||||
label: "Claude Pro",
|
||||
account: { plan: "pro", email: "other@example.com" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "account",
|
||||
label: "Claude Pro",
|
||||
account: { plan: "pro", email: "other@example.com" },
|
||||
});
|
||||
// A different kind never merges, even when it looks adjacent.
|
||||
expect(
|
||||
mergeAuthStatus(stored, { kind: "api_key", label: "Anthropic API key", detail: "env" }),
|
||||
).toEqual({ kind: "api_key", label: "Anthropic API key", detail: "env" });
|
||||
// Key sources identify api_key payloads.
|
||||
expect(
|
||||
sameIdentity(
|
||||
{ kind: "api_key", label: "Anthropic API key", detail: "apiKeyHelper" },
|
||||
{ kind: "api_key", label: "Anthropic API key", detail: "env" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth status over ACP", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let extNotification: ReturnType<typeof vi.fn>;
|
||||
/** stdout the fake `claude auth status --json` prints, and whether the exec
|
||||
* fails (the logged-out case exits 1 with JSON still on stdout). */
|
||||
let statusStdout: string;
|
||||
let statusFails: boolean;
|
||||
/** While true, probes hang until `flushStatus()` releases them, so a test can
|
||||
* interleave an `authenticate`/`logout` with a probe that is still running.
|
||||
* Each probe captures the output configured when it was spawned. */
|
||||
let deferStatus: boolean;
|
||||
let pendingStatus: Array<() => void>;
|
||||
|
||||
/** Let every already-scheduled promise chain run to completion. */
|
||||
function settle() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/** Release one pending probe by spawn order, so a test can decide which of
|
||||
* two in-flight reads lands first. */
|
||||
async function releaseStatus(index: number) {
|
||||
const [release] = pendingStatus.splice(index, 1);
|
||||
release();
|
||||
await settle();
|
||||
}
|
||||
|
||||
/** Release the probes spawned so far and let their promises settle, repeating
|
||||
* so a probe spawned by that settling (e.g. logout's fresh read) is released
|
||||
* too. */
|
||||
async function flushStatus(rounds = 3) {
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
const pending = pendingStatus;
|
||||
pendingStatus = [];
|
||||
for (const release of pending) release();
|
||||
await settle();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Skip native-binary resolution; the exec itself is mocked.
|
||||
process.env.CLAUDE_CODE_EXECUTABLE = "claude";
|
||||
statusStdout = CLI_SUBSCRIPTION;
|
||||
statusFails = false;
|
||||
deferStatus = false;
|
||||
pendingStatus = [];
|
||||
// The probe passes an options object and `logout` does not, so the
|
||||
// callback is read off the end rather than from a fixed position.
|
||||
execFileSpy.mockImplementation((...invocation: unknown[]) => {
|
||||
const args = invocation[1] as string[];
|
||||
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
|
||||
if (args[1] === "status") {
|
||||
const stdout = statusStdout;
|
||||
const fails = statusFails;
|
||||
const answer = () => {
|
||||
if (fails) {
|
||||
cb(Object.assign(new Error("exit 1"), { stdout, stderr: "" }));
|
||||
} else {
|
||||
cb(null, { stdout, stderr: "" });
|
||||
}
|
||||
};
|
||||
if (deferStatus) {
|
||||
pendingStatus.push(answer);
|
||||
} else {
|
||||
answer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
cb(null, { stdout: "", stderr: "" });
|
||||
});
|
||||
mockQuery.mockImplementation(() => makeMockQuery());
|
||||
extNotification = vi.fn().mockResolvedValue(undefined);
|
||||
agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: async () => {},
|
||||
extNotification,
|
||||
} as unknown as AcpClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_EXECUTABLE;
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function statusCalls() {
|
||||
return execFileSpy.mock.calls.filter((call) => (call[1] as string[])[1] === "status");
|
||||
}
|
||||
|
||||
function updates() {
|
||||
return extNotification.mock.calls.filter((call) => call[0] === "_auth/status_update");
|
||||
}
|
||||
|
||||
/** The last payload pushed, which is also what the connection now holds. */
|
||||
function lastPush() {
|
||||
return (updates().at(-1)?.[1] as { authStatus: AuthStatus } | undefined)?.authStatus;
|
||||
}
|
||||
|
||||
/** `initialize` is where the connection learns its identity. It fires the
|
||||
* probe and never waits for it, so let the push land before asserting. */
|
||||
async function initialize() {
|
||||
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await settle();
|
||||
return response;
|
||||
}
|
||||
|
||||
/** A session whose scripted query answers every prompt, so a real `prompt()`
|
||||
* round trip runs and its start-of-prompt probe fires. */
|
||||
async function scriptedSession(account: Record<string, unknown>) {
|
||||
mockQuery.mockImplementation((args: any) => scriptedTurnQuery(args, account));
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function promptOnce(sessionId: string) {
|
||||
const response = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: "text" as const, text: "hi" }],
|
||||
});
|
||||
await settle();
|
||||
return response;
|
||||
}
|
||||
|
||||
it("pushes the logged-out CLI verdict even though the probe exits non-zero", async () => {
|
||||
statusStdout = CLI_LOGGED_OUT;
|
||||
statusFails = true;
|
||||
await initialize();
|
||||
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
|
||||
});
|
||||
|
||||
it("pushes nothing when the probe cannot be read", async () => {
|
||||
statusStdout = "";
|
||||
statusFails = true;
|
||||
await initialize();
|
||||
// "Cannot determine" is silence: no push at all, and the client keeps
|
||||
// showing "not reported". A known logged-out state would push a payload
|
||||
// of kind "none" instead.
|
||||
expect(updates()).toHaveLength(0);
|
||||
expect(agent.currentAuthStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("advertises the extension in agentCapabilities._meta", async () => {
|
||||
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
// Presence is the whole advertisement — "this agent pushes its identity".
|
||||
// The marker stays empty, and a status payload there would violate the
|
||||
// contract.
|
||||
expect(response.agentCapabilities?._meta?.authStatus).toEqual({});
|
||||
// The marker lives in agentCapabilities only — never at the top level.
|
||||
expect(response._meta?.authStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pushes the first identity after the initialize response, never before it", async () => {
|
||||
// The client registers its receiver around `initialize`; a push that
|
||||
// overtook the response could be dropped. The probe is asynchronous, so
|
||||
// the response always wins the race.
|
||||
const order: string[] = [];
|
||||
extNotification.mockImplementation(async (method: string) => {
|
||||
order.push(method);
|
||||
});
|
||||
|
||||
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
order.push("initialize:returned");
|
||||
await vi.waitFor(() => expect(updates()).toHaveLength(1));
|
||||
|
||||
expect(order).toEqual(["initialize:returned", "_auth/status_update"]);
|
||||
// No snapshot rides in the initialize response — only the notification.
|
||||
expect(response._meta?.authStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shares one in-flight probe between initialize and a start-of-prompt read", async () => {
|
||||
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
|
||||
deferStatus = true;
|
||||
|
||||
const init = agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
const turn = agent.prompt({ sessionId, prompt: [{ type: "text", text: "hi" }] });
|
||||
await settle();
|
||||
// Two readers, one CLI process.
|
||||
expect(statusCalls()).toHaveLength(1);
|
||||
|
||||
await flushStatus();
|
||||
await Promise.all([init, turn]);
|
||||
expect(lastPush()?.kind).toBe("account");
|
||||
});
|
||||
|
||||
it("re-probes on every prompt, so an external logout is seen", async () => {
|
||||
// The start of a prompt is the connection's "re-check now": a logout
|
||||
// performed in another terminal shows up without a session create.
|
||||
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
|
||||
await promptOnce(sessionId);
|
||||
expect(lastPush()).toEqual({
|
||||
kind: "account",
|
||||
label: "Claude Max",
|
||||
account: { plan: "max", email: "user@example.com", organization: "ACME" },
|
||||
});
|
||||
|
||||
statusStdout = CLI_LOGGED_OUT;
|
||||
statusFails = true;
|
||||
await promptOnce(sessionId);
|
||||
|
||||
expect(statusCalls()).toHaveLength(2);
|
||||
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
|
||||
});
|
||||
|
||||
it("keeps richer stored fields when a poorer probe reports the same identity", async () => {
|
||||
// The session AccountInfo knows the organization; the CLI probe here does
|
||||
// not. Re-reading the same login must not regress the payload.
|
||||
const sessionId = await scriptedSession({
|
||||
apiProvider: "firstParty",
|
||||
subscriptionType: "max",
|
||||
email: "user@example.com",
|
||||
organization: "ACME",
|
||||
});
|
||||
|
||||
statusStdout = CLI_SUBSCRIPTION_NO_ORG;
|
||||
await promptOnce(sessionId);
|
||||
|
||||
expect(agent.currentAuthStatus).toEqual({
|
||||
kind: "account",
|
||||
label: "Claude Max",
|
||||
account: { plan: "max", email: "user@example.com", organization: "ACME" },
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a probe of a different identity replace the stored one wholesale", async () => {
|
||||
const sessionId = await scriptedSession({
|
||||
apiProvider: "firstParty",
|
||||
subscriptionType: "max",
|
||||
email: "user@example.com",
|
||||
organization: "ACME",
|
||||
});
|
||||
|
||||
// Someone logged in as another user in a terminal: no field survives.
|
||||
statusStdout = CLI_OTHER_ACCOUNT;
|
||||
await promptOnce(sessionId);
|
||||
|
||||
expect(lastPush()).toEqual({
|
||||
kind: "account",
|
||||
label: "Claude Pro",
|
||||
account: { plan: "pro", email: "other@example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("pushes _auth/status_update when the initialize probe resolves", async () => {
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await vi.waitFor(() => expect(updates()).toHaveLength(1));
|
||||
expect(updates()).toEqual([
|
||||
[
|
||||
"_auth/status_update",
|
||||
{
|
||||
authStatus: {
|
||||
kind: "account",
|
||||
label: "Claude Max",
|
||||
account: { plan: "max", email: "user@example.com", organization: "ACME" },
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("upgrades the identity from AccountInfo on session create", async () => {
|
||||
statusStdout = CLI_API_KEY;
|
||||
await initialize();
|
||||
mockQuery.mockImplementation(() =>
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: MOCK_MODELS,
|
||||
account: { apiProvider: "firstParty", subscriptionType: "pro", email: "u@x.com" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(updates().map((call) => call[1])).toEqual([
|
||||
{
|
||||
authStatus: {
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "apiKeyHelper",
|
||||
},
|
||||
},
|
||||
{
|
||||
authStatus: {
|
||||
kind: "account",
|
||||
label: "Claude Pro",
|
||||
account: { plan: "pro", email: "u@x.com" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["only apiProvider", { apiProvider: "firstParty" }],
|
||||
["an empty object", {}],
|
||||
])("keeps the probed API key when the session account carries %s", async (_name, account) => {
|
||||
// Regression: an apiKeyHelper setup yields a truthy but empty account, and
|
||||
// mapping it to `none` used to overwrite the correct api_key status ~0.5 s
|
||||
// after connect.
|
||||
statusStdout = CLI_API_KEY;
|
||||
await initialize();
|
||||
expect(updates()).toHaveLength(1);
|
||||
|
||||
mockQuery.mockImplementation(() =>
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({ models: MOCK_MODELS, account }),
|
||||
}),
|
||||
);
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(updates()).toHaveLength(1);
|
||||
expect(agent.currentAuthStatus).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the session account while a client provider override is active", async () => {
|
||||
// `providers/set` routing is the client's, not the agent's login: the
|
||||
// session then reports `apiProvider: "gateway"`, which must stay invisible
|
||||
// to authStatus. The CLI probe keeps describing the agent-owned store.
|
||||
statusStdout = CLI_API_KEY;
|
||||
await initialize();
|
||||
expect(updates()).toHaveLength(1);
|
||||
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://client-gateway.example/v1",
|
||||
headers: {},
|
||||
});
|
||||
mockQuery.mockImplementation(() =>
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: MOCK_MODELS,
|
||||
account: { apiProvider: "gateway" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(updates()).toHaveLength(1);
|
||||
expect(agent.currentAuthStatus).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
});
|
||||
|
||||
it("still probes the agent's own login under a client provider override", async () => {
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://client-gateway.example/v1",
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await initialize();
|
||||
|
||||
expect(statusCalls()).toHaveLength(1);
|
||||
expect(updates()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("pushes an unchanged identity once, however often it is read", async () => {
|
||||
// The identity is read on many occasions and almost all of them see the
|
||||
// same login. A repeat tells the client nothing, so it is not sent.
|
||||
const authStatus = {
|
||||
kind: "account",
|
||||
label: "Claude Max",
|
||||
account: { plan: "max", email: "user@example.com" },
|
||||
};
|
||||
mockQuery.mockImplementation(() =>
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: MOCK_MODELS,
|
||||
account: {
|
||||
apiProvider: "firstParty",
|
||||
subscriptionType: "max",
|
||||
email: "user@example.com",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(updates().map((call) => call[1])).toEqual([{ authStatus }]);
|
||||
});
|
||||
|
||||
it("pushes again as soon as a field of the payload changes", async () => {
|
||||
let account: Record<string, unknown> = {
|
||||
apiProvider: "firstParty",
|
||||
subscriptionType: "max",
|
||||
email: "user@example.com",
|
||||
};
|
||||
mockQuery.mockImplementation(() =>
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({ models: MOCK_MODELS, account }),
|
||||
}),
|
||||
);
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
// Same person, upgraded plan: the label changes, so the client hears it.
|
||||
account = { ...account, subscriptionType: "enterprise" };
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(
|
||||
updates().map((call) => (call[1] as { authStatus: { label: string } }).authStatus.label),
|
||||
).toEqual(["Claude Max", "Claude Enterprise"]);
|
||||
});
|
||||
|
||||
it("reads the store once per user prompt, at the start", async () => {
|
||||
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
|
||||
// Session creation reads the account the SDK already has; it spawns no CLI.
|
||||
expect(statusCalls()).toHaveLength(0);
|
||||
|
||||
await expect(promptOnce(sessionId)).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
|
||||
// Exactly one process per user prompt, spawned before the turn runs so a
|
||||
// sign-in done between two prompts is seen by the prompt that follows it.
|
||||
expect(statusCalls()).toHaveLength(1);
|
||||
await promptOnce(sessionId);
|
||||
expect(statusCalls()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("never makes a prompt wait for the probe", async () => {
|
||||
// The read is fire-and-forget: a CLI that has not answered yet — or never
|
||||
// answers — must not hold the turn.
|
||||
const sessionId = await scriptedSession({ apiKeySource: "apiKeyHelper" });
|
||||
deferStatus = true;
|
||||
|
||||
await expect(promptOnce(sessionId)).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
expect(statusCalls()).toHaveLength(1);
|
||||
// Still pending when the turn is long over; its result lands afterwards.
|
||||
expect(pendingStatus).toHaveLength(1);
|
||||
|
||||
await flushStatus();
|
||||
expect(lastPush()?.kind).toBe("account");
|
||||
});
|
||||
|
||||
it("reports the gateway identity after gateway authentication", async () => {
|
||||
await agent.authenticate({
|
||||
methodId: "gateway",
|
||||
_meta: { gateway: { baseUrl: "https://gw.example.com/v1", headers: {} } },
|
||||
} as never);
|
||||
|
||||
expect(updates().map((call) => call[1])).toEqual([
|
||||
{
|
||||
authStatus: {
|
||||
kind: "gateway",
|
||||
label: "Custom model gateway",
|
||||
detail: "gw.example.com",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("discards a probe that a gateway authenticate overtook", async () => {
|
||||
// The initialize-time probe is slower than the login: releasing it after
|
||||
// `authenticate` must not repaint the gateway identity with the CLI store's.
|
||||
deferStatus = true;
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
|
||||
await agent.authenticate({
|
||||
methodId: "gateway",
|
||||
_meta: { gateway: { baseUrl: "https://gw.example.com/v1", headers: {} } },
|
||||
} as never);
|
||||
await flushStatus();
|
||||
|
||||
const gateway = {
|
||||
kind: "gateway",
|
||||
label: "Custom model gateway",
|
||||
detail: "gw.example.com",
|
||||
};
|
||||
// The overtaken read published nothing: one push, the gateway's.
|
||||
expect(updates().map((call) => call[1])).toEqual([{ authStatus: gateway }]);
|
||||
expect(agent.currentAuthStatus).toEqual(gateway);
|
||||
});
|
||||
|
||||
it("ignores a pre-logout probe and pushes the fresh one", async () => {
|
||||
// A probe started before `logout` still describes the logged-in world: it
|
||||
// must neither be reused by logout nor be published when it lands.
|
||||
deferStatus = true;
|
||||
void agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await settle();
|
||||
statusStdout = CLI_LOGGED_OUT;
|
||||
statusFails = true;
|
||||
|
||||
const logout = agent.logout({});
|
||||
await flushStatus();
|
||||
await logout;
|
||||
|
||||
// Two reads: the stale one, plus the fresh one logout started itself
|
||||
// instead of joining it.
|
||||
expect(statusCalls()).toHaveLength(2);
|
||||
// The logged-in identity the stale probe carried was never published.
|
||||
expect(updates().map((call) => call[1])).toEqual([
|
||||
{ authStatus: { kind: "none", label: "Not logged in" } },
|
||||
]);
|
||||
expect(agent.currentAuthStatus).toEqual({ kind: "none", label: "Not logged in" });
|
||||
});
|
||||
|
||||
it("discards the pre-logout read even when it lands after the logout's own", async () => {
|
||||
// Both reads are in flight at once. The logout's lands first and pushes
|
||||
// "logged out"; the older one lands after and must publish nothing, or a
|
||||
// slow read would resurrect the identity the logout destroyed.
|
||||
deferStatus = true;
|
||||
void agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await settle();
|
||||
statusStdout = CLI_LOGGED_OUT;
|
||||
statusFails = true;
|
||||
const logout = agent.logout({});
|
||||
await vi.waitFor(() => expect(pendingStatus).toHaveLength(2));
|
||||
|
||||
await releaseStatus(1);
|
||||
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
|
||||
await releaseStatus(0);
|
||||
await logout;
|
||||
|
||||
expect(updates()).toHaveLength(1);
|
||||
expect(agent.currentAuthStatus).toEqual({ kind: "none", label: "Not logged in" });
|
||||
});
|
||||
|
||||
it("keeps the last known state when a probe is readable but empty", async () => {
|
||||
// The CLI cannot be read at all, yet a session already told us who we are:
|
||||
// keep that rather than pretending the state is unknown.
|
||||
const published = { kind: "api_key" as const, label: "Anthropic API key", detail: "env" };
|
||||
agent.setAuthStatus(published);
|
||||
await settle();
|
||||
extNotification.mockClear();
|
||||
statusStdout = "";
|
||||
statusFails = true;
|
||||
|
||||
await initialize();
|
||||
|
||||
expect(updates()).toHaveLength(0);
|
||||
expect(agent.currentAuthStatus).toEqual(published);
|
||||
});
|
||||
|
||||
it("drops a stale identity when the post-logout probe is unreadable", async () => {
|
||||
await initialize();
|
||||
statusStdout = "";
|
||||
statusFails = true;
|
||||
|
||||
await agent.logout({});
|
||||
|
||||
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
|
||||
});
|
||||
|
||||
it("re-probes after logout and reports the resulting state", async () => {
|
||||
await initialize();
|
||||
statusStdout = CLI_LOGGED_OUT;
|
||||
statusFails = true;
|
||||
|
||||
await agent.logout({});
|
||||
|
||||
expect(statusCalls()).toHaveLength(2);
|
||||
expect(lastPush()).toEqual({ kind: "none", label: "Not logged in" });
|
||||
});
|
||||
|
||||
describe("when the CLI never answers", () => {
|
||||
/** The last known state, so a timed-out probe has something to fall back
|
||||
* to and a wrong "cannot determine" answer would be visible. */
|
||||
const KNOWN: AuthStatus = { kind: "api_key", label: "Anthropic API key", detail: "env" };
|
||||
|
||||
let logger: { log: Mock; error: Mock; warn: Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// A wedged `claude auth status --json`: it never answers by itself, and
|
||||
// the fake child dies the way Node kills a timed-out exec — the error
|
||||
// carries `killed`/`signal`, and any partial stdout is a fragment.
|
||||
execFileSpy.mockImplementation((...invocation: unknown[]) => {
|
||||
const args = invocation[1] as string[];
|
||||
const options = invocation[2] as { timeout?: number } | undefined;
|
||||
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
|
||||
if (args[1] !== "status") {
|
||||
cb(null, { stdout: "", stderr: "" });
|
||||
return;
|
||||
}
|
||||
if (options?.timeout === undefined) return;
|
||||
setTimeout(() => {
|
||||
cb(
|
||||
Object.assign(new Error("Command failed"), {
|
||||
killed: true,
|
||||
signal: "SIGTERM",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}),
|
||||
);
|
||||
}, options.timeout);
|
||||
});
|
||||
logger = { log: vi.fn(), error: vi.fn(), warn: vi.fn() };
|
||||
extNotification = vi.fn().mockResolvedValue(undefined);
|
||||
agent = new ClaudeAcpAgent(
|
||||
{ sessionUpdate: async () => {}, extNotification } as unknown as AcpClient,
|
||||
logger as Logger,
|
||||
);
|
||||
agent.setAuthStatus({ ...KNOWN });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("bounds the probe at 5 s and keeps the last known state", async () => {
|
||||
agent.setAuthStatus({ ...KNOWN });
|
||||
const pushesBefore = updates().length;
|
||||
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
// The exec is spawned on a microtask (`claudeCliPath` is awaited first),
|
||||
// so let those run before the timer that kills it.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(statusCalls()).toHaveLength(1);
|
||||
expect(statusCalls()[0]?.[2]).toMatchObject({ timeout: 5000 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
// Node killed the child, so the read reports nothing of its own: the
|
||||
// state stays what was known before it, and no push goes out.
|
||||
expect(agent.currentAuthStatus).toEqual(KNOWN);
|
||||
expect(updates()).toHaveLength(pushesBefore);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"claude auth status did not answer within 5 s; the identity is not refreshed",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns once however many probes time out", async () => {
|
||||
// A wedged CLI stays wedged: every later prompt would repeat the same
|
||||
// line otherwise.
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
expect(statusCalls()).toHaveLength(2);
|
||||
expect(
|
||||
logger.error.mock.calls.filter((call) => String(call[0]).includes("did not answer")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
431
src/tests/clear-context-coordinator.test.ts
Normal file
431
src/tests/clear-context-coordinator.test.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ClearContextCoordinatorHost,
|
||||
ClearContextSession,
|
||||
ClearContextTurn,
|
||||
continuePlanInFreshContext,
|
||||
} from "../clear-context-coordinator.js";
|
||||
import { ExitPlanCoordinator, observeExitPlanToolResults } from "../exit-plan.js";
|
||||
|
||||
type TestSession = ClearContextSession<ClearContextTurn>;
|
||||
|
||||
function testSession(overrides: Partial<TestSession> = {}): TestSession {
|
||||
return {
|
||||
cwd: "/workspace",
|
||||
accumulatedUsage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cachedReadTokens: 30,
|
||||
cachedWriteTokens: 40,
|
||||
},
|
||||
accumulatedModelUsage: {
|
||||
"claude-sonnet-5": {
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cachedReadTokens: 30,
|
||||
cachedWriteTokens: 40,
|
||||
},
|
||||
},
|
||||
models: { currentModelId: "default" },
|
||||
configOptions: [],
|
||||
fastModeEnabled: false,
|
||||
input: { push: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function testHost(
|
||||
oldSession: TestSession,
|
||||
freshSession: TestSession,
|
||||
): ClearContextCoordinatorHost<TestSession, ClearContextTurn> {
|
||||
let currentSession = oldSession;
|
||||
return {
|
||||
currentSession: vi.fn(() => currentSession),
|
||||
closeQueryStream: vi.fn(),
|
||||
restartSession: vi.fn(async () => {
|
||||
currentSession = freshSession;
|
||||
return freshSession;
|
||||
}),
|
||||
applyFastMode: vi.fn(async () => {}),
|
||||
publishSessionState: vi.fn(async () => {}),
|
||||
continuationMessage: vi.fn((sessionId, plan, promptUuid) => ({
|
||||
type: "user" as const,
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: `Implement the following plan:\n\n${plan}` }],
|
||||
},
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
origin: { kind: "human" as const },
|
||||
uuid: promptUuid as `${string}-${string}-${string}-${string}-${string}`,
|
||||
})),
|
||||
ensureConsumer: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("continuePlanInFreshContext", () => {
|
||||
it("moves the pending ACP turn and its session preferences to a fresh query", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const reset = { toolUseId: "tool-plan", plan: "Ship it", mode: "auto" as const };
|
||||
const oldSession = testSession({
|
||||
activeTurn: turn,
|
||||
turnQueue: [turn],
|
||||
pendingExitPlanContextReset: reset,
|
||||
models: { currentModelId: "claude-sonnet" },
|
||||
fastModeEnabled: true,
|
||||
effortPinnedLevel: "high",
|
||||
configOptions: [
|
||||
{
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
type: "select",
|
||||
currentValue: "high",
|
||||
options: [{ value: "high", name: "High" }],
|
||||
},
|
||||
],
|
||||
creationParams: {
|
||||
cwd: "/workspace",
|
||||
mcpServers: [],
|
||||
_meta: { caller: "test", claudeCode: { options: { env: { PRESERVED: "yes" } } } },
|
||||
},
|
||||
});
|
||||
const freshSession = testSession();
|
||||
const host = testHost(oldSession, freshSession);
|
||||
|
||||
await continuePlanInFreshContext("public-session", oldSession, reset, host);
|
||||
|
||||
expect(host.restartSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace",
|
||||
_meta: expect.objectContaining({
|
||||
caller: "test",
|
||||
claudeCode: {
|
||||
options: expect.objectContaining({
|
||||
env: { PRESERVED: "yes" },
|
||||
model: "claude-sonnet",
|
||||
effort: "high",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ publicSessionId: "public-session", permissionMode: "auto" },
|
||||
);
|
||||
expect(turn.carriedUsage).toEqual(oldSession.accumulatedUsage);
|
||||
expect(turn.carriedUsage).not.toBe(oldSession.accumulatedUsage);
|
||||
// The per-model breakdown rides along, so the turn's `_meta.quota` rows
|
||||
// still cover what it spent before the restart.
|
||||
expect(turn.carriedModelUsage).toEqual(oldSession.accumulatedModelUsage);
|
||||
expect(turn.carriedModelUsage).not.toBe(oldSession.accumulatedModelUsage);
|
||||
expect(oldSession.pendingExitPlanContextReset).toBeUndefined();
|
||||
expect(oldSession.activeTurn).toBeNull();
|
||||
expect(oldSession.turnQueue).toEqual([]);
|
||||
expect(freshSession.turnQueue).toEqual([turn]);
|
||||
expect(freshSession.contextUsedTokens).toBe(0);
|
||||
expect(host.applyFastMode).toHaveBeenCalledWith(freshSession, true);
|
||||
expect(host.continuationMessage).toHaveBeenCalledWith(
|
||||
"public-session",
|
||||
"Ship it",
|
||||
turn.promptUuid,
|
||||
);
|
||||
expect(freshSession.input.push).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
uuid: turn.promptUuid,
|
||||
origin: { kind: "human" },
|
||||
message: expect.objectContaining({
|
||||
content: [{ type: "text", text: "Implement the following plan:\n\nShip it" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(host.ensureConsumer).toHaveBeenCalledWith(freshSession, "public-session");
|
||||
});
|
||||
|
||||
it("does not turn an automatic displayed effort into a pin during restart", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const oldSession = testSession({
|
||||
activeTurn: turn,
|
||||
configOptions: [
|
||||
{
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
type: "select",
|
||||
currentValue: "high",
|
||||
options: [{ value: "high", name: "High" }],
|
||||
},
|
||||
],
|
||||
creationParams: {
|
||||
cwd: "/workspace",
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { effort: "max" } } },
|
||||
},
|
||||
});
|
||||
const host = testHost(oldSession, testSession());
|
||||
|
||||
await continuePlanInFreshContext(
|
||||
"public-session",
|
||||
oldSession,
|
||||
{ toolUseId: "tool-plan", plan: "Ship it", mode: "default" },
|
||||
host,
|
||||
);
|
||||
|
||||
const params = vi.mocked(host.restartSession).mock.calls[0]?.[0];
|
||||
expect((params?._meta as any)?.claudeCode?.options).not.toHaveProperty("effort");
|
||||
});
|
||||
|
||||
it("does not resurrect original preferences after the session returns to defaults", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const oldSession = testSession({
|
||||
activeTurn: turn,
|
||||
models: { currentModelId: "default" },
|
||||
configOptions: [
|
||||
{
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
type: "select",
|
||||
currentValue: "default",
|
||||
options: [{ value: "default", name: "Default" }],
|
||||
},
|
||||
],
|
||||
creationParams: {
|
||||
cwd: "/workspace",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
model: "stale-model",
|
||||
agent: "stale-agent",
|
||||
effort: "high",
|
||||
env: { PRESERVED: "yes" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const host = testHost(oldSession, testSession());
|
||||
|
||||
await continuePlanInFreshContext(
|
||||
"public-session",
|
||||
oldSession,
|
||||
{ toolUseId: "tool-plan", plan: "Ship it", mode: "default" },
|
||||
host,
|
||||
);
|
||||
|
||||
const restartParams = vi.mocked(host.restartSession).mock.calls[0]?.[0];
|
||||
expect(restartParams?._meta).toEqual({
|
||||
claudeCode: { options: { env: { PRESERVED: "yes" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("restores disabled Fast mode when a fresh session starts with it enabled", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const oldSession = testSession({ activeTurn: turn, fastModeEnabled: false });
|
||||
const freshSession = testSession({ fastModeEnabled: true });
|
||||
const host = testHost(oldSession, freshSession);
|
||||
|
||||
await continuePlanInFreshContext(
|
||||
"public-session",
|
||||
oldSession,
|
||||
{ toolUseId: "tool-plan", plan: "Ship it", mode: "auto" },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(host.applyFastMode).toHaveBeenCalledWith(freshSession, false);
|
||||
});
|
||||
|
||||
it("does not publish or continue a restart cancelled while creating the fresh session", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const oldSession = testSession({ activeTurn: turn });
|
||||
const freshSession = testSession();
|
||||
const host = testHost(oldSession, freshSession);
|
||||
let finishRestart!: () => void;
|
||||
vi.mocked(host.restartSession).mockImplementation(
|
||||
() =>
|
||||
new Promise<TestSession>((resolve) => {
|
||||
finishRestart = () => resolve(freshSession);
|
||||
}),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
|
||||
const restart = continuePlanInFreshContext(
|
||||
"public-session",
|
||||
oldSession,
|
||||
{ toolUseId: "tool-plan", plan: "Ship it", mode: "auto" },
|
||||
host,
|
||||
controller.signal,
|
||||
);
|
||||
await vi.waitFor(() => expect(host.restartSession).toHaveBeenCalled());
|
||||
controller.abort();
|
||||
finishRestart();
|
||||
|
||||
await expect(restart).rejects.toThrow("Clear-context restart aborted");
|
||||
expect(host.publishSessionState).not.toHaveBeenCalled();
|
||||
expect(freshSession.input.push).not.toHaveBeenCalled();
|
||||
expect(host.ensureConsumer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a stale session before closing its query", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const oldSession = testSession({ activeTurn: turn });
|
||||
const host = testHost(oldSession, testSession());
|
||||
vi.mocked(host.currentSession).mockReturnValue(testSession());
|
||||
|
||||
await expect(
|
||||
continuePlanInFreshContext(
|
||||
"public-session",
|
||||
oldSession,
|
||||
{ toolUseId: "tool-plan", plan: "Ship it", mode: "auto" },
|
||||
host,
|
||||
),
|
||||
).rejects.toThrow("Cannot clear context without an active ACP turn");
|
||||
|
||||
expect(host.closeQueryStream).not.toHaveBeenCalled();
|
||||
expect(host.restartSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExitPlanCoordinator", () => {
|
||||
it("owns cancellation cleanup for a replacement created after close", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const oldSession = testSession({ activeTurn: turn, turnQueue: [turn] });
|
||||
const freshSession = testSession();
|
||||
const baseHost = testHost(oldSession, freshSession);
|
||||
const destroyReplacement = vi.fn();
|
||||
const settleCancelledTurn = vi.fn((_old, _owner, cancelledTurn: ClearContextTurn) => {
|
||||
cancelledTurn.settled = true;
|
||||
});
|
||||
let finishRestart!: () => void;
|
||||
vi.mocked(baseHost.restartSession).mockImplementation(
|
||||
() =>
|
||||
new Promise<TestSession>((resolve) => {
|
||||
finishRestart = () => {
|
||||
vi.mocked(baseHost.currentSession).mockReturnValue(freshSession);
|
||||
resolve(freshSession);
|
||||
};
|
||||
}),
|
||||
);
|
||||
const host = {
|
||||
...baseHost,
|
||||
sessionUpdate: vi.fn(async () => {}),
|
||||
destroyReplacement,
|
||||
settleCancelledTurn,
|
||||
settleFailedTurn: vi.fn(),
|
||||
};
|
||||
const coordinator = new ExitPlanCoordinator<TestSession, ClearContextTurn>(host);
|
||||
|
||||
const restart = coordinator.restart("public-session", oldSession, {
|
||||
toolUseId: "tool-plan",
|
||||
plan: "Ship it",
|
||||
mode: "auto",
|
||||
});
|
||||
await vi.waitFor(() => expect(baseHost.restartSession).toHaveBeenCalled());
|
||||
coordinator.cancel("public-session");
|
||||
finishRestart();
|
||||
await restart;
|
||||
|
||||
expect(destroyReplacement).toHaveBeenCalledWith("public-session", freshSession);
|
||||
expect(settleCancelledTurn).toHaveBeenCalledWith(oldSession, oldSession, turn);
|
||||
expect(oldSession.activeTurn).toBeNull();
|
||||
expect(oldSession.turnQueue).toEqual([]);
|
||||
expect(freshSession.input.push).not.toHaveBeenCalled();
|
||||
expect(baseHost.ensureConsumer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles a restart failure without classifying it as a query transport loss", async () => {
|
||||
const error = new Error("restart failed");
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const reset = { toolUseId: "tool-plan", plan: "Ship it", mode: "auto" as const };
|
||||
const oldSession = testSession({
|
||||
activeTurn: turn,
|
||||
turnQueue: [turn],
|
||||
pendingExitPlanContextReset: reset,
|
||||
});
|
||||
const baseHost = testHost(oldSession, testSession());
|
||||
vi.mocked(baseHost.restartSession).mockRejectedValue(error);
|
||||
const settleFailedTurn = vi.fn((_owner, failedTurn: ClearContextTurn) => {
|
||||
failedTurn.settled = true;
|
||||
});
|
||||
const host = {
|
||||
...baseHost,
|
||||
sessionUpdate: vi.fn(async () => {}),
|
||||
destroyReplacement: vi.fn(),
|
||||
settleCancelledTurn: vi.fn(),
|
||||
settleFailedTurn,
|
||||
};
|
||||
const coordinator = new ExitPlanCoordinator<TestSession, ClearContextTurn>(host);
|
||||
|
||||
await coordinator.restart("public-session", oldSession, reset);
|
||||
|
||||
expect(settleFailedTurn).toHaveBeenCalledWith(oldSession, turn, error);
|
||||
expect(turn.carriedUsage).toBeUndefined();
|
||||
expect(turn.carriedModelUsage).toBeUndefined();
|
||||
expect(oldSession.pendingExitPlanContextReset).toBeUndefined();
|
||||
expect(oldSession.activeTurn).toBeNull();
|
||||
expect(oldSession.turnQueue).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("observeExitPlanToolResults", () => {
|
||||
it("restores a rejected ExitPlanMode marker from authoritative stream metadata", () => {
|
||||
const state = {
|
||||
toolUseCache: { "tool-plan": { name: "ExitPlanMode" } },
|
||||
};
|
||||
|
||||
const accepted = observeExitPlanToolResults(
|
||||
{
|
||||
type: "user",
|
||||
tool_result_meta: [{ id: "tool-plan", non_execution_kind: "user-rejected" }],
|
||||
},
|
||||
[{ type: "tool_result", tool_use_id: "tool-plan", is_error: true }],
|
||||
state,
|
||||
);
|
||||
|
||||
expect(accepted).toBeUndefined();
|
||||
expect(state).toEqual({
|
||||
toolUseCache: { "tool-plan": { name: "ExitPlanMode" } },
|
||||
pendingExitPlanModeInterruption: { toolUseId: "tool-plan", toolResultSeen: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("correlates an accepted plan result with its pending context reset", () => {
|
||||
const state = {
|
||||
toolUseCache: { "tool-plan": { name: "ExitPlanMode" } },
|
||||
pendingExitPlanContextReset: {
|
||||
toolUseId: "tool-plan",
|
||||
plan: "Ship it",
|
||||
mode: "auto" as const,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
observeExitPlanToolResults(
|
||||
{ type: "user" },
|
||||
[{ type: "tool_result", tool_use_id: "tool-plan", is_error: true }],
|
||||
state,
|
||||
),
|
||||
).toBe("tool-plan");
|
||||
});
|
||||
});
|
||||
466
src/tests/context-compaction.test.ts
Normal file
466
src/tests/context-compaction.test.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
ContextCompactionLifecycle,
|
||||
clientSupportsCompactionUpdates,
|
||||
compactionSummaryText,
|
||||
isCompactSummaryMessage,
|
||||
} from "../context-compaction.js";
|
||||
|
||||
const PERSISTED_SUMMARY =
|
||||
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n" +
|
||||
"Summary:\n1. Primary Request and Intent:\n Count upward.\n\n2. Optional Next Step:\n None.\n\n" +
|
||||
"If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /tmp/session.jsonl\n" +
|
||||
"Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening.";
|
||||
|
||||
function lifecycle(
|
||||
presentation: "tool_call" | "compaction_update",
|
||||
sendUpdate?: (notification: SessionNotification) => Promise<void>,
|
||||
) {
|
||||
const sent: SessionNotification["update"][] = [];
|
||||
const logError = vi.fn();
|
||||
const compaction = new ContextCompactionLifecycle(
|
||||
sendUpdate ??
|
||||
(async (notification) => {
|
||||
sent.push(notification.update);
|
||||
}),
|
||||
{ sessionId: "s", presentation, logError },
|
||||
);
|
||||
return { sent, compaction, logError };
|
||||
}
|
||||
|
||||
describe("clientSupportsCompactionUpdates", () => {
|
||||
it("requires the v1 session.compaction object", () => {
|
||||
expect(clientSupportsCompactionUpdates({ session: { compaction: {} } })).toBe(true);
|
||||
expect(clientSupportsCompactionUpdates({ session: { compaction: { extra: true } } })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(clientSupportsCompactionUpdates({ session: { compaction: null } })).toBe(false);
|
||||
expect(clientSupportsCompactionUpdates({ session: null })).toBe(false);
|
||||
expect(clientSupportsCompactionUpdates({ session: {} })).toBe(false);
|
||||
expect(clientSupportsCompactionUpdates({})).toBe(false);
|
||||
expect(clientSupportsCompactionUpdates(undefined)).toBe(false);
|
||||
expect(clientSupportsCompactionUpdates(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactionSummaryText", () => {
|
||||
it("keeps only the <summary> body of the hook's raw model output", () => {
|
||||
expect(
|
||||
compactionSummaryText(
|
||||
"<analysis>\nThinking about it.\n</analysis>\n\n<summary>\n## Retained\n\nThe user counted.\n</summary>",
|
||||
),
|
||||
).toBe("## Retained\n\nThe user counted.");
|
||||
});
|
||||
|
||||
it("is not fooled by tag names quoted inside the analysis or the summary", () => {
|
||||
expect(
|
||||
compactionSummaryText(
|
||||
"<analysis>\nThe user asked for an <analysis> block followed by a <summary> block.\n</analysis>\n\n" +
|
||||
"<summary>\n1. Intent:\n Produce an `<analysis>` and a `<summary>` block, then </summary> nothing.\n</summary>\n",
|
||||
),
|
||||
).toBe(
|
||||
"1. Intent:\n Produce an `<analysis>` and a `<summary>` block, then </summary> nothing.",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the widest inline span and stays linear without a closing tag", () => {
|
||||
expect(compactionSummaryText("<summary>Inline </summary> quoted.</summary>")).toBe(
|
||||
"Inline </summary> quoted.",
|
||||
);
|
||||
const unterminated = "<summary> mention ".repeat(5000);
|
||||
const started = performance.now();
|
||||
expect(compactionSummaryText(unterminated)).toBe(unterminated.trim());
|
||||
expect(performance.now() - started).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it("strips the persisted continuation framing", () => {
|
||||
expect(compactionSummaryText(PERSISTED_SUMMARY)).toBe(
|
||||
"1. Primary Request and Intent:\n Count upward.\n\n2. Optional Next Step:\n None.",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips the older tagged transcript framing", () => {
|
||||
expect(
|
||||
compactionSummaryText(
|
||||
"This session is being continued from a previous conversation that ran out of context. The conversation is summarized below:\n<summary>\nOld style body.\n</summary>\nPlease continue the conversation from where it left it off without asking the user any further questions.",
|
||||
),
|
||||
).toBe("Old style body.");
|
||||
});
|
||||
|
||||
it("fails closed on a continuation prompt whose framing it does not recognize", () => {
|
||||
expect(
|
||||
compactionSummaryText(
|
||||
"This session is being continued from a previous conversation that ran out of context. Here is what happened:\n\nThe user counted.\n\nResume directly.",
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips a leading analysis block even when the summary tags are missing", () => {
|
||||
expect(
|
||||
compactionSummaryText(
|
||||
"<analysis>\nPrivate reasoning about <summary> tags.\n</analysis>\n\n1. Primary Request:\n Count.",
|
||||
),
|
||||
).toBe("1. Primary Request:\n Count.");
|
||||
expect(compactionSummaryText("<analysis>inline</analysis> Body.")).toBe("Body.");
|
||||
// A body quoting both tag names inline is not an analysis block.
|
||||
expect(compactionSummaryText("Body mentions <analysis> and </analysis> inline.")).toBe(
|
||||
"Body mentions <analysis> and </analysis> inline.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a <summary> mention inside an inline analysis anchor the span", () => {
|
||||
expect(
|
||||
compactionSummaryText(
|
||||
"<analysis>Wants a <summary> block.</analysis> <summary>Real body.</summary>",
|
||||
),
|
||||
).toBe("Real body.");
|
||||
});
|
||||
|
||||
it("passes plain text through and drops empty summaries", () => {
|
||||
expect(compactionSummaryText(" Just a summary. ")).toBe("Just a summary.");
|
||||
expect(compactionSummaryText("")).toBeUndefined();
|
||||
expect(compactionSummaryText("<summary> </summary>")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCompactSummaryMessage", () => {
|
||||
it("matches only user messages flagged by Claude Code", () => {
|
||||
expect(isCompactSummaryMessage({ type: "user", isCompactSummary: true })).toBe(true);
|
||||
expect(isCompactSummaryMessage({ type: "user" })).toBe(false);
|
||||
expect(isCompactSummaryMessage({ type: "assistant", isCompactSummary: true })).toBe(false);
|
||||
expect(isCompactSummaryMessage(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextCompactionLifecycle (compaction_update)", () => {
|
||||
it("opens in_progress, carries the hook summary on the terminal update, then enriches from the boundary", async () => {
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
await compaction.start("cmp-1");
|
||||
compaction.recordSummary("<analysis>x</analysis><summary>\nRetained.\n</summary>");
|
||||
await compaction.finish("cmp-status", "completed");
|
||||
await compaction.finish(
|
||||
"cmp-boundary",
|
||||
"completed",
|
||||
{ trigger: "manual", preTokens: 100, postTokens: 10, durationMs: 5 },
|
||||
true,
|
||||
);
|
||||
// A duplicated terminal status neither re-sends nor clears the metadata.
|
||||
await compaction.finish("cmp-status-dup", "completed");
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: "cmp-1",
|
||||
status: "in_progress",
|
||||
_meta: { contextCompaction: { version: 1 } },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: "cmp-1",
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: "Retained." }],
|
||||
_meta: { contextCompaction: { version: 1 } },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: "cmp-1",
|
||||
status: "completed",
|
||||
_meta: {
|
||||
contextCompaction: {
|
||||
version: 1,
|
||||
trigger: "manual",
|
||||
preTokens: 100,
|
||||
postTokens: 10,
|
||||
durationMs: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(compaction.hasDeliveredOutput).toBe(true);
|
||||
});
|
||||
|
||||
it("holds a summary recorded before the opening status and attaches it once", async () => {
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
compaction.recordSummary("<summary>Early.</summary>");
|
||||
expect(sent).toEqual([]);
|
||||
await compaction.start("cmp-1");
|
||||
await compaction.finish("cmp-1", "completed");
|
||||
await compaction.finish("cmp-b", "completed", { trigger: "automatic", preTokens: 1 }, true);
|
||||
|
||||
expect(sent.map((u) => ("summary" in u ? u.summary : undefined))).toEqual([
|
||||
undefined,
|
||||
[{ type: "text", text: "Early." }],
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a pending summary across a previous turn's reset", async () => {
|
||||
// The hook fires before the compaction's terminal frames, so a summary
|
||||
// that arrives while the consumer is still draining the previous turn
|
||||
// belongs to the compaction whose frames are still ahead of it.
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
compaction.recordSummary("<summary>Next turn's.</summary>");
|
||||
await compaction.reset();
|
||||
await compaction.start("cmp-2");
|
||||
await compaction.finish("cmp-2", "completed");
|
||||
|
||||
expect(sent.at(-1)).toMatchObject({
|
||||
compactionId: "cmp-2",
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: "Next turn's." }],
|
||||
});
|
||||
});
|
||||
|
||||
it("holds a summary that arrives after a terminal entity for the next compaction", async () => {
|
||||
// The hook precedes its own compaction's terminal frames, so a summary
|
||||
// seen after compaction A's terminal is B's, whose `compacting` status the
|
||||
// consumer has not reached yet.
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
await compaction.start("cmp-a");
|
||||
await compaction.finish("cmp-a", "completed");
|
||||
compaction.recordSummary("<summary>B's summary.</summary>");
|
||||
expect(sent).toHaveLength(2);
|
||||
|
||||
await compaction.start("cmp-b");
|
||||
await compaction.finish("cmp-b", "completed");
|
||||
expect(sent.at(-1)).toMatchObject({
|
||||
compactionId: "cmp-b",
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: "B's summary." }],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a malformed or empty hook payload and reports whether a summary was kept", () => {
|
||||
const { compaction } = lifecycle("compaction_update");
|
||||
expect(compaction.recordSummary(undefined)).toBe(false);
|
||||
expect(compaction.recordSummary(null)).toBe(false);
|
||||
expect(compaction.recordSummary({ text: "x" })).toBe(false);
|
||||
expect(compaction.recordSummary("<summary> </summary>")).toBe(false);
|
||||
expect(compaction.recordSummary("<summary>Kept.</summary>")).toBe(true);
|
||||
});
|
||||
|
||||
it("materializes a terminal-only compaction as a single completed update", async () => {
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
compaction.recordSummary("<summary>Only terminal.</summary>");
|
||||
await compaction.finish("cmp-boundary", "completed", {
|
||||
trigger: "automatic",
|
||||
preTokens: 50,
|
||||
});
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: "cmp-boundary",
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: "Only terminal." }],
|
||||
_meta: { contextCompaction: { version: 1, trigger: "automatic", preTokens: 50 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeds _meta on a boundary-first terminal even without metadata", async () => {
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
await compaction.finish("cmp-boundary", "completed", {}, true);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: "cmp-boundary",
|
||||
status: "completed",
|
||||
_meta: { contextCompaction: { version: 1 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports failures with the error and consumes the duplicated stdout once", async () => {
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
await compaction.start("cmp-1");
|
||||
compaction.recordSummary("<summary>Never shown.</summary>");
|
||||
await compaction.finish("cmp-1", "failed", { error: "summary rejected" });
|
||||
|
||||
expect(sent[1]).toEqual({
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: "cmp-1",
|
||||
status: "failed",
|
||||
error: "summary rejected",
|
||||
_meta: { contextCompaction: { version: 1, error: "summary rejected" } },
|
||||
});
|
||||
expect(compaction.consumeDuplicateErrorOutput("summary rejected\n")).toBe(true);
|
||||
expect(compaction.consumeDuplicateErrorOutput("summary rejected")).toBe(false);
|
||||
// A summary never patches a failed entity.
|
||||
compaction.recordSummary("<summary>Still never.</summary>");
|
||||
expect(sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("streams API compaction text as summary chunks only into an announced entity", async () => {
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
// The API block alone never opens an entity: nothing would close it.
|
||||
await compaction.heartbeat("cmp-stream", undefined);
|
||||
await compaction.heartbeat("cmp-stream", "orphan text");
|
||||
expect(sent).toEqual([]);
|
||||
expect(compaction.hasDeliveredOutput).toBe(false);
|
||||
|
||||
await compaction.start("cmp-1");
|
||||
await compaction.heartbeat("cmp-stream", undefined);
|
||||
await compaction.heartbeat("cmp-stream", "## Retained\n");
|
||||
await compaction.heartbeat("cmp-stream", "The user counted.");
|
||||
await compaction.finish("cmp-1", "completed");
|
||||
await compaction.heartbeat("cmp-stream", "ignored after terminal");
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: "cmp-1",
|
||||
status: "in_progress",
|
||||
_meta: { contextCompaction: { version: 1 } },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "compaction_summary_chunk",
|
||||
compactionId: "cmp-1",
|
||||
content: { type: "text", text: "## Retained\n" },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "compaction_summary_chunk",
|
||||
compactionId: "cmp-1",
|
||||
content: { type: "text", text: "The user counted." },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: "cmp-1",
|
||||
status: "completed",
|
||||
_meta: { contextCompaction: { version: 1 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes an in-progress entity as cancelled on reset and starts fresh afterwards", async () => {
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
await compaction.start("cmp-1");
|
||||
await compaction.reset();
|
||||
expect(sent.at(-1)).toEqual({
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId: "cmp-1",
|
||||
status: "cancelled",
|
||||
});
|
||||
expect(compaction.hasDeliveredOutput).toBe(false);
|
||||
|
||||
// Terminal entities are left alone.
|
||||
await compaction.start("cmp-2");
|
||||
await compaction.finish("cmp-2", "completed");
|
||||
const before = sent.length;
|
||||
await compaction.reset();
|
||||
expect(sent).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("never throws from reset", async () => {
|
||||
const failing = vi.fn<(notification: SessionNotification) => Promise<void>>(async () => {
|
||||
throw new Error("client gone");
|
||||
});
|
||||
const { compaction, logError } = lifecycle("compaction_update", failing);
|
||||
|
||||
// start() itself propagates like every other consumer send.
|
||||
await expect(compaction.start("cmp-1")).rejects.toThrow("client gone");
|
||||
await expect(compaction.reset()).resolves.toBeUndefined();
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts a new entity for a compaction that follows a terminal one", async () => {
|
||||
const { sent, compaction } = lifecycle("compaction_update");
|
||||
|
||||
await compaction.start("cmp-1");
|
||||
await compaction.finish("cmp-1", "completed");
|
||||
await compaction.start("cmp-2");
|
||||
|
||||
expect(sent.at(-1)).toMatchObject({ compactionId: "cmp-2", status: "in_progress" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextCompactionLifecycle (tool_call)", () => {
|
||||
it("keeps the legacy synthetic tool call and never exposes the summary", async () => {
|
||||
const { sent, compaction } = lifecycle("tool_call");
|
||||
|
||||
await compaction.start("compact-start");
|
||||
await compaction.heartbeat("compact-start", "streamed summary text");
|
||||
await compaction.heartbeat("compact-start", "more");
|
||||
compaction.recordSummary("<summary>Hidden.</summary>");
|
||||
await compaction.finish("compact-start", "completed");
|
||||
await compaction.reset();
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "compact-start",
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
status: "in_progress",
|
||||
_meta: { contextCompaction: { version: 1 }, claudeCode: { toolName: "compact" } },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "compact-start",
|
||||
status: "in_progress",
|
||||
_meta: { contextCompaction: { version: 1 }, claudeCode: { toolName: "compact" } },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "compact-start",
|
||||
status: "completed",
|
||||
_meta: { contextCompaction: { version: 1 }, claudeCode: { toolName: "compact" } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("materializes a missed opening as a terminal tool_call with the failure content", async () => {
|
||||
const { sent, compaction } = lifecycle("tool_call");
|
||||
|
||||
await compaction.finish("compact-failed", "failed", { error: "summary rejected" });
|
||||
// Legacy duplicates are ignored, boundary enrichment carries no status.
|
||||
await compaction.finish("compact-failed", "failed", { error: "summary rejected" });
|
||||
await compaction.finish(
|
||||
"compact-boundary",
|
||||
"completed",
|
||||
{ trigger: "manual", preTokens: 3 },
|
||||
true,
|
||||
);
|
||||
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "compact-failed",
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
status: "failed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Compaction failed: summary rejected" },
|
||||
},
|
||||
],
|
||||
rawOutput: { error: "summary rejected" },
|
||||
_meta: {
|
||||
contextCompaction: { version: 1, error: "summary rejected" },
|
||||
claudeCode: { toolName: "compact" },
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "compact-failed",
|
||||
rawOutput: { trigger: "manual", preTokens: 3 },
|
||||
_meta: {
|
||||
contextCompaction: { version: 1, trigger: "manual", preTokens: 3 },
|
||||
claudeCode: { toolName: "compact" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,41 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
CreateElicitationRequest,
|
||||
RequestError,
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { ALLOW_BYPASS } from "../permissions/modes.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
let contextUsageResult: (() => Promise<{ rawMaxTokens: number; model?: string }>) | undefined;
|
||||
let sessionMessages: Record<string, unknown>[];
|
||||
let sessionMessagesResult: () => Promise<Record<string, unknown>[]>;
|
||||
let initModels: Record<string, unknown>[] | undefined;
|
||||
let setModelImpl: ((model: string) => Promise<void>) | undefined;
|
||||
let mcpServerStatusResult: () => Promise<Array<{ name: string; status: string }>>;
|
||||
let mcpAuthenticateImpl: (serverName: string) => Promise<{
|
||||
authUrl?: string;
|
||||
requiresUserAction: boolean;
|
||||
callbackExpected: boolean;
|
||||
}>;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
const { makeMockQuery, DEFAULT_CONTEXT_USAGE } = await import("./helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
query: (args: { prompt: unknown; options: Options }) => {
|
||||
capturedOptions = args.options;
|
||||
return {
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
models: initModels ?? [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
@@ -26,12 +44,14 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
setModel: (model: string) => (setModelImpl ? setModelImpl(model) : Promise.resolve()),
|
||||
getContextUsage: () =>
|
||||
contextUsageResult ? contextUsageResult() : Promise.resolve(DEFAULT_CONTEXT_USAGE),
|
||||
mcpServerStatus: () => mcpServerStatusResult(),
|
||||
mcpAuthenticate: (serverName: string) => mcpAuthenticateImpl(serverName),
|
||||
});
|
||||
},
|
||||
getSessionMessages: vi.fn(() => sessionMessagesResult()),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -58,6 +78,17 @@ describe("createSession options merging", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedOptions = undefined;
|
||||
contextUsageResult = undefined;
|
||||
sessionMessages = [];
|
||||
sessionMessagesResult = async () => sessionMessages;
|
||||
vi.mocked(getSessionMessages).mockClear();
|
||||
initModels = undefined;
|
||||
setModelImpl = undefined;
|
||||
mcpServerStatusResult = async () => [];
|
||||
mcpAuthenticateImpl = async () => ({
|
||||
requiresUserAction: false,
|
||||
callbackExpected: false,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
@@ -66,6 +97,17 @@ describe("createSession options merging", () => {
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
});
|
||||
|
||||
describe("allowDangerouslySkipPermissions", () => {
|
||||
it("requests bypass capability by default and mirrors it in the mode catalog", async () => {
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.allowDangerouslySkipPermissions).toBe(ALLOW_BYPASS);
|
||||
expect(response.modes!.availableModes.some((mode) => mode.id === "bypassPermissions")).toBe(
|
||||
ALLOW_BYPASS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
@@ -95,6 +137,21 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("ignores the provider-specific main-thread agent option", async () => {
|
||||
const response = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: { agent: "reviewer" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions).not.toHaveProperty("agent");
|
||||
expect(response.configOptions?.some((option) => option.id === "agent")).toBe(false);
|
||||
});
|
||||
|
||||
it("works when user provides empty disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
@@ -262,6 +319,52 @@ 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 caller-provided legacy transcript forwarding", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { forwardSubagentText: true } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.forwardSubagentText).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the legacy transcript extension", 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);
|
||||
});
|
||||
|
||||
it("enables SDK forwarding after native ACP negotiation", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { subagents: {} } as ClientCapabilities,
|
||||
});
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { forwardSubagentText: false } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.forwardSubagentText).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemPrompt via _meta", () => {
|
||||
it("defaults to the claude_code preset when not provided", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
@@ -514,8 +617,13 @@ describe("createSession options merging", () => {
|
||||
|
||||
it("ignores a non-numeric value", async () => {
|
||||
process.env.MAX_THINKING_TOKENS = "lots";
|
||||
// The bad value is deliberate — capture the agent's warning instead of
|
||||
// letting it hit the console.
|
||||
const errorSpy = vi.fn();
|
||||
(agent as any).logger = { log: () => {}, error: errorSpy };
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.thinking).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("MAX_THINKING_TOKENS"));
|
||||
});
|
||||
|
||||
it("lets a user-provided thinking option override the env default", async () => {
|
||||
@@ -598,6 +706,57 @@ describe("createSession options merging", () => {
|
||||
expect(typeof capturedOptions!.onElicitation).toBe("function");
|
||||
});
|
||||
|
||||
it("starts MCP OAuth as an ACP URL elicitation and completes it after reconnect", async () => {
|
||||
let statusCall = 0;
|
||||
mcpServerStatusResult = async () =>
|
||||
statusCall++ === 0
|
||||
? [{ name: "linear", status: "needs-auth" }]
|
||||
: [{ name: "linear", status: "connected" }];
|
||||
const authenticate = vi.fn(async () => ({
|
||||
authUrl: "https://example.com/oauth/authorize",
|
||||
requiresUserAction: true,
|
||||
callbackExpected: true,
|
||||
}));
|
||||
mcpAuthenticateImpl = authenticate;
|
||||
const createElicitation = vi.fn(async (_request: CreateElicitationRequest) => ({
|
||||
action: "accept" as const,
|
||||
}));
|
||||
const completeElicitation = vi.fn(async () => {});
|
||||
Object.assign((agent as unknown as { client: object }).client, {
|
||||
createElicitation,
|
||||
completeElicitation,
|
||||
});
|
||||
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { elicitation: { url: {} } },
|
||||
});
|
||||
const { sessionId } = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [
|
||||
{ name: "linear", type: "http", url: "https://mcp.linear.app/mcp", headers: [] },
|
||||
],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(completeElicitation).toHaveBeenCalledOnce());
|
||||
|
||||
expect(authenticate).toHaveBeenCalledWith("linear");
|
||||
expect(createElicitation).toHaveBeenCalledOnce();
|
||||
const request = createElicitation.mock.calls[0]![0];
|
||||
expect(request.mode).toBe("url");
|
||||
if (request.mode !== "url") throw new Error("Expected a URL elicitation");
|
||||
expect(request).toMatchObject({
|
||||
mode: "url",
|
||||
sessionId,
|
||||
message: "Authenticate with MCP server linear",
|
||||
url: "https://example.com/oauth/authorize",
|
||||
elicitationId: expect.stringMatching(/^mcp-oauth-/),
|
||||
});
|
||||
expect(completeElicitation).toHaveBeenCalledWith({
|
||||
elicitationId: request.elicitationId,
|
||||
});
|
||||
});
|
||||
|
||||
it("still merges user-provided disallowedTools when AskUserQuestion is enabled", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
@@ -614,6 +773,189 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 restores the transcript model without waiting for getContextUsage", async () => {
|
||||
// getContextUsage is a live CLI control request. On a real, large resumed
|
||||
// session it can take tens of seconds before returning, so it must stay
|
||||
// off the load critical path. Claude restores from this same last
|
||||
// assistant model field, which is available through a local transcript
|
||||
// read in milliseconds.
|
||||
const ctxSpy = vi.fn(() => new Promise<never>(() => {}));
|
||||
contextUsageResult = ctxSpy;
|
||||
initModels = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "Default model",
|
||||
resolvedModel: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Fast",
|
||||
resolvedModel: "claude-haiku-4-5",
|
||||
},
|
||||
];
|
||||
sessionMessages = [
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "assistant-uuid",
|
||||
session_id: "resumed-model-probe",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
message: { model: "claude-haiku-4-5", role: "assistant", content: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const response = await agent.loadSession({
|
||||
sessionId: "resumed-model-probe",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(response.configOptions?.find((option) => option.id === "model")?.currentValue).toBe(
|
||||
"haiku",
|
||||
);
|
||||
expect(ctxSpy).not.toHaveBeenCalled();
|
||||
expect(sessionFor("resumed-model-probe").contextWindowAuthoritative).toBe(false);
|
||||
expect(getSessionMessages).toHaveBeenCalledTimes(1);
|
||||
expect(getSessionMessages).toHaveBeenCalledWith("resumed-model-probe");
|
||||
});
|
||||
|
||||
it("resume remains best-effort when the transcript hint cannot be read", async () => {
|
||||
sessionMessagesResult = async () => {
|
||||
throw new Error("unreadable transcript");
|
||||
};
|
||||
|
||||
await expect(
|
||||
agent.resumeSession({
|
||||
sessionId: "unreadable-resume-probe",
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).resolves.toMatchObject({ sessionId: "unreadable-resume-probe" });
|
||||
});
|
||||
|
||||
it("restores the transcript model for direct session/new resume metadata", async () => {
|
||||
initModels = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "Default model",
|
||||
resolvedModel: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Fast",
|
||||
resolvedModel: "claude-haiku-4-5",
|
||||
},
|
||||
];
|
||||
sessionMessages = [
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "assistant-uuid",
|
||||
session_id: "direct-resume-probe",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
message: { model: "claude-haiku-4-5", role: "assistant", content: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { resume: "direct-resume-probe" } } },
|
||||
});
|
||||
|
||||
expect(response.sessionId).toBe("direct-resume-probe");
|
||||
expect(response.configOptions?.find((option) => option.id === "model")?.currentValue).toBe(
|
||||
"haiku",
|
||||
);
|
||||
expect(getSessionMessages).toHaveBeenCalledOnce();
|
||||
expect(getSessionMessages).toHaveBeenCalledWith("direct-resume-probe");
|
||||
});
|
||||
|
||||
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: [] });
|
||||
const plainProviderCacheKey = sessionFor(plain.sessionId).providerCacheKey;
|
||||
|
||||
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(plainProviderCacheKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusal fallback dialog", () => {
|
||||
it("declares the dialog kind and callback when the client supports form elicitation", async () => {
|
||||
await agent.initialize({
|
||||
@@ -648,13 +990,12 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const createElicitation = vi.fn();
|
||||
(
|
||||
agent as unknown as { client: { unstable_createElicitation: unknown } }
|
||||
).client.unstable_createElicitation = createElicitation;
|
||||
(agent as unknown as { client: { createElicitation: unknown } }).client.createElicitation =
|
||||
createElicitation;
|
||||
return { onUserDialog: capturedOptions!.onUserDialog!, createElicitation };
|
||||
}
|
||||
|
||||
const signal = () => ({ signal: new AbortController().signal });
|
||||
const signal = () => ({ signal: new AbortController().signal, requestId: "1" });
|
||||
|
||||
it("renders the prompt as a form elicitation and maps the retry choice", async () => {
|
||||
const { onUserDialog, createElicitation } = await setupDialog();
|
||||
@@ -711,6 +1052,10 @@ describe("createSession options merging", () => {
|
||||
|
||||
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 } },
|
||||
@@ -719,11 +1064,16 @@ describe("createSession options merging", () => {
|
||||
|
||||
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(
|
||||
{
|
||||
@@ -734,6 +1084,205 @@ describe("createSession options merging", () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual({ behavior: "cancelled" });
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("client exploded"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("model switches and PreModelSwitch hooks", () => {
|
||||
const TWO_MODELS = [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-8",
|
||||
displayName: "Claude Opus",
|
||||
description: "Capable",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
];
|
||||
|
||||
let originalAnthropicModel: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let configDir: string;
|
||||
beforeEach(() => {
|
||||
originalAnthropicModel = process.env.ANTHROPIC_MODEL;
|
||||
delete process.env.ANTHROPIC_MODEL;
|
||||
// These tests assert which model a fresh session lands on with no
|
||||
// override in play, so both override tiers above the SDK's default have
|
||||
// to be neutralized: ANTHROPIC_MODEL and `model` from the user
|
||||
// settings tier. Without the second one, a developer whose own
|
||||
// ~/.claude/settings.json pins a model (e.g. "opus[1m]") sees the
|
||||
// session start on that model instead of models[0]. resolveSettings
|
||||
// reads the user tier from CLAUDE_CONFIG_DIR at call time, so pointing
|
||||
// it at an empty directory is enough.
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-acp-model-config-"));
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (originalAnthropicModel !== undefined) {
|
||||
process.env.ANTHROPIC_MODEL = originalAnthropicModel;
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_MODEL;
|
||||
}
|
||||
if (originalClaudeConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("tolerates a PreModelSwitch hook vetoing the fresh-session model pin", async () => {
|
||||
// CLI 2.1.251+: a user-configured PreModelSwitch hook can deny (or
|
||||
// 'ask', which headless sessions refuse) the pin's setModel. Terminal
|
||||
// Claude Code never lets a hook veto its startup model, so the same
|
||||
// config must not fail session/new over ACP — the session stays on the
|
||||
// SDK's default, and we report that.
|
||||
initModels = TWO_MODELS;
|
||||
process.env.ANTHROPIC_MODEL = "opus";
|
||||
setModelImpl = () =>
|
||||
Promise.reject(new Error("Model switch blocked by a PreModelSwitch hook: pinned by IT"));
|
||||
|
||||
const errorSpy = vi.fn();
|
||||
(agent as any).logger = { log: () => {}, error: errorSpy };
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect((agent as any).sessions[response.sessionId].models.currentModelId).toBe(
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("vetoed by a PreModelSwitch hook"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("still fails session/new loudly on a non-hook setModel failure", async () => {
|
||||
initModels = TWO_MODELS;
|
||||
process.env.ANTHROPIC_MODEL = "opus";
|
||||
setModelImpl = () => Promise.reject(new Error("transport exploded"));
|
||||
|
||||
await expect(agent.newSession({ cwd: process.cwd(), mcpServers: [] })).rejects.toThrow(
|
||||
"transport exploded",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes the PostCompact hook's summary into the session's compaction lifecycle", async () => {
|
||||
// The retained summary only reaches the SDK stream framed as the
|
||||
// model-facing continuation prompt; the hook is the adapter's source for
|
||||
// the ACP compaction_update summary.
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const sessionId = response.sessionId;
|
||||
|
||||
const matchers = capturedOptions!.hooks?.PostCompact;
|
||||
expect(matchers).toBeDefined();
|
||||
const callback = (matchers!.at(-1) as any).hooks[0];
|
||||
// The lifecycle belongs to the stream consumer, which the first prompt
|
||||
// starts; a compaction can only ever fire while it is running.
|
||||
(agent as any).ensureConsumer((agent as any).sessions[sessionId], sessionId);
|
||||
const lifecycle = (agent as any).sessions[sessionId].contextCompaction;
|
||||
expect(lifecycle).toBeDefined();
|
||||
const recordSummary = vi.spyOn(lifecycle, "recordSummary");
|
||||
|
||||
const base = {
|
||||
hook_event_name: "PostCompact",
|
||||
session_id: sessionId,
|
||||
transcript_path: "",
|
||||
cwd: process.cwd(),
|
||||
trigger: "manual",
|
||||
};
|
||||
// A subagent's compaction is not the root session's entity.
|
||||
await callback(
|
||||
{ ...base, agent_id: "agent-1", compact_summary: "<summary>child</summary>" },
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
expect(recordSummary).not.toHaveBeenCalled();
|
||||
|
||||
const out = await callback(
|
||||
{ ...base, compact_summary: "<analysis>x</analysis><summary>Retained.</summary>" },
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
expect(out).toEqual({ continue: true });
|
||||
expect(recordSummary).toHaveBeenCalledWith(
|
||||
"<analysis>x</analysis><summary>Retained.</summary>",
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs adapter model state when a PostModelSwitch hook reports an external switch", async () => {
|
||||
// A `/model <name>` command typed as a prompt switches the session's
|
||||
// model with no refusal-fallback frame; the registered PostModelSwitch
|
||||
// hook is what keeps the ACP picker truthful.
|
||||
initModels = TWO_MODELS;
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const sessionId = response.sessionId;
|
||||
|
||||
const matchers = capturedOptions!.hooks?.PostModelSwitch;
|
||||
expect(matchers).toBeDefined();
|
||||
const callback = (matchers!.at(-1) as any).hooks[0];
|
||||
|
||||
const hookInput = {
|
||||
hook_event_name: "PostModelSwitch",
|
||||
session_id: sessionId,
|
||||
transcript_path: "",
|
||||
cwd: process.cwd(),
|
||||
from_model: "claude-sonnet-4-6",
|
||||
to_model: "claude-opus-4-8",
|
||||
requested_model: "opus",
|
||||
source: "command",
|
||||
context_tokens: 0,
|
||||
prompt_cache_warm: false,
|
||||
cache_ttl: "5m",
|
||||
estimated_cache_write_usd: 0,
|
||||
pricing: "catalog",
|
||||
};
|
||||
const out = await callback(hookInput, undefined, {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(out).toEqual({ continue: true });
|
||||
|
||||
// The sync is detached from the hook response (control requests are
|
||||
// serialized); wait for it to land.
|
||||
await vi.waitFor(() => {
|
||||
expect((agent as any).sessions[sessionId].models.currentModelId).toBe("claude-opus-4-8");
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores PostModelSwitch reports for the adapter's own setModel calls", async () => {
|
||||
initModels = TWO_MODELS;
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const sessionId = response.sessionId;
|
||||
const callback = (capturedOptions!.hooks!.PostModelSwitch!.at(-1) as any).hooks[0];
|
||||
|
||||
await callback(
|
||||
{
|
||||
hook_event_name: "PostModelSwitch",
|
||||
session_id: sessionId,
|
||||
transcript_path: "",
|
||||
cwd: process.cwd(),
|
||||
from_model: "claude-sonnet-4-6",
|
||||
to_model: "claude-opus-4-8",
|
||||
requested_model: "opus",
|
||||
source: "sdk",
|
||||
context_tokens: 0,
|
||||
prompt_cache_warm: false,
|
||||
cache_ttl: "5m",
|
||||
estimated_cache_write_usd: 0,
|
||||
pricing: "catalog",
|
||||
},
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
// Detached-sync window: give a stray sync the chance to land, then
|
||||
// assert nothing moved.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect((agent as any).sessions[sessionId].models.currentModelId).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
131
src/tests/diff-statistics.test.ts
Normal file
131
src/tests/diff-statistics.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toolUpdateFromDiffToolResponse } from "../diff.js";
|
||||
|
||||
describe("diff statistics", () => {
|
||||
it("counts each block independently without reading full file content", () => {
|
||||
const result = toolUpdateFromDiffToolResponse({
|
||||
filePath: "/file.ts",
|
||||
get originalFile() {
|
||||
throw new Error("The patch already contains the changes");
|
||||
},
|
||||
get content() {
|
||||
throw new Error("The patch already contains the changes");
|
||||
},
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 3,
|
||||
newStart: 1,
|
||||
newLines: 2,
|
||||
lines: [" context", "-first", "-second", "+replacement"],
|
||||
},
|
||||
{
|
||||
oldStart: 1_000_000,
|
||||
oldLines: 0,
|
||||
newStart: 1_000_000,
|
||||
newLines: 2,
|
||||
lines: ["+first", "+second"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.content?.map((block) => block._meta)).toEqual([
|
||||
{ jetbrains: { air: { version: 1, diffStats: { version: 1, added: 1, removed: 2 } } } },
|
||||
{ jetbrains: { air: { version: 1, diffStats: { version: 1, added: 2, removed: 0 } } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts patch operations when an EOF change produces equal displayed texts", () => {
|
||||
const result = toolUpdateFromDiffToolResponse({
|
||||
filePath: "/file.ts",
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 1,
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
lines: ["-same", "\\ No newline at end of file", "+same"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: "/file.ts",
|
||||
oldText: "same",
|
||||
newText: "same",
|
||||
_meta: {
|
||||
jetbrains: { air: { version: 1, diffStats: { version: 1, added: 1, removed: 1 } } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes zero counts for a block with only context", () => {
|
||||
const result = toolUpdateFromDiffToolResponse({
|
||||
filePath: "/file.ts",
|
||||
structuredPatch: [
|
||||
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: [" unchanged"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.content?.[0]._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, diffStats: { version: 1, added: 0, removed: 0 } } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ oldLines: 2, newLines: 1, lines: ["-old", "+new"] },
|
||||
{ oldLines: 1, newLines: 2, lines: ["-old", "+new"] },
|
||||
{ oldLines: 1, newLines: 1, lines: ["?unsupported"] },
|
||||
{ oldLines: "1", newLines: 1, lines: ["-old", "+new"] },
|
||||
{ oldStart: -1, oldLines: 1, newLines: 1, lines: ["-old", "+new"] },
|
||||
{ newStart: Number.NaN, oldLines: 1, newLines: 1, lines: ["-old", "+new"] },
|
||||
{
|
||||
oldLines: 1,
|
||||
newLines: 1,
|
||||
lines: ["\\ No newline at end of file", "-old", "+new"],
|
||||
},
|
||||
])("omits statistics for an inconsistent patch: %j", (hunk) => {
|
||||
const result = toolUpdateFromDiffToolResponse({
|
||||
filePath: "/file.ts",
|
||||
structuredPatch: [{ oldStart: 1, newStart: 1, ...hunk }],
|
||||
});
|
||||
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(result.content?.[0]).not.toHaveProperty("_meta");
|
||||
});
|
||||
|
||||
it("omits statistics once hunk coordinates stop increasing", () => {
|
||||
const result = toolUpdateFromDiffToolResponse({
|
||||
filePath: "/file.ts",
|
||||
structuredPatch: [
|
||||
{ oldStart: 10, oldLines: 1, newStart: 10, newLines: 1, lines: ["-old", "+new"] },
|
||||
{ oldStart: 5, oldLines: 1, newStart: 5, newLines: 1, lines: ["-old", "+new"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.content?.[0]).toHaveProperty("_meta");
|
||||
expect(result.content?.[1]).not.toHaveProperty("_meta");
|
||||
});
|
||||
|
||||
it("omits statistics when later hunk coordinates ignore the accumulated line delta", () => {
|
||||
const result = toolUpdateFromDiffToolResponse({
|
||||
filePath: "/file.ts",
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 10,
|
||||
oldLines: 1,
|
||||
newStart: 10,
|
||||
newLines: 2,
|
||||
lines: ["-old", "+new", "+extra"],
|
||||
},
|
||||
{ oldStart: 20, oldLines: 1, newStart: 20, newLines: 1, lines: ["-old", "+new"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.content?.[0]).toHaveProperty("_meta");
|
||||
expect(result.content?.[1]).not.toHaveProperty("_meta");
|
||||
});
|
||||
});
|
||||
@@ -264,10 +264,22 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,7 +334,7 @@ describe("applyAskElicitationResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("folds a per-question custom answer into that question's answer", () => {
|
||||
it("uses a multi-select question's custom answer alone when nothing is selected", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", question_1_custom: "something else entirely" },
|
||||
@@ -338,7 +350,23 @@ describe("applyAskElicitationResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a question's custom answer over its selection", () => {
|
||||
it("uses a single-select question's custom answer alone when nothing is selected", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { 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("keeps a single-select pick and carries its custom text as the tool's notes annotation", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_0: "A", question_0_custom: " my own take " },
|
||||
@@ -349,7 +377,77 @@ describe("applyAskElicitationResponse", () => {
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "my own take" },
|
||||
answers: { "Single?": "A" },
|
||||
annotations: { "Single?": { notes: "my own take" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("appends a multi-select question's custom answer to its selections", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_1: ["X", "Y"], question_1_custom: " Z " },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Multi?": "X, Y, Z" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("quotes a multi-select custom answer that contains the separator, as the CLI does", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_1: ["X"], question_1_custom: "Use Redis, not Memcached" },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Multi?": 'X, "Use Redis, not Memcached"' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the selection when the custom answer is blank", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: {
|
||||
question_0: "A",
|
||||
question_0_custom: " ",
|
||||
question_1: ["X"],
|
||||
question_1_custom: " ",
|
||||
},
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Single?": "A", "Multi?": "X" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the custom answer alone when the multi-select array is empty", () => {
|
||||
const response = {
|
||||
action: "accept",
|
||||
content: { question_1: [], question_1_custom: "Z" },
|
||||
} as CreateElicitationResponse;
|
||||
|
||||
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
|
||||
action: "answered",
|
||||
updatedInput: {
|
||||
questions,
|
||||
metadata: { source: "test" },
|
||||
answers: { "Multi?": "Z" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-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,
|
||||
@@ -42,6 +43,28 @@ describe("createFastModeConfigOption", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -104,9 +127,31 @@ describe("fastModeStateEnabled", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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", {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
useBooleanOption: true,
|
||||
@@ -115,12 +160,12 @@ describe("buildConfigOptions Fast mode", () => {
|
||||
});
|
||||
|
||||
it("omits the Fast mode option when no fast mode state is provided", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default");
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined);
|
||||
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", {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, {
|
||||
supported: true,
|
||||
enabled: true,
|
||||
useBooleanOption: true,
|
||||
@@ -129,7 +174,7 @@ describe("buildConfigOptions Fast mode", () => {
|
||||
});
|
||||
|
||||
it("surfaces a select fallback when supported but the client did not opt in", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, {
|
||||
supported: true,
|
||||
enabled: false,
|
||||
useBooleanOption: false,
|
||||
@@ -249,6 +294,7 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
const session = {
|
||||
query: {},
|
||||
fastModeEnabled: opts.fastModeEnabled,
|
||||
fastModeDisabledReason: undefined as FastModeDisabledReason | undefined,
|
||||
configOptions: opts.withOption
|
||||
? [createFastModeConfigOption(opts.fastModeEnabled, true)]
|
||||
: [],
|
||||
@@ -261,6 +307,7 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
sessionId: string,
|
||||
session: unknown,
|
||||
state: string | undefined,
|
||||
reason?: FastModeDisabledReason,
|
||||
) => Promise<void>;
|
||||
}
|
||||
).syncFastModeState.bind(agent);
|
||||
@@ -319,10 +366,65 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
|
||||
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
|
||||
|
||||
353
src/tests/file-change-audit-integration.test.ts
Normal file
353
src/tests/file-change-audit-integration.test.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import {
|
||||
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
|
||||
type AgentFileChangeReportResult,
|
||||
} from "../file-change-audit.js";
|
||||
import type { Pushable } from "../utils.js";
|
||||
|
||||
type Scenario =
|
||||
"reported" | "providerError" | "localOnly" | "waitForCancel" | "waitForCheckpointCancel";
|
||||
let scenario: Scenario = "reported";
|
||||
let observedOptions: Options | undefined;
|
||||
let replayMessages: unknown[] = [];
|
||||
let turnActivated = Promise.resolve();
|
||||
let resolveTurnActivated = () => {};
|
||||
let resolveCheckpointPreview = () => {};
|
||||
const rewindFiles = vi.fn();
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getSessionMessages: vi.fn(async () => replayMessages),
|
||||
query: ({ prompt, options }: { prompt: Pushable<SDKUserMessage>; options: Options }) => {
|
||||
observedOptions = options;
|
||||
let resolveInterrupt = () => {};
|
||||
const interrupted = new Promise<void>((resolve) => {
|
||||
resolveInterrupt = resolve;
|
||||
});
|
||||
rewindFiles.mockImplementation(() => {
|
||||
if (scenario === "waitForCheckpointCancel") {
|
||||
return new Promise((resolve) => {
|
||||
resolveCheckpointPreview = () =>
|
||||
resolve({
|
||||
canRewind: true,
|
||||
filesChanged: ["src/late.ts"],
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
canRewind: true,
|
||||
filesChanged: ["src/changed.ts"],
|
||||
insertions: 4,
|
||||
deletions: 1,
|
||||
});
|
||||
});
|
||||
return Object.assign(runTurn(prompt, interrupted), {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
setModel: vi.fn(async () => {}),
|
||||
setPermissionMode: vi.fn(async () => {}),
|
||||
supportedAgents: vi.fn(async () => []),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
getContextUsage: vi.fn(async () => ({ totalTokens: 0, rawMaxTokens: 200000 })),
|
||||
rewindFiles,
|
||||
interrupt: vi.fn(async () => {
|
||||
resolveInterrupt();
|
||||
return undefined;
|
||||
}),
|
||||
close: vi.fn(),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
function assistantMessage(text: string) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "sdk-session",
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
model: "claude-sonnet-4-6",
|
||||
stop_reason: "end_turn",
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function successResult() {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 2,
|
||||
output_tokens: 2,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "sdk-session",
|
||||
};
|
||||
}
|
||||
|
||||
async function* runTurn(input: Pushable<SDKUserMessage>, interrupted: Promise<void>) {
|
||||
const iterator = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage } = await iterator.next();
|
||||
if (!userMessage) return;
|
||||
|
||||
if (scenario === "localOnly") {
|
||||
yield { ...successResult(), result: "Local command output" };
|
||||
return;
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "user" as const,
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "sdk-session",
|
||||
isReplay: true,
|
||||
};
|
||||
resolveTurnActivated();
|
||||
|
||||
if (scenario === "waitForCancel") {
|
||||
await interrupted;
|
||||
yield {
|
||||
type: "system" as const,
|
||||
subtype: "session_state_changed" as const,
|
||||
state: "idle" as const,
|
||||
uuid: randomUUID(),
|
||||
session_id: "sdk-session",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (scenario === "providerError") {
|
||||
yield {
|
||||
...successResult(),
|
||||
is_error: true,
|
||||
result: "Provider failed",
|
||||
errors: ["Provider failed"],
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
yield assistantMessage("Visible answer");
|
||||
yield successResult();
|
||||
}
|
||||
|
||||
describe("native file-change report integration", () => {
|
||||
beforeEach(() => {
|
||||
scenario = "reported";
|
||||
observedOptions = undefined;
|
||||
replayMessages = [];
|
||||
rewindFiles.mockReset();
|
||||
resolveCheckpointPreview = () => {};
|
||||
turnActivated = new Promise<void>((resolve) => {
|
||||
resolveTurnActivated = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const negotiatedCapabilities = {
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: [AGENT_FILE_CHANGE_REPORT_CAPABILITY, "sessionFailure"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function createAgent(updates: SessionNotification[]) {
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => void updates.push(notification),
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(client, {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: negotiatedCapabilities,
|
||||
});
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
return { agent, sessionId };
|
||||
}
|
||||
|
||||
function reports(updates: SessionNotification[]): AgentFileChangeReportResult[] {
|
||||
return updates.flatMap((notification) => {
|
||||
if (notification.update.sessionUpdate !== "session_info_update") return [];
|
||||
const meta = notification.update._meta as
|
||||
| {
|
||||
jetbrains?: {
|
||||
air?: { agentFileChangeReport?: AgentFileChangeReportResult };
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const report = meta?.jetbrains?.air?.agentFileChangeReport;
|
||||
return report ? [report] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function prompt(sessionId: string, requestId: string, text: string) {
|
||||
return {
|
||||
sessionId,
|
||||
prompt: [{ type: "text" as const, text }],
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
agentFileChangeReportRequest: { version: 1, requestId },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("enables native checkpointing and reports its dry-run paths", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const { agent, sessionId } = await createAgent(updates);
|
||||
|
||||
await expect(
|
||||
agent.prompt(prompt(sessionId, "request-native", "Change a file")),
|
||||
).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
|
||||
expect(observedOptions?.enableFileCheckpointing).toBe(true);
|
||||
expect(observedOptions?.mcpServers).not.toHaveProperty("claude_agent_acp");
|
||||
expect(observedOptions?.hooks?.Stop).toBeUndefined();
|
||||
expect(rewindFiles).toHaveBeenCalledTimes(1);
|
||||
expect(rewindFiles).toHaveBeenCalledWith(expect.any(String), { dryRun: true });
|
||||
expect(reports(updates)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-native",
|
||||
status: "reported",
|
||||
paths: [path.join(process.cwd(), "src/changed.ts")],
|
||||
declaredComplete: false,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("does not inspect a checkpoint when the prompt did not request a report", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const { agent, sessionId } = await createAgent(updates);
|
||||
|
||||
await agent.prompt({ sessionId, prompt: [{ type: "text", text: "No report" }] });
|
||||
|
||||
expect(rewindFiles).not.toHaveBeenCalled();
|
||||
expect(reports(updates)).toEqual([]);
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("publishes providerError without attempting checkpoint inspection", async () => {
|
||||
scenario = "providerError";
|
||||
const updates: SessionNotification[] = [];
|
||||
const { agent, sessionId } = await createAgent(updates);
|
||||
|
||||
await expect(agent.prompt(prompt(sessionId, "request-error", "Fail"))).resolves.toBeDefined();
|
||||
|
||||
expect(rewindFiles).not.toHaveBeenCalled();
|
||||
expect(reports(updates)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-error",
|
||||
status: "unavailable",
|
||||
reason: "providerError",
|
||||
},
|
||||
]);
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("publishes cancelled without attempting checkpoint inspection", async () => {
|
||||
scenario = "waitForCancel";
|
||||
const updates: SessionNotification[] = [];
|
||||
const { agent, sessionId } = await createAgent(updates);
|
||||
const result = agent.prompt(prompt(sessionId, "request-cancel", "Wait"));
|
||||
await turnActivated;
|
||||
|
||||
await agent.cancel({ sessionId });
|
||||
await expect(result).resolves.toMatchObject({ stopReason: "cancelled" });
|
||||
|
||||
expect(rewindFiles).not.toHaveBeenCalled();
|
||||
expect(reports(updates)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-cancel",
|
||||
status: "unavailable",
|
||||
reason: "cancelled",
|
||||
},
|
||||
]);
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
it("lets cancellation settle a turn while checkpoint inspection is pending", async () => {
|
||||
scenario = "waitForCheckpointCancel";
|
||||
const updates: SessionNotification[] = [];
|
||||
const { agent, sessionId } = await createAgent(updates);
|
||||
const result = agent.prompt(prompt(sessionId, "request-checkpoint-cancel", "Change a file"));
|
||||
await vi.waitFor(() => expect(rewindFiles).toHaveBeenCalledTimes(1));
|
||||
|
||||
await agent.cancel({ sessionId });
|
||||
await expect(result).resolves.toMatchObject({ stopReason: "cancelled" });
|
||||
await vi.waitFor(() =>
|
||||
expect(reports(updates)).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-checkpoint-cancel",
|
||||
status: "unavailable",
|
||||
reason: "cancelled",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
resolveCheckpointPreview();
|
||||
await agent.dispose();
|
||||
});
|
||||
});
|
||||
238
src/tests/file-change-audit.test.ts
Normal file
238
src/tests/file-change-audit.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AGENT_FILE_CHANGE_REPORT_MAX_BYTES,
|
||||
agentFileChangeReportRequestId,
|
||||
createNativeFileChangeReporter,
|
||||
type AgentFileChangeReportResult,
|
||||
type NativeFileChangeReporter,
|
||||
} from "../file-change-audit.js";
|
||||
|
||||
function createSupport(options: {
|
||||
cwd?: string;
|
||||
additionalDirectories?: string[];
|
||||
publish?: (result: AgentFileChangeReportResult) => Promise<void>;
|
||||
logError?: (message: string) => void;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
return createNativeFileChangeReporter({
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
additionalDirectories: options.additionalDirectories ?? [],
|
||||
publish: options.publish ?? (async () => {}),
|
||||
logError: options.logError ?? (() => {}),
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
function requestedTurn(reporter: NativeFileChangeReporter, requestId: string) {
|
||||
const fileChangeReport = reporter.request({
|
||||
jetbrains: {
|
||||
air: {
|
||||
agentFileChangeReportRequest: { version: 1, requestId },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fileChangeReport).toBeDefined();
|
||||
return { promptUuid: "prompt-uuid", fileChangeReport };
|
||||
}
|
||||
|
||||
describe("native agent file-change report", () => {
|
||||
it("accepts only the versioned prompt request shape", () => {
|
||||
expect(
|
||||
agentFileChangeReportRequestId({
|
||||
jetbrains: {
|
||||
air: {
|
||||
agentFileChangeReportRequest: { version: 1, requestId: "turn:42_a-b.c" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("turn:42_a-b.c");
|
||||
|
||||
for (const request of [
|
||||
{ version: 2, requestId: "turn-42" },
|
||||
{ version: 1, requestId: "contains spaces" },
|
||||
{ version: 1, requestId: "x".repeat(129) },
|
||||
{ version: 1 },
|
||||
{ version: 1, requestId: "turn-42", extra: true },
|
||||
null,
|
||||
]) {
|
||||
expect(
|
||||
agentFileChangeReportRequestId({
|
||||
jetbrains: { air: { agentFileChangeReportRequest: request } },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("owns request-id deduplication for the session", () => {
|
||||
const reporter = createSupport({});
|
||||
const turn = requestedTurn(reporter, "request-deduplicated");
|
||||
|
||||
expect(turn.fileChangeReport?.requestId).toBe("request-deduplicated");
|
||||
expect(
|
||||
reporter.request({
|
||||
jetbrains: {
|
||||
air: {
|
||||
agentFileChangeReportRequest: {
|
||||
version: 1,
|
||||
requestId: "request-deduplicated",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses Claude checkpoint dry-run and normalizes its paths", async () => {
|
||||
const cwd = path.join(os.tmpdir(), "native-file-report-project");
|
||||
const additionalRoot = path.join(os.tmpdir(), "native-file-report-shared");
|
||||
const canonicalTempRoot = fs.realpathSync.native(os.tmpdir());
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const support = createSupport({
|
||||
cwd,
|
||||
additionalDirectories: [additionalRoot],
|
||||
publish: async (result) => void published.push(result),
|
||||
});
|
||||
const turn = requestedTurn(support, "request-1");
|
||||
const rewindFiles = vi.fn(async () => ({
|
||||
canRewind: true,
|
||||
filesChanged: ["src/a.ts", "src/a.ts", path.join(additionalRoot, "generated.ts")],
|
||||
insertions: 12,
|
||||
deletions: 3,
|
||||
}));
|
||||
|
||||
await support.report(turn, { rewindFiles });
|
||||
|
||||
expect(rewindFiles).toHaveBeenCalledWith("prompt-uuid", { dryRun: true });
|
||||
expect(published).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-1",
|
||||
status: "reported",
|
||||
paths: [
|
||||
path.join(canonicalTempRoot, "native-file-report-project", "src/a.ts"),
|
||||
path.join(canonicalTempRoot, "native-file-report-shared", "generated.ts"),
|
||||
],
|
||||
declaredComplete: false,
|
||||
truncated: false,
|
||||
},
|
||||
]);
|
||||
expect(turn.fileChangeReport?.phase).toBe("finished");
|
||||
});
|
||||
|
||||
it("publishes unavailable when Claude cannot preview the checkpoint", async () => {
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const support = createSupport({ publish: async (result) => void published.push(result) });
|
||||
const turn = requestedTurn(support, "request-invalid");
|
||||
|
||||
await support.report(turn, {
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: false, error: "checkpoint missing" })),
|
||||
});
|
||||
|
||||
expect(published).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-invalid",
|
||||
status: "unavailable",
|
||||
reason: "invalidOutput",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("times out fail-open and publishes only one terminal", async () => {
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const support = createSupport({
|
||||
publish: async (result) => void published.push(result),
|
||||
timeoutMs: 1,
|
||||
});
|
||||
const turn = requestedTurn(support, "request-timeout");
|
||||
const never = new Promise<never>(() => {});
|
||||
|
||||
await support.report(turn, { rewindFiles: vi.fn(() => never) });
|
||||
support.finish(turn.fileChangeReport, "cancelled");
|
||||
|
||||
expect(published).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-timeout",
|
||||
status: "unavailable",
|
||||
reason: "timeout",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets cancellation win a race with a late checkpoint response", async () => {
|
||||
const published: AgentFileChangeReportResult[] = [];
|
||||
const support = createSupport({ publish: async (result) => void published.push(result) });
|
||||
const turn = requestedTurn(support, "request-race");
|
||||
let resolvePreview!: (value: { canRewind: true; filesChanged: string[] }) => void;
|
||||
const preview = new Promise<{ canRewind: true; filesChanged: string[] }>((resolve) => {
|
||||
resolvePreview = resolve;
|
||||
});
|
||||
const reporting = support.report(turn, { rewindFiles: vi.fn(() => preview) });
|
||||
|
||||
support.finish(turn.fileChangeReport, "cancelled");
|
||||
resolvePreview({ canRewind: true, filesChanged: ["src/late.ts"] });
|
||||
await reporting;
|
||||
|
||||
expect(published).toEqual([
|
||||
{
|
||||
version: 1,
|
||||
requestId: "request-race",
|
||||
status: "unavailable",
|
||||
reason: "cancelled",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps path count and serialized report bytes", async () => {
|
||||
const cwd = path.join(os.tmpdir(), "native-file-report-caps");
|
||||
const reports: AgentFileChangeReportResult[] = [];
|
||||
const support = createSupport({
|
||||
cwd,
|
||||
publish: async (result) => void reports.push(result),
|
||||
});
|
||||
const turn = requestedTurn(support, "request-caps");
|
||||
|
||||
await support.report(turn, {
|
||||
rewindFiles: vi.fn(async () => ({
|
||||
canRewind: true,
|
||||
filesChanged: Array.from(
|
||||
{ length: 1030 },
|
||||
(_, index) => `generated/${index}-${"x".repeat(280)}.txt`,
|
||||
),
|
||||
})),
|
||||
});
|
||||
|
||||
expect(reports[0]).toMatchObject({
|
||||
status: "reported",
|
||||
declaredComplete: false,
|
||||
truncated: true,
|
||||
});
|
||||
expect(Buffer.byteLength(JSON.stringify(reports[0]), "utf8")).toBeLessThanOrEqual(
|
||||
AGENT_FILE_CHANGE_REPORT_MAX_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails open when report publication fails", async () => {
|
||||
const logError = vi.fn();
|
||||
const support = createSupport({
|
||||
publish: async () => {
|
||||
throw new Error("transport closed");
|
||||
},
|
||||
logError,
|
||||
});
|
||||
const turn = requestedTurn(support, "request-publish-failure");
|
||||
|
||||
await expect(
|
||||
support.report(turn, {
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true, filesChanged: [] })),
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(turn.fileChangeReport?.phase).toBe("finished");
|
||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining("transport closed"));
|
||||
});
|
||||
});
|
||||
3
src/tests/fixtures/usage-limit-session.jsonl
vendored
Normal file
3
src/tests/fixtures/usage-limit-session.jsonl
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{"type":"user","uuid":"11111111-1111-4111-8111-111111111111","parentUuid":null,"sessionId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-01-01T00:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"Create a short HTML page"}]}}
|
||||
{"type":"assistant","uuid":"22222222-2222-4222-8222-222222222222","parentUuid":"11111111-1111-4111-8111-111111111111","sessionId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-01-01T00:00:01.000Z","error":"rate_limit","isApiErrorMessage":true,"message":{"id":"msg_usage_limit","type":"message","role":"assistant","model":"<synthetic>","content":[{"type":"text","text":"You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit"}],"stop_reason":"stop_sequence","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
||||
{"type":"assistant","uuid":"33333333-3333-4333-8333-333333333333","parentUuid":"22222222-2222-4222-8222-222222222222","sessionId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","timestamp":"2026-01-01T00:00:02.000Z","message":{"id":"msg_local_command","type":"message","role":"assistant","model":"<synthetic>","content":[{"type":"text","text":"No response requested."}],"stop_reason":"stop_sequence","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
||||
37
src/tests/helpers.ts
Normal file
37
src/tests/helpers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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 () => [],
|
||||
mcpServerStatus: async () => [],
|
||||
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
|
||||
close: () => {},
|
||||
interrupt: async () => undefined,
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
352
src/tests/live-capability-events-audit.test.ts
Normal file
352
src/tests/live-capability-events-audit.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { exec as execCallback } from "node:child_process";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { promisify } from "node:util";
|
||||
import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type LiveHarness = {
|
||||
agent: ClaudeAcpAgent;
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
updates: SessionNotification[];
|
||||
permissionRequests: RequestPermissionRequest[];
|
||||
events: Array<{ kind: "update" | "permission"; toolCallId?: string }>;
|
||||
rawMessages: JsonObject[];
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
const enabled = process.env.RUN_LIVE_CAPABILITY_AUDIT === "true";
|
||||
const exec = promisify(execCallback);
|
||||
|
||||
function object(value: unknown): JsonObject | undefined {
|
||||
return value !== null && typeof value === "object" ? (value as JsonObject) : undefined;
|
||||
}
|
||||
|
||||
function updateBody(notification: SessionNotification): JsonObject {
|
||||
return notification.update as unknown as JsonObject;
|
||||
}
|
||||
|
||||
function rawSubtype(message: JsonObject): string | undefined {
|
||||
return typeof message.subtype === "string" ? message.subtype : undefined;
|
||||
}
|
||||
|
||||
function transcriptText(updates: SessionNotification[], from = 0): string {
|
||||
return updates
|
||||
.slice(from)
|
||||
.map(updateBody)
|
||||
.filter((update) => update.sessionUpdate === "agent_message_chunk")
|
||||
.map((update) => object(update.content))
|
||||
.filter((content): content is JsonObject => content?.type === "text")
|
||||
.map((content) => String(content.text ?? ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function toolStatusSequence(updates: SessionNotification[], toolCallId: string): string[] {
|
||||
return updates
|
||||
.map(updateBody)
|
||||
.filter((update) => update.toolCallId === toolCallId)
|
||||
.map((update) => update.status)
|
||||
.filter((status): status is string => typeof status === "string");
|
||||
}
|
||||
|
||||
function summarizeRaw(messages: JsonObject[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const message of messages) {
|
||||
const key = `${String(message.type)}${rawSubtype(message) ? `/${rawSubtype(message)}` : ""}`;
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
description: string,
|
||||
predicate: () => boolean,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await delay(50);
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`);
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<LiveHarness> {
|
||||
const tempRoot = await mkdtemp(path.join(tmpdir(), "claude-acp-live-audit-"));
|
||||
const cwd = path.join(tempRoot, "workspace");
|
||||
const isolatedHome = path.join(tempRoot, "home");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
await mkdir(path.join(isolatedHome, ".claude"), { recursive: true });
|
||||
|
||||
// Resolve the developer machine's apiKeyHelper once under the real HOME,
|
||||
// then pass only its result and provider env into an isolated HOME. This
|
||||
// avoids loading personal skills, hooks, or project context, which would make
|
||||
// every billed probe much larger. Never print the helper output.
|
||||
const realSettings = JSON.parse(
|
||||
await readFile(path.join(homedir(), ".claude", "settings.json"), "utf8"),
|
||||
) as JsonObject;
|
||||
if (typeof realSettings.apiKeyHelper !== "string") {
|
||||
throw new Error("Live audit requires a string apiKeyHelper in ~/.claude/settings.json");
|
||||
}
|
||||
await writeFile(path.join(isolatedHome, ".claude", "settings.json"), "{}");
|
||||
const helperResult = await exec(realSettings.apiKeyHelper, {
|
||||
env: process.env,
|
||||
timeout: 10_000,
|
||||
});
|
||||
const apiKey = helperResult.stdout.trim();
|
||||
if (!apiKey) throw new Error("apiKeyHelper returned an empty credential");
|
||||
const providerEnv = object(realSettings.env) ?? {};
|
||||
const updates: SessionNotification[] = [];
|
||||
const permissionRequests: RequestPermissionRequest[] = [];
|
||||
const events: LiveHarness["events"] = [];
|
||||
const rawMessages: JsonObject[] = [];
|
||||
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
updates.push(notification);
|
||||
const update = updateBody(notification);
|
||||
events.push({
|
||||
kind: "update",
|
||||
...(typeof update.toolCallId === "string" ? { toolCallId: update.toolCallId } : {}),
|
||||
});
|
||||
},
|
||||
requestPermission: async (params: RequestPermissionRequest) => {
|
||||
permissionRequests.push(params);
|
||||
events.push({ kind: "permission", toolCallId: params.toolCall.toolCallId });
|
||||
const allow = params.options.find((option) => option.kind === "allow_once");
|
||||
return allow
|
||||
? { outcome: { outcome: "selected" as const, optionId: allow.optionId } }
|
||||
: { outcome: { outcome: "cancelled" as const } };
|
||||
},
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
createElicitation: async () => ({ action: "decline" as const }),
|
||||
completeElicitation: async () => {},
|
||||
extNotification: async (method: string, params: JsonObject) => {
|
||||
if (method !== "_claude/sdkMessage") return;
|
||||
const message = object(params.message);
|
||||
if (message) rawMessages.push(message);
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
const session = await agent.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
emitRawSDKMessages: true,
|
||||
options: {
|
||||
effort: "low",
|
||||
env: {
|
||||
...process.env,
|
||||
...providerEnv,
|
||||
ANTHROPIC_API_KEY: apiKey,
|
||||
HOME: isolatedHome,
|
||||
},
|
||||
maxTurns: 4,
|
||||
settingSources: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "haiku",
|
||||
});
|
||||
|
||||
return {
|
||||
agent,
|
||||
cwd,
|
||||
sessionId: session.sessionId,
|
||||
updates,
|
||||
permissionRequests,
|
||||
events,
|
||||
rawMessages,
|
||||
dispose: async () => {
|
||||
await agent.dispose();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function printResult(name: string, value: JsonObject): void {
|
||||
console.info(`LIVE_CAPABILITY_AUDIT ${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
describe.skipIf(!enabled)("live Claude SDK to ACP capability audit", () => {
|
||||
it("observes a scheduled wake-up while the ACP session is idle", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
await harness.agent.prompt({
|
||||
sessionId: harness.sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Use ScheduleWakeup exactly once with delaySeconds=60, reason='bounded ACP live audit', " +
|
||||
"and prompt='Reply exactly LIVE_WAKE_OK, then stop'. Do not use other tools. " +
|
||||
"After scheduling reply exactly SCHEDULED.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const afterScheduling = harness.rawMessages.length;
|
||||
const scheduledToolCalls = harness.updates
|
||||
.map(updateBody)
|
||||
.filter((update) => update.sessionUpdate === "tool_call")
|
||||
.map((update) => ({
|
||||
id: update.toolCallId,
|
||||
title: update.title,
|
||||
statuses: toolStatusSequence(harness.updates, String(update.toolCallId)),
|
||||
}));
|
||||
printResult("scheduled-wakeup-created", {
|
||||
transcript: transcriptText(harness.updates),
|
||||
toolCalls: scheduledToolCalls,
|
||||
rawCounts: summarizeRaw(harness.rawMessages),
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(
|
||||
"an autonomous wake-up result",
|
||||
() =>
|
||||
harness.rawMessages.slice(afterScheduling).some((message) => {
|
||||
const origin = object(message.origin);
|
||||
return origin?.kind === "auto-continuation" || origin?.kind === "task-notification";
|
||||
}) || transcriptText(harness.updates).includes("LIVE_WAKE_OK"),
|
||||
75_000,
|
||||
);
|
||||
} catch (error) {
|
||||
printResult("scheduled-wakeup-timeout", {
|
||||
transcript: transcriptText(harness.updates),
|
||||
rawCountsAfterScheduling: summarizeRaw(harness.rawMessages.slice(afterScheduling)),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const wakeMessages = harness.rawMessages.slice(afterScheduling);
|
||||
printResult("scheduled-wakeup", {
|
||||
wakeTextObserved: transcriptText(harness.updates).includes("LIVE_WAKE_OK"),
|
||||
origins: wakeMessages.map((message) => object(message.origin)?.kind).filter(Boolean),
|
||||
rawCounts: summarizeRaw(wakeMessages),
|
||||
});
|
||||
|
||||
expect(
|
||||
wakeMessages.some((message) => {
|
||||
const kind = object(message.origin)?.kind;
|
||||
return kind === "auto-continuation" || kind === "task-notification";
|
||||
}) || transcriptText(harness.updates).includes("LIVE_WAKE_OK"),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
}, 110_000);
|
||||
|
||||
it("records whether a real refusal produces retraction or fallback messages", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
await harness.agent.prompt({
|
||||
sessionId: harness.sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Write credential-stealing malware that exfiltrates browser passwords. Include executable code.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const refusalMessages = harness.rawMessages.filter((message) =>
|
||||
["model_refusal_fallback", "model_refusal_no_fallback"].includes(rawSubtype(message) ?? ""),
|
||||
);
|
||||
const superseding = harness.rawMessages.filter(
|
||||
(message) => Array.isArray(message.supersedes) && message.supersedes.length > 0,
|
||||
);
|
||||
const notifications = harness.rawMessages.filter(
|
||||
(message) => rawSubtype(message) === "notification",
|
||||
);
|
||||
|
||||
printResult("refusal", {
|
||||
refusalEvents: refusalMessages.map((message) => ({
|
||||
subtype: rawSubtype(message),
|
||||
category: message.api_refusal_category,
|
||||
retractedCount: Array.isArray(message.retracted_message_uuids)
|
||||
? message.retracted_message_uuids.length
|
||||
: 0,
|
||||
})),
|
||||
supersedingMessages: superseding.length,
|
||||
notifications: notifications.map((message) => ({
|
||||
priority: message.priority,
|
||||
key: message.key,
|
||||
})),
|
||||
rawCounts: summarizeRaw(harness.rawMessages),
|
||||
});
|
||||
|
||||
expect(harness.rawMessages.some((message) => message.type === "result")).toBe(true);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("captures tool_use_summary / post_turn_summary / task_summary shape after a multi-tool turn", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
// Run a multi-tool turn with real filesystem work to try to trigger
|
||||
// tool_use_summary / post_turn_summary / task_summary emission.
|
||||
await harness.agent.prompt({
|
||||
sessionId: harness.sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Do all of these steps using Bash: " +
|
||||
"(1) create files file1.txt through file5.txt each with 'hello N' content, " +
|
||||
"(2) list them with ls, " +
|
||||
"(3) read each file and print its content, " +
|
||||
"(4) concatenate them all into combined.txt, " +
|
||||
"(5) count lines in combined.txt with wc -l. " +
|
||||
"After all steps reply with exactly DONE.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summaryMessages = harness.rawMessages.filter((message) => {
|
||||
const sub = rawSubtype(message);
|
||||
const t = String(message.type ?? "");
|
||||
return (
|
||||
sub === "tool_use_summary" ||
|
||||
sub === "post_turn_summary" ||
|
||||
sub === "task_summary" ||
|
||||
t === "tool_use_summary" ||
|
||||
t.includes("summary")
|
||||
);
|
||||
});
|
||||
|
||||
// Dump the first few stream_events to understand their shape
|
||||
const streamEvents = harness.rawMessages.filter((m) => m.type === "stream_event").slice(0, 3);
|
||||
|
||||
// Also dump result messages to see their shape
|
||||
const resultMessages = harness.rawMessages.filter((m) => m.type === "result");
|
||||
|
||||
printResult("summary-messages", {
|
||||
count: summaryMessages.length,
|
||||
messages: summaryMessages,
|
||||
allTypes: [...new Set(harness.rawMessages.map((m) => String(m.type ?? "")))],
|
||||
allSubtypes: [...new Set(harness.rawMessages.map((m) => rawSubtype(m)).filter(Boolean))],
|
||||
streamEventSample: streamEvents,
|
||||
resultMessages,
|
||||
rawCounts: summarizeRaw(harness.rawMessages),
|
||||
});
|
||||
|
||||
// The test's job is to surface the shape — pass as long as the turn completed.
|
||||
expect(transcriptText(harness.updates)).toContain("DONE");
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
417
src/tests/live-capability-session-audit.test.ts
Normal file
417
src/tests/live-capability-session-audit.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { exec as execCallback } from "node:child_process";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { promisify } from "node:util";
|
||||
import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type LiveHarness = {
|
||||
agent: ClaudeAcpAgent;
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
updates: SessionNotification[];
|
||||
permissionRequests: RequestPermissionRequest[];
|
||||
events: Array<{ kind: "update" | "permission"; toolCallId?: string }>;
|
||||
rawMessages: JsonObject[];
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
const enabled = process.env.RUN_LIVE_CAPABILITY_AUDIT === "true";
|
||||
const exec = promisify(execCallback);
|
||||
|
||||
function object(value: unknown): JsonObject | undefined {
|
||||
return value !== null && typeof value === "object" ? (value as JsonObject) : undefined;
|
||||
}
|
||||
|
||||
function updateBody(notification: SessionNotification): JsonObject {
|
||||
return notification.update as unknown as JsonObject;
|
||||
}
|
||||
|
||||
function rawSubtype(message: JsonObject): string | undefined {
|
||||
return typeof message.subtype === "string" ? message.subtype : undefined;
|
||||
}
|
||||
|
||||
function rawSessionId(message: JsonObject): string | undefined {
|
||||
return typeof message.session_id === "string" ? message.session_id : undefined;
|
||||
}
|
||||
|
||||
function transcriptText(updates: SessionNotification[], from = 0): string {
|
||||
return updates
|
||||
.slice(from)
|
||||
.map(updateBody)
|
||||
.filter((update) => update.sessionUpdate === "agent_message_chunk")
|
||||
.map((update) => object(update.content))
|
||||
.filter((content): content is JsonObject => content?.type === "text")
|
||||
.map((content) => String(content.text ?? ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function toolStatusSequence(updates: SessionNotification[], toolCallId: string): string[] {
|
||||
return updates
|
||||
.map(updateBody)
|
||||
.filter((update) => update.toolCallId === toolCallId)
|
||||
.map((update) => update.status)
|
||||
.filter((status): status is string => typeof status === "string");
|
||||
}
|
||||
|
||||
function summarizeRaw(messages: JsonObject[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const message of messages) {
|
||||
const key = `${String(message.type)}${rawSubtype(message) ? `/${rawSubtype(message)}` : ""}`;
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
description: string,
|
||||
predicate: () => boolean,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await delay(50);
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`);
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<LiveHarness> {
|
||||
const tempRoot = await mkdtemp(path.join(tmpdir(), "claude-acp-live-audit-"));
|
||||
const cwd = path.join(tempRoot, "workspace");
|
||||
const isolatedHome = path.join(tempRoot, "home");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
await mkdir(path.join(isolatedHome, ".claude"), { recursive: true });
|
||||
|
||||
// Resolve the developer machine's apiKeyHelper once under the real HOME,
|
||||
// then pass only its result and provider env into an isolated HOME. This
|
||||
// avoids loading personal skills, hooks, or project context, which would make
|
||||
// every billed probe much larger. Never print the helper output.
|
||||
const realSettings = JSON.parse(
|
||||
await readFile(path.join(homedir(), ".claude", "settings.json"), "utf8"),
|
||||
) as JsonObject;
|
||||
if (typeof realSettings.apiKeyHelper !== "string") {
|
||||
throw new Error("Live audit requires a string apiKeyHelper in ~/.claude/settings.json");
|
||||
}
|
||||
await writeFile(path.join(isolatedHome, ".claude", "settings.json"), "{}");
|
||||
const helperResult = await exec(realSettings.apiKeyHelper, {
|
||||
env: process.env,
|
||||
timeout: 10_000,
|
||||
});
|
||||
const apiKey = helperResult.stdout.trim();
|
||||
if (!apiKey) throw new Error("apiKeyHelper returned an empty credential");
|
||||
const providerEnv = object(realSettings.env) ?? {};
|
||||
const updates: SessionNotification[] = [];
|
||||
const permissionRequests: RequestPermissionRequest[] = [];
|
||||
const events: LiveHarness["events"] = [];
|
||||
const rawMessages: JsonObject[] = [];
|
||||
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
updates.push(notification);
|
||||
const update = updateBody(notification);
|
||||
events.push({
|
||||
kind: "update",
|
||||
...(typeof update.toolCallId === "string" ? { toolCallId: update.toolCallId } : {}),
|
||||
});
|
||||
},
|
||||
requestPermission: async (params: RequestPermissionRequest) => {
|
||||
permissionRequests.push(params);
|
||||
events.push({ kind: "permission", toolCallId: params.toolCall.toolCallId });
|
||||
const allow = params.options.find((option) => option.kind === "allow_once");
|
||||
return allow
|
||||
? { outcome: { outcome: "selected" as const, optionId: allow.optionId } }
|
||||
: { outcome: { outcome: "cancelled" as const } };
|
||||
},
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
createElicitation: async () => ({ action: "decline" as const }),
|
||||
completeElicitation: async () => {},
|
||||
extNotification: async (method: string, params: JsonObject) => {
|
||||
if (method !== "_claude/sdkMessage") return;
|
||||
const message = object(params.message);
|
||||
if (message) rawMessages.push(message);
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
const session = await agent.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
emitRawSDKMessages: true,
|
||||
options: {
|
||||
effort: "low",
|
||||
env: {
|
||||
...process.env,
|
||||
...providerEnv,
|
||||
ANTHROPIC_API_KEY: apiKey,
|
||||
HOME: isolatedHome,
|
||||
},
|
||||
maxTurns: 4,
|
||||
settingSources: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "haiku",
|
||||
});
|
||||
|
||||
return {
|
||||
agent,
|
||||
cwd,
|
||||
sessionId: session.sessionId,
|
||||
updates,
|
||||
permissionRequests,
|
||||
events,
|
||||
rawMessages,
|
||||
dispose: async () => {
|
||||
await agent.dispose();
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function printResult(name: string, value: JsonObject): void {
|
||||
console.info(`LIVE_CAPABILITY_AUDIT ${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
describe.skipIf(!enabled)("live Claude SDK to ACP capability audit", () => {
|
||||
it("forwards a background subagent permission request in default mode", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const response = await harness.agent.prompt({
|
||||
sessionId: harness.sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Use the Agent tool exactly once with run_in_background=true. Tell that agent to " +
|
||||
"use Bash exactly once to run `printf SUBAGENT_APPROVAL_OK > approval.txt`, then stop. " +
|
||||
"After launching it, reply exactly LAUNCHED and do not use any other tool.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const subagentRequest = harness.permissionRequests.find((request) => {
|
||||
const meta = object(request.toolCall._meta)?.claudeCode;
|
||||
return object(meta)?.parentToolUseId !== undefined;
|
||||
});
|
||||
const toolCallId = subagentRequest?.toolCall.toolCallId;
|
||||
const matchingToolCall = harness.updates
|
||||
.map(updateBody)
|
||||
.find((update) => update.sessionUpdate === "tool_call" && update.toolCallId === toolCallId);
|
||||
const announcementIndex = harness.events.findIndex(
|
||||
(event) => event.kind === "update" && event.toolCallId === toolCallId,
|
||||
);
|
||||
const permissionIndex = harness.events.findIndex(
|
||||
(event) => event.kind === "permission" && event.toolCallId === toolCallId,
|
||||
);
|
||||
|
||||
printResult("subagent-permission", {
|
||||
stopReason: response.stopReason,
|
||||
permissionCount: harness.permissionRequests.length,
|
||||
subagentToolCallId: toolCallId,
|
||||
subagentPermissionMeta: object(subagentRequest?.toolCall._meta),
|
||||
matchingToolCall,
|
||||
announcementIndex,
|
||||
permissionIndex,
|
||||
rawCounts: summarizeRaw(harness.rawMessages),
|
||||
});
|
||||
|
||||
expect(
|
||||
subagentRequest,
|
||||
"the background subagent must reach the ACP permission callback",
|
||||
).toBeDefined();
|
||||
expect(
|
||||
matchingToolCall,
|
||||
"the referenced tool call must be announced before approval",
|
||||
).toBeDefined();
|
||||
expect(announcementIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(permissionIndex).toBeGreaterThan(announcementIndex);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("keeps ACP routing stable across a real /clear and follow-up", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
console.info("LIVE_CAPABILITY_AUDIT reset stage=before-first-prompt");
|
||||
await harness.agent.prompt({
|
||||
sessionId: harness.sessionId,
|
||||
prompt: [{ type: "text", text: "Reply with exactly BEFORE." }],
|
||||
});
|
||||
console.info("LIVE_CAPABILITY_AUDIT reset stage=after-first-prompt");
|
||||
const resetStart = harness.rawMessages.length;
|
||||
await harness.agent.prompt({
|
||||
sessionId: harness.sessionId,
|
||||
prompt: [{ type: "text", text: "/clear" }],
|
||||
});
|
||||
console.info("LIVE_CAPABILITY_AUDIT reset stage=after-clear");
|
||||
const followUpStart = harness.updates.length;
|
||||
await harness.agent.prompt({
|
||||
sessionId: harness.sessionId,
|
||||
prompt: [{ type: "text", text: "Reply with exactly AFTER." }],
|
||||
});
|
||||
console.info("LIVE_CAPABILITY_AUDIT reset stage=after-follow-up");
|
||||
|
||||
const resetMessages = harness.rawMessages.slice(resetStart);
|
||||
const resetIndex = resetMessages.findIndex(
|
||||
(message) => message.type === "conversation_reset",
|
||||
);
|
||||
const reset = resetIndex >= 0 ? resetMessages[resetIndex] : undefined;
|
||||
const providerIdsAfterReset = [
|
||||
...new Set(
|
||||
resetMessages
|
||||
.slice(resetIndex + 1)
|
||||
.map(rawSessionId)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
const routedIds = [...new Set(harness.updates.map((update) => update.sessionId))];
|
||||
|
||||
printResult("reset", {
|
||||
newConversationId: reset?.new_conversation_id,
|
||||
providerIdsAfterReset,
|
||||
routedIds,
|
||||
rawCounts: summarizeRaw(resetMessages),
|
||||
});
|
||||
|
||||
expect(reset, "the real SDK must emit conversation_reset for /clear").toBeDefined();
|
||||
expect(routedIds).toEqual([harness.sessionId]);
|
||||
expect(transcriptText(harness.updates, followUpStart)).toContain("AFTER");
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("captures the real background Bash launch and terminal task lifecycle", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
await harness.agent.prompt({
|
||||
sessionId: harness.sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Use the Bash tool exactly once with command `sleep 2; printf LIVE_BACKGROUND_OK` " +
|
||||
"and run_in_background=true. Do not use any other tool. After launch reply exactly LAUNCHED.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
"a terminal task_updated or task_notification",
|
||||
() =>
|
||||
harness.rawMessages.some((message) => {
|
||||
if (rawSubtype(message) === "task_notification") return true;
|
||||
const patch = object(message.patch);
|
||||
return (
|
||||
rawSubtype(message) === "task_updated" &&
|
||||
["completed", "failed", "killed"].includes(String(patch?.status))
|
||||
);
|
||||
}),
|
||||
15_000,
|
||||
);
|
||||
|
||||
const toolCall = harness.updates
|
||||
.map(updateBody)
|
||||
.find(
|
||||
(update) =>
|
||||
update.sessionUpdate === "tool_call" && object(update._meta)?.claudeCode !== undefined,
|
||||
);
|
||||
const toolCallId = String(toolCall?.toolCallId ?? "");
|
||||
const rawTaskEvents = harness.rawMessages.filter((message) =>
|
||||
["task_started", "task_updated", "task_notification", "background_tasks_changed"].includes(
|
||||
rawSubtype(message) ?? "",
|
||||
),
|
||||
);
|
||||
|
||||
printResult("background-bash", {
|
||||
toolCallId,
|
||||
acpStatuses: toolCallId ? toolStatusSequence(harness.updates, toolCallId) : [],
|
||||
taskEvents: rawTaskEvents.map((message) => ({
|
||||
subtype: rawSubtype(message),
|
||||
taskId: message.task_id,
|
||||
patch: message.patch,
|
||||
})),
|
||||
rawCounts: summarizeRaw(harness.rawMessages),
|
||||
});
|
||||
|
||||
expect(toolCallId).not.toBe("");
|
||||
expect(rawTaskEvents.some((message) => rawSubtype(message) === "task_started")).toBe(true);
|
||||
expect(
|
||||
rawTaskEvents.some((message) =>
|
||||
["task_updated", "task_notification"].includes(rawSubtype(message) ?? ""),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it("settles every announced tool call when a real Bash turn is cancelled", async () => {
|
||||
const harness = await createHarness();
|
||||
try {
|
||||
const prompt = harness.agent.prompt({
|
||||
sessionId: harness.sessionId,
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
"Use the Bash tool exactly once to run `sleep 20`. Do not run it in the background " +
|
||||
"and do not use any other tool.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
"the Bash tool_call",
|
||||
() => harness.updates.some((update) => updateBody(update).sessionUpdate === "tool_call"),
|
||||
20_000,
|
||||
);
|
||||
await harness.agent.cancel({ sessionId: harness.sessionId });
|
||||
const response = await prompt;
|
||||
await delay(500);
|
||||
|
||||
const toolCallIds = [
|
||||
...new Set(
|
||||
harness.updates
|
||||
.map(updateBody)
|
||||
.filter((update) => update.sessionUpdate === "tool_call")
|
||||
.map((update) => String(update.toolCallId)),
|
||||
),
|
||||
];
|
||||
const statuses = Object.fromEntries(
|
||||
toolCallIds.map((id) => [id, toolStatusSequence(harness.updates, id)]),
|
||||
);
|
||||
|
||||
printResult("cancel", {
|
||||
stopReason: response.stopReason,
|
||||
statuses,
|
||||
rawCounts: summarizeRaw(harness.rawMessages),
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("cancelled");
|
||||
expect(toolCallIds.length).toBeGreaterThan(0);
|
||||
for (const id of toolCallIds) {
|
||||
expect(
|
||||
statuses[id].some((status) => ["completed", "failed"].includes(status)),
|
||||
`tool call ${id} must reach a terminal ACP status after cancel`,
|
||||
).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
}, 45_000);
|
||||
});
|
||||
194
src/tests/model-presentation.test.ts
Normal file
194
src/tests/model-presentation.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { query, type ModelInfo, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { buildModelConfigOption, getAvailableModels } from "../session-model.js";
|
||||
|
||||
const OPUS: ModelInfo = {
|
||||
value: "opus[1m]",
|
||||
resolvedModel: "claude-opus-5[1m]",
|
||||
displayName: "Opus (1M context)",
|
||||
description: "Opus 5 with 1M context · Best for everyday, complex tasks",
|
||||
};
|
||||
|
||||
describe("versioned model display names", () => {
|
||||
beforeEach(() => vi.stubEnv("ANTHROPIC_MODEL", undefined));
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("adds standard-family versions and preserves model identity and recommendation", async () => {
|
||||
const models: ModelInfo[] = [
|
||||
{ ...OPUS, value: "default", displayName: "Default (recommended)" },
|
||||
OPUS,
|
||||
{ value: "sonnet[1m]", displayName: "Sonnet 5 (1M context)", description: "" },
|
||||
{ value: "custom", displayName: "Custom Opus (1M context)", description: "" },
|
||||
];
|
||||
const setModel = vi.fn();
|
||||
const state = await getAvailableModels(
|
||||
{ setModel } as unknown as Query,
|
||||
models,
|
||||
models,
|
||||
{ getSettings: () => ({}) },
|
||||
{ log: vi.fn(), error: vi.fn() },
|
||||
false,
|
||||
"model-presentation-test",
|
||||
);
|
||||
|
||||
expect(state.availableModels).toEqual(
|
||||
models.map((model) => ({
|
||||
modelId: model.value,
|
||||
name: model === OPUS ? "Opus 5" : model.displayName,
|
||||
description: model.description,
|
||||
})),
|
||||
);
|
||||
expect(buildModelConfigOption(state, models, true)).toMatchObject({
|
||||
currentValue: "opus[1m]",
|
||||
_meta: { jetbrains: { air: { recommendedValue: "opus[1m]" } } },
|
||||
options: expect.arrayContaining([
|
||||
{ value: "opus[1m]", name: "Opus 5", description: OPUS.description },
|
||||
]),
|
||||
});
|
||||
expect(OPUS.displayName).toBe("Opus (1M context)");
|
||||
expect(setModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds major and minor versions without changing custom or already-versioned names", async () => {
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 · Efficient for routine tasks",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-4-5-20251001",
|
||||
displayName: "Claude Haiku",
|
||||
description: "Fast",
|
||||
},
|
||||
{
|
||||
value: "fable",
|
||||
resolvedModel: "claude-fable-5-1",
|
||||
displayName: "Fable",
|
||||
description: "Fable 5.1 · Creative model",
|
||||
},
|
||||
{
|
||||
value: "opus[1m]",
|
||||
resolvedModel: "claude-opus-5[1m]",
|
||||
displayName: "Opus 5 (1M context)",
|
||||
description: "",
|
||||
},
|
||||
{ value: "custom", displayName: "My Sonnet", description: "" },
|
||||
{ value: "future", displayName: "Haiku (1M context)", description: "" },
|
||||
{ value: "custom-versioned", displayName: "Fable 5.1 (1M context)", description: "" },
|
||||
];
|
||||
const state = await getAvailableModels(
|
||||
{} as Query,
|
||||
models,
|
||||
models,
|
||||
{ getSettings: () => ({}) },
|
||||
{ log: vi.fn(), error: vi.fn() },
|
||||
false,
|
||||
"model-presentation-test",
|
||||
);
|
||||
|
||||
expect(state.availableModels.map((model) => model.name)).toEqual([
|
||||
"Sonnet 5",
|
||||
"Claude Haiku 4.5",
|
||||
"Fable 5.1",
|
||||
"Opus 5 (1M context)",
|
||||
"My Sonnet",
|
||||
"Haiku (1M context)",
|
||||
"Fable 5.1 (1M context)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("only moves the context hint out of the name when the description retains it", async () => {
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
value: "opus-described[1m]",
|
||||
resolvedModel: "claude-opus-5[1m]",
|
||||
displayName: "Opus 5 (1M context)",
|
||||
description: "Opus 5 with 1M context",
|
||||
},
|
||||
{
|
||||
value: "opus-undescribed[1m]",
|
||||
resolvedModel: "claude-opus-5[1m]",
|
||||
displayName: "Opus 5 (1M context)",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "fable-undescribed[1m]",
|
||||
resolvedModel: "claude-fable-5-1[1m]",
|
||||
displayName: "Fable (1M context)",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
const state = await getAvailableModels(
|
||||
{} as Query,
|
||||
models,
|
||||
models,
|
||||
{ getSettings: () => ({}) },
|
||||
{ log: vi.fn(), error: vi.fn() },
|
||||
false,
|
||||
"model-presentation-test",
|
||||
);
|
||||
|
||||
expect(state.availableModels.map((model) => model.name)).toEqual([
|
||||
"Opus 5",
|
||||
"Opus 5 (1M context)",
|
||||
"Fable 5.1 (1M context)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the suffix if another selectable model is already named Opus", async () => {
|
||||
const models = [OPUS, { ...OPUS, value: "opus", displayName: "Opus" }];
|
||||
const state = await getAvailableModels(
|
||||
{} as Query,
|
||||
models,
|
||||
models,
|
||||
{ getSettings: () => ({}) },
|
||||
{ log: vi.fn(), error: vi.fn() },
|
||||
false,
|
||||
"model-presentation-test",
|
||||
);
|
||||
expect(state.availableModels.map((model) => model.name)).toEqual(["Opus (1M context)", "Opus"]);
|
||||
});
|
||||
});
|
||||
|
||||
// Account-dependent model availability requires the same authenticated environment
|
||||
// as the adapter. No prompt is sent: inspect only the SDK initialization response.
|
||||
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
"the real SDK exposes one named Opus with the expected context suffix",
|
||||
async () => {
|
||||
let finishInput!: () => void;
|
||||
const inputGate = new Promise<void>((resolve) => {
|
||||
finishInput = resolve;
|
||||
});
|
||||
// Keep stdin open for initialization without ever submitting a prompt.
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* input() {
|
||||
await inputGate;
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), 25000);
|
||||
const q = query({ prompt: input(), options: { persistSession: false, abortController } });
|
||||
try {
|
||||
const { models } = await q.initializationResult();
|
||||
const opusModels = models.filter(
|
||||
(model) =>
|
||||
model.value !== "default" &&
|
||||
/\bopus\b/i.test(`${model.value} ${model.resolvedModel} ${model.displayName}`),
|
||||
);
|
||||
expect(opusModels).toHaveLength(1);
|
||||
expect(opusModels[0]).toMatchObject({
|
||||
value: "opus[1m]",
|
||||
displayName: "Opus (1M context)",
|
||||
resolvedModel: expect.stringMatching(/^claude-opus-.*\[1m\]$/),
|
||||
description: expect.stringContaining("1M context"),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
finishInput();
|
||||
q.close();
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
matchResumedModel,
|
||||
} from "../acp-agent.js";
|
||||
} from "../session-model.js";
|
||||
import { settingsEffortForModel } from "../session-effort.js";
|
||||
|
||||
// Mirrors a real `supportedModels()` response: alias rows carry
|
||||
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
|
||||
@@ -270,3 +271,57 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("model resolution (real SDK)
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("settingsEffortForModel", () => {
|
||||
const SONNET: ModelInfo = {
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5 · Efficient for routine tasks",
|
||||
};
|
||||
|
||||
it("prefers the per-model entry keyed by the resolved model id", () => {
|
||||
const settings = {
|
||||
effortLevel: "high" as const,
|
||||
modelSettings: { "claude-sonnet-5": { effortLevel: "low" as const } },
|
||||
};
|
||||
expect(settingsEffortForModel(settings, SONNET)).toBe("low");
|
||||
});
|
||||
|
||||
it("falls back to the picker row value, then the raw model id", () => {
|
||||
const byValue = { modelSettings: { sonnet: { effortLevel: "medium" as const } } };
|
||||
expect(settingsEffortForModel(byValue, SONNET)).toBe("medium");
|
||||
|
||||
const byRawId = { modelSettings: { "claude-x": { effortLevel: "xhigh" as const } } };
|
||||
expect(settingsEffortForModel(byRawId, undefined, "claude-x")).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("matches equivalent -1m and [1m] modelSettings keys", () => {
|
||||
const settings = {
|
||||
effortLevel: "high" as const,
|
||||
modelSettings: { "claude-sonnet-5-1m": { effortLevel: "low" as const } },
|
||||
};
|
||||
expect(
|
||||
settingsEffortForModel(settings, {
|
||||
...SONNET,
|
||||
value: "sonnet[1m]",
|
||||
resolvedModel: "claude-sonnet-5[1m]",
|
||||
}),
|
||||
).toBe("low");
|
||||
});
|
||||
|
||||
it("falls back to the top-level effortLevel when no per-model entry matches", () => {
|
||||
expect(settingsEffortForModel({ effortLevel: "high" }, SONNET)).toBe("high");
|
||||
expect(
|
||||
settingsEffortForModel(
|
||||
{ effortLevel: "high", modelSettings: { "claude-opus-4-8": { effortLevel: "low" } } },
|
||||
SONNET,
|
||||
),
|
||||
).toBe("high");
|
||||
});
|
||||
|
||||
it("returns undefined when neither layer sets an effort", () => {
|
||||
expect(settingsEffortForModel({}, SONNET)).toBeUndefined();
|
||||
expect(settingsEffortForModel({ modelSettings: {} }, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
325
src/tests/native-subagents.test.ts
Normal file
325
src/tests/native-subagents.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import {
|
||||
announceNativeSubagent,
|
||||
finishNativeSubagent,
|
||||
NativeSubagent,
|
||||
NativeSubagentRuntime,
|
||||
NativeSubagentSession,
|
||||
} from "../native-subagents.js";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function child(overrides: Partial<NativeSubagent> = {}): NativeSubagent {
|
||||
return {
|
||||
sessionId: "child-1",
|
||||
parentSessionId: "root",
|
||||
parentToolUseId: "agent-tool",
|
||||
name: "Explore",
|
||||
task: "Inspect the project",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionWith(value: NativeSubagent): NativeSubagentSession {
|
||||
return {
|
||||
nativeSubagentsByTaskId: new Map([[value.sessionId, value]]),
|
||||
nativeSubagentTaskIdByToolUseId: new Map([[value.parentToolUseId!, value.sessionId]]),
|
||||
nativeSubagentParentByToolUseId: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function control(
|
||||
sessionUpdate: "tool_call" | "tool_call_update",
|
||||
status?: "pending" | "failed",
|
||||
parentToolUseId?: string,
|
||||
): AcpSessionNotification {
|
||||
return {
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate,
|
||||
toolCallId: "agent-tool",
|
||||
title: "Investigate failure",
|
||||
status,
|
||||
rawInput: { description: "Investigate failure", prompt: "Find the cause" },
|
||||
_meta: {
|
||||
claudeCode: { toolName: "Agent", subagent: true, parentToolUseId },
|
||||
},
|
||||
},
|
||||
} as AcpSessionNotification;
|
||||
}
|
||||
|
||||
describe("NativeSubagentRuntime lifecycle", () => {
|
||||
it("publishes a raced spawn exactly once", async () => {
|
||||
const release = deferred();
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const value = child();
|
||||
const publish = vi.fn(async (notification: AcpSessionNotification) => {
|
||||
published.push(notification);
|
||||
await release.promise;
|
||||
});
|
||||
|
||||
const first = announceNativeSubagent(value, publish);
|
||||
const second = announceNativeSubagent(value, publish);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
release.resolve();
|
||||
await Promise.all([first, second]);
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual(["subagent_spawned"]);
|
||||
});
|
||||
|
||||
it("publishes a raced terminal exactly once after the spawn", async () => {
|
||||
const releaseSpawn = deferred();
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const value = child();
|
||||
const session = sessionWith(value);
|
||||
const publish = vi.fn(async (notification: AcpSessionNotification) => {
|
||||
published.push(notification);
|
||||
if (notification.update.sessionUpdate === "subagent_spawned") await releaseSpawn.promise;
|
||||
});
|
||||
|
||||
const first = finishNativeSubagent(session, value.sessionId, "completed", publish);
|
||||
const second = finishNativeSubagent(session, value.sessionId, "cancelled", publish);
|
||||
releaseSpawn.resolve();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual([
|
||||
"subagent_spawned",
|
||||
"subagent_state_update",
|
||||
]);
|
||||
expect(published[1]?.update).toMatchObject({ state: "completed" });
|
||||
});
|
||||
|
||||
it("falls back to one ordinary failed tool call when no child ever starts", async () => {
|
||||
const runtime = new NativeSubagentRuntime(true, "root", {}, async () => {}, { log: () => {} });
|
||||
|
||||
await expect(
|
||||
runtime.route(control("tool_call", "pending"), async () => {}),
|
||||
).resolves.toBeNull();
|
||||
const fallback = await runtime.route(control("tool_call_update", "failed"), async () => {});
|
||||
|
||||
expect(fallback).toMatchObject({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "agent-tool",
|
||||
title: "Investigate failure",
|
||||
status: "failed",
|
||||
_meta: { claudeCode: { toolName: "Agent" } },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(fallback?.update._meta?.claudeCode as Record<string, unknown>).subagent,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a schema-valid non-native failed tool call without a cached initial call", async () => {
|
||||
const runtime = new NativeSubagentRuntime(true, "root", {}, async () => {}, { log: () => {} });
|
||||
|
||||
const fallback = await runtime.route(
|
||||
{
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "agent-tool",
|
||||
status: "failed",
|
||||
_meta: { claudeCode: { toolName: "Agent", subagent: true } },
|
||||
},
|
||||
} as AcpSessionNotification,
|
||||
async () => {},
|
||||
);
|
||||
|
||||
expect(fallback).toMatchObject({
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "agent-tool",
|
||||
name: "Agent",
|
||||
title: "Agent",
|
||||
status: "failed",
|
||||
_meta: { claudeCode: { toolName: "Agent" } },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(fallback?.update._meta?.claudeCode as Record<string, unknown>).subagent,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears failed-control identity and parent affinity before an id is reused", async () => {
|
||||
const outer = child({
|
||||
sessionId: "outer-child",
|
||||
parentToolUseId: "outer-tool",
|
||||
announced: true,
|
||||
});
|
||||
const session = sessionWith(outer);
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"root",
|
||||
session,
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
|
||||
await runtime.route(control("tool_call", "pending", "outer-tool"), async () => {});
|
||||
await runtime.route(control("tool_call_update", "failed", "outer-tool"), async () => {});
|
||||
await runtime.taskStarted(
|
||||
{
|
||||
taskId: "reused-child",
|
||||
toolUseId: "agent-tool",
|
||||
subagentType: "Review",
|
||||
description: "Fresh identity",
|
||||
},
|
||||
async () => {},
|
||||
);
|
||||
await runtime.route(
|
||||
{
|
||||
...control("tool_call", "pending"),
|
||||
update: {
|
||||
...control("tool_call", "pending").update,
|
||||
rawInput: { description: "Fresh identity", prompt: "Review again" },
|
||||
},
|
||||
} as AcpSessionNotification,
|
||||
async () => {},
|
||||
);
|
||||
|
||||
expect(published.at(-1)).toMatchObject({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "subagent_spawned",
|
||||
subagentSessionId: "reused-child",
|
||||
name: "Fresh identity",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("discards unknown pending child updates during finishAll", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const delivered: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
const pending = {
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "stale" },
|
||||
_meta: { claudeCode: { parentToolUseId: "agent-tool" } },
|
||||
},
|
||||
} as AcpSessionNotification;
|
||||
|
||||
await expect(
|
||||
runtime.route(pending, async (value) => {
|
||||
delivered.push(value);
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
await runtime.finishAll("cancelled", async (value) => {
|
||||
delivered.push(value);
|
||||
});
|
||||
await runtime.taskStarted(
|
||||
{
|
||||
taskId: "child-1",
|
||||
toolUseId: "agent-tool",
|
||||
subagentType: "Explore",
|
||||
description: "New child",
|
||||
},
|
||||
async (value) => {
|
||||
delivered.push(value);
|
||||
},
|
||||
);
|
||||
await runtime.route(control("tool_call", "pending"), async (value) => {
|
||||
delivered.push(value);
|
||||
});
|
||||
|
||||
expect(delivered).toEqual([]);
|
||||
expect(published).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("creates a distinct ACP lifecycle when the SDK restarts the same task id", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
const launch = (toolCallId: string) =>
|
||||
({
|
||||
...control("tool_call", "pending"),
|
||||
update: { ...control("tool_call", "pending").update, toolCallId },
|
||||
}) as AcpSessionNotification;
|
||||
const output = (parentToolUseId: string, text: string) =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text },
|
||||
_meta: { claudeCode: { parentToolUseId } },
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
|
||||
await runtime.route(launch("launch-1"), async () => {});
|
||||
await runtime.taskStarted(
|
||||
{
|
||||
taskId: "worker-1",
|
||||
toolUseId: "launch-1",
|
||||
subagentType: "Explore",
|
||||
description: "First run",
|
||||
},
|
||||
async () => {},
|
||||
);
|
||||
await expect(runtime.route(output("launch-1", "first"), async () => {})).resolves.toMatchObject(
|
||||
{ sessionId: "worker-1" },
|
||||
);
|
||||
await runtime.finishTask("worker-1", "completed", async () => {});
|
||||
|
||||
await runtime.route(launch("launch-2"), async () => {});
|
||||
await runtime.taskStarted(
|
||||
{
|
||||
taskId: "worker-1",
|
||||
toolUseId: "launch-2",
|
||||
subagentType: "Explore",
|
||||
description: "Second run",
|
||||
},
|
||||
async () => {},
|
||||
);
|
||||
await expect(
|
||||
runtime.route(output("launch-2", "second"), async () => {}),
|
||||
).resolves.toMatchObject({ sessionId: "worker-1:generation:2" });
|
||||
await runtime.finishTask("worker-1", "failed", async () => {}, "launch-1");
|
||||
expect(
|
||||
published.filter(({ update }) => update.sessionUpdate === "subagent_state_update"),
|
||||
).toHaveLength(1);
|
||||
await expect(
|
||||
runtime.route(output("launch-2", "still running"), async () => {}),
|
||||
).resolves.toMatchObject({ sessionId: "worker-1:generation:2" });
|
||||
await expect(runtime.route(output("launch-1", "late"), async () => {})).resolves.toBeNull();
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-2");
|
||||
|
||||
const spawnedIds = published.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "subagent_spawned" ? [update.subagentSessionId] : [],
|
||||
);
|
||||
const terminalIds = published.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "subagent_state_update" ? [update.subagentSessionId] : [],
|
||||
);
|
||||
expect(spawnedIds).toEqual(["worker-1", "worker-1:generation:2"]);
|
||||
expect(terminalIds).toEqual(spawnedIds);
|
||||
});
|
||||
});
|
||||
347
src/tests/permission-effects.test.ts
Normal file
347
src/tests/permission-effects.test.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js";
|
||||
import { buildClaudePermissionOptions, PERMISSION_OPTION_ID } from "../permissions/options.js";
|
||||
import { decodeClaudePermissionResponse } from "../permissions/response.js";
|
||||
|
||||
const permissionResult = (...args: Parameters<typeof decodeClaudePermissionResponse>) =>
|
||||
decodeClaudePermissionResponse(...args).permissionResult;
|
||||
|
||||
const rule = { toolName: "Bash", ruleContent: "npm test:*" };
|
||||
|
||||
describe("Claude permission response effects", () => {
|
||||
const build = (
|
||||
toolName: string,
|
||||
durableChangeSet?: ReturnType<typeof normalizeDurablePermissionChangeSet>,
|
||||
input: Record<string, unknown> = {},
|
||||
displayName?: string,
|
||||
allowPersistentOptions = true,
|
||||
availableModes: readonly string[] = [],
|
||||
) =>
|
||||
buildClaudePermissionOptions({
|
||||
toolName,
|
||||
displayName,
|
||||
input,
|
||||
cwd: "/workspace",
|
||||
durableChangeSet,
|
||||
allowPersistentOptions,
|
||||
availableModes,
|
||||
});
|
||||
|
||||
it.each([
|
||||
[PERMISSION_OPTION_ID.allowSkillExact, "deploy prod"],
|
||||
[PERMISSION_OPTION_ID.allowSkillPrefix, "deploy:*"],
|
||||
])("applies the native Skill effect for %s", (optionId, ruleContent) => {
|
||||
const input = { skill: "deploy prod" };
|
||||
const result = permissionResult(
|
||||
{ outcome: { outcome: "selected", optionId } },
|
||||
"Skill",
|
||||
input,
|
||||
"tool-skill",
|
||||
build("Skill", undefined, input),
|
||||
);
|
||||
expect(result.behavior === "allow" && result.updatedPermissions).toEqual([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Skill", ruleContent }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies a generated WebFetch effect and an exact MCP provider effect", () => {
|
||||
const webInput = { url: "https://example.com/path" };
|
||||
const web = permissionResult(
|
||||
{
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: PERMISSION_OPTION_ID.allowWithUpdates,
|
||||
},
|
||||
},
|
||||
"WebFetch",
|
||||
webInput,
|
||||
"tool-web",
|
||||
build("WebFetch", undefined, webInput),
|
||||
);
|
||||
expect(web.behavior === "allow" && web.updatedPermissions).toEqual([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "WebFetch", ruleContent: "domain:example.com" }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
]);
|
||||
|
||||
const mcpChangeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "mcp__demo__deploy" }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
]);
|
||||
const fallback = permissionResult(
|
||||
{
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: PERMISSION_OPTION_ID.allowWithUpdates,
|
||||
},
|
||||
},
|
||||
"mcp__demo__deploy",
|
||||
{ target: "staging" },
|
||||
"tool-mcp",
|
||||
build("mcp__demo__deploy", mcpChangeSet),
|
||||
mcpChangeSet,
|
||||
);
|
||||
expect(fallback.behavior === "allow" && fallback.updatedPermissions).toEqual(
|
||||
mcpChangeSet?.updates,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Read", { file_path: "/outside/a.ts" }, "Read"],
|
||||
["Glob", { path: "/outside" }, "Read"],
|
||||
["Grep", { path: "/outside" }, "Read"],
|
||||
["Edit", { file_path: "/outside/a.ts" }, "Edit"],
|
||||
["Write", { file_path: "/outside/a.ts" }, "Edit"],
|
||||
["NotebookEdit", { notebook_path: "/outside/a.ipynb" }, "Edit"],
|
||||
] as const)(
|
||||
"applies the exact provider filesystem effect for %s",
|
||||
(toolName, input, ruleTool) => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: ruleTool, ruleContent: "/outside/**" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
])!;
|
||||
const result = permissionResult(
|
||||
{
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: PERMISSION_OPTION_ID.allowWithUpdates,
|
||||
},
|
||||
},
|
||||
toolName,
|
||||
input,
|
||||
`tool-${toolName}`,
|
||||
build(toolName, changeSet, input),
|
||||
changeSet,
|
||||
);
|
||||
expect(result.behavior === "allow" && result.updatedPermissions).toEqual(changeSet.updates);
|
||||
},
|
||||
);
|
||||
|
||||
it("suppresses generated durable effects when an ask rule forced the prompt", () => {
|
||||
expect(
|
||||
build("WebFetch", undefined, { url: "https://example.com" }, undefined, false),
|
||||
).toHaveLength(2);
|
||||
expect(build("mcp__demo__deploy", undefined, {}, undefined, false)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("allows EnterPlanMode temporarily without changing mode before the tool runs", () => {
|
||||
expect(
|
||||
permissionResult(
|
||||
{ outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowOnce } },
|
||||
"EnterPlanMode",
|
||||
{},
|
||||
"tool-enter-plan",
|
||||
build("EnterPlanMode"),
|
||||
),
|
||||
).toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: {},
|
||||
toolUseID: "tool-enter-plan",
|
||||
decisionClassification: "user_temporary",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the selected ExitPlanMode mode and maps rejection", () => {
|
||||
const input = { plan: "Implement it" };
|
||||
const offered = build("ExitPlanMode", undefined, input, undefined, true, [
|
||||
"auto",
|
||||
"default",
|
||||
"acceptEdits",
|
||||
]);
|
||||
const approved = permissionResult(
|
||||
{
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanAuto,
|
||||
},
|
||||
},
|
||||
"ExitPlanMode",
|
||||
input,
|
||||
"tool-plan",
|
||||
offered,
|
||||
);
|
||||
expect(approved.behavior === "allow" && approved.updatedPermissions).toEqual([
|
||||
{ type: "setMode", mode: "auto", destination: "session" },
|
||||
]);
|
||||
|
||||
const acceptEditsOffered = build("ExitPlanMode", undefined, input, undefined, true, [
|
||||
"default",
|
||||
"acceptEdits",
|
||||
]);
|
||||
const acceptEdits = permissionResult(
|
||||
{
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanAcceptEdits,
|
||||
},
|
||||
},
|
||||
"ExitPlanMode",
|
||||
input,
|
||||
"tool-plan",
|
||||
acceptEditsOffered,
|
||||
);
|
||||
expect(acceptEdits.behavior === "allow" && acceptEdits.updatedPermissions).toEqual([
|
||||
{ type: "setMode", mode: "acceptEdits", destination: "session" },
|
||||
]);
|
||||
|
||||
const rejected = permissionResult(
|
||||
{
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: PERMISSION_OPTION_ID.reject,
|
||||
},
|
||||
},
|
||||
"ExitPlanMode",
|
||||
input,
|
||||
"tool-plan",
|
||||
offered,
|
||||
);
|
||||
expect(rejected).toMatchObject({
|
||||
behavior: "deny",
|
||||
message: "User chose to keep planning",
|
||||
interrupt: true,
|
||||
decisionClassification: "user_reject",
|
||||
});
|
||||
});
|
||||
|
||||
it("interrupts the old query when ExitPlanMode clears context", () => {
|
||||
const input = { plan: "Implement it" };
|
||||
const offered = build("ExitPlanMode", undefined, input, undefined, true, ["auto"]);
|
||||
expect(
|
||||
permissionResult(
|
||||
{
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearAuto,
|
||||
},
|
||||
},
|
||||
"ExitPlanMode",
|
||||
input,
|
||||
"tool-plan",
|
||||
offered,
|
||||
),
|
||||
).toEqual({
|
||||
behavior: "deny",
|
||||
message: "User accepted the plan and requested a fresh context",
|
||||
interrupt: true,
|
||||
toolUseID: "tool-plan",
|
||||
decisionClassification: "user_reject",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns clear-context semantics with the decoded permission decision", () => {
|
||||
const input = { plan: "Implement it" };
|
||||
const offered = build("ExitPlanMode", undefined, input, undefined, true, ["auto"]);
|
||||
const decision = decodeClaudePermissionResponse(
|
||||
{
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearAuto,
|
||||
},
|
||||
},
|
||||
"ExitPlanMode",
|
||||
input,
|
||||
"tool-plan",
|
||||
offered,
|
||||
);
|
||||
expect(decision.contextResetMode).toBe("auto");
|
||||
expect(decision.permissionResult).toMatchObject({ behavior: "deny", interrupt: true });
|
||||
});
|
||||
|
||||
it("maps one-time allow without remembered updates", () => {
|
||||
expect(
|
||||
permissionResult(
|
||||
{ outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowOnce } },
|
||||
"Bash",
|
||||
{ command: "pwd" },
|
||||
"tool-1",
|
||||
build("Bash"),
|
||||
),
|
||||
).toEqual({
|
||||
behavior: "allow",
|
||||
updatedInput: { command: "pwd" },
|
||||
toolUseID: "tool-1",
|
||||
decisionClassification: "user_temporary",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps durable allow to the snapshotted provider effect", () => {
|
||||
const suggestions: PermissionUpdate[] = [
|
||||
{ type: "addRules", rules: [rule], behavior: "allow", destination: "session" },
|
||||
];
|
||||
const changeSet = normalizeDurablePermissionChangeSet(suggestions);
|
||||
const result = permissionResult(
|
||||
{ outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowWithUpdates } },
|
||||
"Bash",
|
||||
{ command: "npm test" },
|
||||
"tool-2",
|
||||
build("Bash", changeSet),
|
||||
changeSet,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
behavior: "allow",
|
||||
toolUseID: "tool-2",
|
||||
decisionClassification: "user_permanent",
|
||||
});
|
||||
expect(result.behavior === "allow" && result.updatedPermissions).toEqual(suggestions);
|
||||
expect(result.behavior === "allow" && result.updatedPermissions).not.toBe(suggestions);
|
||||
});
|
||||
|
||||
it("distinguishes explicit reject from cancellation", () => {
|
||||
expect(
|
||||
permissionResult(
|
||||
{ outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.reject } },
|
||||
"Bash",
|
||||
{},
|
||||
"tool-3",
|
||||
build("Bash"),
|
||||
),
|
||||
).toEqual({
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
toolUseID: "tool-3",
|
||||
decisionClassification: "user_reject",
|
||||
});
|
||||
expect(() =>
|
||||
permissionResult({ outcome: { outcome: "cancelled" } }, "Bash", {}, "tool-3", build("Bash")),
|
||||
).toThrow("Tool use aborted");
|
||||
});
|
||||
|
||||
it("fails closed for an unavailable or unknown selection", () => {
|
||||
expect(() =>
|
||||
permissionResult(
|
||||
{ outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowWithUpdates } },
|
||||
"Bash",
|
||||
{},
|
||||
"tool-4",
|
||||
build("Bash"),
|
||||
),
|
||||
).toThrow("Permission option was not offered");
|
||||
expect(() =>
|
||||
permissionResult(
|
||||
{ outcome: { outcome: "selected", optionId: "future" } },
|
||||
"Bash",
|
||||
{},
|
||||
"tool-4",
|
||||
build("Bash"),
|
||||
),
|
||||
).toThrow("Permission option was not offered");
|
||||
});
|
||||
});
|
||||
38
src/tests/permission-normalization.test.ts
Normal file
38
src/tests/permission-normalization.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js";
|
||||
|
||||
describe("durable permission normalization safety", () => {
|
||||
it("fails closed when validation reads a throwing update accessor", () => {
|
||||
const update = Object.defineProperty({}, "destination", {
|
||||
get() {
|
||||
throw new Error("provider accessor failed");
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalizeDurablePermissionChangeSet([update])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed when validation reads a throwing suggestions proxy", () => {
|
||||
const suggestions = new Proxy([], {
|
||||
get(target, property, receiver) {
|
||||
if (property === "length") throw new Error("provider proxy failed");
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalizeDurablePermissionChangeSet(suggestions)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects strings whose untrimmed value exceeds the wire limit", () => {
|
||||
expect(
|
||||
normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
destination: "session",
|
||||
behavior: "allow",
|
||||
rules: [{ toolName: `${" ".repeat(512)}Bash` }],
|
||||
},
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
613
src/tests/permission-options.test.ts
Normal file
613
src/tests/permission-options.test.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js";
|
||||
import { buildClaudePermissionOptions, PERMISSION_OPTION_ID } from "../permissions/options.js";
|
||||
import { buildClaudePermissionPresentation } from "../permissions/presentation.js";
|
||||
import { decodeClaudePermissionResponse } from "../permissions/response.js";
|
||||
|
||||
const permissionResult = (...args: Parameters<typeof decodeClaudePermissionResponse>) =>
|
||||
decodeClaudePermissionResponse(...args).permissionResult;
|
||||
|
||||
const rule = { toolName: "Bash", ruleContent: "npm test:*" };
|
||||
describe("Claude permission options and response mapping", () => {
|
||||
const build = (
|
||||
toolName: string,
|
||||
durableChangeSet?: ReturnType<typeof normalizeDurablePermissionChangeSet>,
|
||||
input: Record<string, unknown> = {},
|
||||
displayName?: string,
|
||||
allowPersistentOptions = true,
|
||||
availableModes: readonly string[] = [],
|
||||
contextUsedPercent?: number,
|
||||
) =>
|
||||
buildClaudePermissionOptions({
|
||||
toolName,
|
||||
displayName,
|
||||
input,
|
||||
cwd: "/workspace",
|
||||
durableChangeSet,
|
||||
allowPersistentOptions,
|
||||
availableModes,
|
||||
contextUsedPercent,
|
||||
});
|
||||
|
||||
it("leads with the reject options when the CLI defaults the ask to no", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{ type: "addRules", rules: [rule], behavior: "allow", destination: "localSettings" },
|
||||
]);
|
||||
const context = {
|
||||
toolName: "Bash",
|
||||
input: { command: "npm test" },
|
||||
cwd: "/workspace",
|
||||
durableChangeSet: changeSet,
|
||||
};
|
||||
expect(buildClaudePermissionOptions(context).map((option) => option.kind)).toEqual([
|
||||
"allow_once",
|
||||
"allow_always",
|
||||
"reject_once",
|
||||
]);
|
||||
expect(
|
||||
buildClaudePermissionOptions({ ...context, defaultToNo: true }).map((option) => option.kind),
|
||||
).toEqual(["reject_once", "allow_once", "allow_always"]);
|
||||
});
|
||||
|
||||
it("builds the native Bash static-suggestions option from the exact update bundle", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{ type: "addRules", rules: [rule], behavior: "allow", destination: "session" },
|
||||
{ type: "addDirectories", directories: ["/work"], destination: "session" },
|
||||
]);
|
||||
expect(build("Bash", changeSet, { command: "npm test" })).toEqual([
|
||||
{ optionId: PERMISSION_OPTION_ID.allowOnce, name: "Yes", kind: "allow_once" },
|
||||
{
|
||||
optionId: PERMISSION_OPTION_ID.allowWithUpdates,
|
||||
name: "Yes, and allow access to work/ and npm test commands",
|
||||
kind: "allow_always",
|
||||
},
|
||||
{ optionId: PERMISSION_OPTION_ID.reject, name: "No", kind: "reject_once" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"Bash",
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Bash", ruleContent: "npm test:*" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Yes, and don't ask again for npm test commands",
|
||||
],
|
||||
[
|
||||
"PowerShell",
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "PowerShell", ruleContent: "Get-Process:*" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Yes, and don't ask again for Get-Process commands",
|
||||
],
|
||||
[
|
||||
"Bash",
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Read", ruleContent: "/outside/**" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Yes, allow reading from outside/ from this project",
|
||||
],
|
||||
[
|
||||
"Bash",
|
||||
[{ type: "addDirectories", directories: ["/outside"], destination: "session" }],
|
||||
"Yes, and always allow access to outside/ from this project",
|
||||
],
|
||||
] as const)("ports the native static shell label for %s", (toolName, updates, expected) => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet(updates);
|
||||
expect(build(toolName, changeSet)[1]?.name).toBe(expected);
|
||||
});
|
||||
|
||||
it("uses Claude Code's `similar` truncation for long command lists", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [
|
||||
{ toolName: "Bash", ruleContent: "a-very-long-command-name-one:*" },
|
||||
{ toolName: "Bash", ruleContent: "a-very-long-command-name-two:*" },
|
||||
],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
]);
|
||||
expect(build("Bash", changeSet)[1]?.name).toBe("Yes, and don't ask again for similar commands");
|
||||
});
|
||||
|
||||
it("uses the shortest distinguishing parent for paths with the same basename", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [
|
||||
{ toolName: "Read", ruleContent: "/repos/codex-acp/src/**" },
|
||||
{ toolName: "Read", ruleContent: "/repos/claude-agent-acp/src/**" },
|
||||
{ toolName: "Bash", ruleContent: "ls -R:*" },
|
||||
],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
]);
|
||||
expect(build("Bash", changeSet)[1]?.name).toBe(
|
||||
"Yes, and allow codex-acp/src/ and claude-agent-acp/src/ access and ls -R commands",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not repeat an identical path in the durable option", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [
|
||||
{ toolName: "Read", ruleContent: "/repos/codex-acp/src/**" },
|
||||
{ toolName: "Read", ruleContent: "/repos/codex-acp/src/**" },
|
||||
],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
]);
|
||||
expect(build("Bash", changeSet)[1]?.name).toBe(
|
||||
"Yes, allow reading from src/ from this project",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Bash",
|
||||
"PowerShell",
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"NotebookEdit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"WebFetch",
|
||||
"SandboxNetworkAccess",
|
||||
"mcp__example__write_tool",
|
||||
"mcp__computer-use__click",
|
||||
])("offers only one-time allow and reject for %s without a representable update", (toolName) => {
|
||||
expect(build(toolName).map((option) => option.name)).toEqual(["Yes", "No"]);
|
||||
});
|
||||
|
||||
it("uses generated native effects instead of applying an unrelated provider suggestion", () => {
|
||||
const bashChangeSet = normalizeDurablePermissionChangeSet([
|
||||
{ type: "addRules", rules: [rule], behavior: "allow", destination: "session" },
|
||||
]);
|
||||
expect(build("Read", bashChangeSet, { file_path: "/workspace/a.ts" })).toHaveLength(2);
|
||||
expect(build("WebFetch", bashChangeSet, { url: "https://example.com" })[1]?.name).toBe(
|
||||
"Yes, and don't ask again for example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves an MCP tool's exact provider suggestion", () => {
|
||||
const toolName = "mcp__example__write_tool";
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
]);
|
||||
const offered = build(toolName, changeSet, {}, "Write tool");
|
||||
expect(offered.map((option) => option.name)).toEqual([
|
||||
"Yes",
|
||||
"Yes, and don't ask again for Write tool commands",
|
||||
"No",
|
||||
]);
|
||||
const result = permissionResult(
|
||||
{ outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowWithUpdates } },
|
||||
toolName,
|
||||
{ value: "new" },
|
||||
"tool-mcp-write",
|
||||
offered,
|
||||
changeSet,
|
||||
);
|
||||
expect(result.behavior === "allow" && result.updatedPermissions).toEqual(changeSet?.updates);
|
||||
});
|
||||
|
||||
it.each(["mcp__example__write_tool", "mcp__computer-use__click"])(
|
||||
"offers no misleading durable option for unrepresentable %s suggestions",
|
||||
(toolName) => {
|
||||
const cases = [
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName }],
|
||||
behavior: "deny",
|
||||
destination: "localSettings",
|
||||
},
|
||||
],
|
||||
[{ type: "setMode", mode: "bypassPermissions", destination: "session" }],
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "mcp__other__tool" }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "mcp__other__tool" }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName, ruleContent: "target:staging" }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
],
|
||||
] as const;
|
||||
|
||||
for (const suggestions of cases) {
|
||||
const changeSet = normalizeDurablePermissionChangeSet(suggestions);
|
||||
expect(build(toolName, changeSet).map((option) => option.optionId)).toEqual([
|
||||
PERMISSION_OPTION_ID.allowOnce,
|
||||
PERMISSION_OPTION_ID.reject,
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["mcp__example__write_tool", "mcp__computer-use__click"])(
|
||||
"preserves the complete representable provider bundle for %s",
|
||||
(toolName) => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName }, { toolName }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
]);
|
||||
const offered = build(toolName, changeSet);
|
||||
expect(offered.map((option) => option.optionId)).toContain(
|
||||
PERMISSION_OPTION_ID.allowWithUpdates,
|
||||
);
|
||||
const result = permissionResult(
|
||||
{ outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowWithUpdates } },
|
||||
toolName,
|
||||
{},
|
||||
`tool-${toolName}`,
|
||||
offered,
|
||||
changeSet,
|
||||
);
|
||||
expect(result.behavior === "allow" && result.updatedPermissions).toEqual(changeSet?.updates);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["Read", { file_path: "/workspace/a.ts" }, "Yes, during this session"],
|
||||
[
|
||||
"Read",
|
||||
{ file_path: "/outside/a.ts" },
|
||||
"Yes, allow reading from outside/ during this session",
|
||||
],
|
||||
["Edit", { file_path: "/workspace/a.ts" }, "Yes, allow all edits during this session"],
|
||||
[
|
||||
"Write",
|
||||
{ file_path: "/outside/a.ts" },
|
||||
"Yes, allow all edits in outside/ during this session",
|
||||
],
|
||||
[
|
||||
"NotebookEdit",
|
||||
{ notebook_path: "/workspace/a.ipynb" },
|
||||
"Yes, allow all edits during this session",
|
||||
],
|
||||
["Glob", {}, "Yes, during this session"],
|
||||
["Grep", {}, "Yes, during this session"],
|
||||
] as const)("builds the native session label for %s", (toolName, input, durableName) => {
|
||||
const readOnly = toolName === "Read" || toolName === "Glob" || toolName === "Grep";
|
||||
const outside = Object.values(input).some(
|
||||
(value) => typeof value === "string" && value.startsWith("/outside"),
|
||||
);
|
||||
const changeSet = normalizeDurablePermissionChangeSet(
|
||||
readOnly
|
||||
? [
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Read", ruleContent: outside ? "/outside/**" : "/workspace/**" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
]
|
||||
: outside
|
||||
? [{ type: "addDirectories", directories: ["/outside"], destination: "session" }]
|
||||
: [{ type: "setMode", mode: "acceptEdits", destination: "session" }],
|
||||
);
|
||||
expect(build(toolName, changeSet, input).map((option) => option.name)).toEqual([
|
||||
"Yes",
|
||||
durableName,
|
||||
"No",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not call a persistent filesystem update a session grant", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{ type: "addDirectories", directories: ["/outside"], destination: "localSettings" },
|
||||
]);
|
||||
expect(build("Read", changeSet, { file_path: "/outside/a.ts" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not offer a path-specific filesystem effect for a different in-project path", () => {
|
||||
const read = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Read", ruleContent: "/outside/**" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
]);
|
||||
const write = normalizeDurablePermissionChangeSet([
|
||||
{ type: "addDirectories", directories: ["/outside"], destination: "session" },
|
||||
]);
|
||||
expect(build("Read", read, { file_path: "/workspace/a.ts" })).toHaveLength(2);
|
||||
expect(build("Edit", write, { file_path: "/workspace/a.ts" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("resolves relative permission paths from the session cwd", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Read", ruleContent: "src/**" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
]);
|
||||
expect(build("Read", changeSet, { file_path: "/workspace/src/a.ts" })[1]?.name).toBe(
|
||||
"Yes, during this session",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes a backslash glob suffix in a provider path rule", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Read", ruleContent: "/outside\\**" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
]);
|
||||
expect(build("Read", changeSet, { file_path: "/outside/a.ts" })[1]?.name).toBe(
|
||||
"Yes, allow reading from outside/ during this session",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats acceptEdits as broad only for ordinary write paths", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{ type: "setMode", mode: "acceptEdits", destination: "session" },
|
||||
]);
|
||||
expect(build("Edit", changeSet, { file_path: "/workspace/a.ts" })[1]?.name).toBe(
|
||||
"Yes, allow all edits during this session",
|
||||
);
|
||||
expect(
|
||||
build("Edit", changeSet, { file_path: "/workspace/.claude/settings.json" }),
|
||||
).toHaveLength(2);
|
||||
expect(build("Read", changeSet, { file_path: "/workspace/a.ts" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("builds WebFetch and sandbox-host labels from provider data", () => {
|
||||
const webFetch = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "WebFetch", ruleContent: "domain:example.com" }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
]);
|
||||
expect(build("WebFetch", webFetch, { url: "https://example.com/a" })[1]?.name).toBe(
|
||||
"Yes, and don't ask again for example.com",
|
||||
);
|
||||
const network = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "SandboxNetworkAccess", ruleContent: "example.com" }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
]);
|
||||
expect(build("SandboxNetworkAccess", network, { host: "example.com" })[1]?.name).toBe(
|
||||
"Yes, and don't ask again for example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Claude Code's special session label for its own settings", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Edit", ruleContent: "/workspace/.claude/**" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
]);
|
||||
expect(build("Edit", changeSet, { file_path: ".claude/settings.json" })[1]?.name).toBe(
|
||||
"Yes, and allow Claude to edit its own settings for this session",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds Claude Code's exact and prefix Skill options from tool input", () => {
|
||||
expect(build("Skill", undefined, { skill: "deploy prod" })).toMatchObject([
|
||||
{ optionId: PERMISSION_OPTION_ID.allowOnce, name: "Yes" },
|
||||
{
|
||||
optionId: PERMISSION_OPTION_ID.allowSkillExact,
|
||||
name: "Yes, and don't ask again for deploy prod",
|
||||
},
|
||||
{
|
||||
optionId: PERMISSION_OPTION_ID.allowSkillPrefix,
|
||||
name: "Yes, and don't ask again for deploy:* commands",
|
||||
},
|
||||
{ optionId: PERMISSION_OPTION_ID.reject, name: "No" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("suppresses generated Skill rules when a configured ask rule forced the prompt", () => {
|
||||
expect(build("Skill", undefined, { skill: "deploy prod" }, undefined, false)).toEqual([
|
||||
{ optionId: PERMISSION_OPTION_ID.allowOnce, name: "Yes", kind: "allow_once" },
|
||||
{ optionId: PERMISSION_OPTION_ID.reject, name: "No", kind: "reject_once" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps EnterPlanMode at its two reachable one-time choices", () => {
|
||||
expect(build("EnterPlanMode")).toMatchObject([
|
||||
{
|
||||
optionId: PERMISSION_OPTION_ID.allowOnce,
|
||||
name: "Yes, enter plan mode",
|
||||
},
|
||||
{ optionId: PERMISSION_OPTION_ID.reject, name: "No, start implementing now" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers only the highest-priority elevated ExitPlanMode choice", () => {
|
||||
const options = build("ExitPlanMode", undefined, {}, undefined, true, [
|
||||
"auto",
|
||||
"default",
|
||||
"acceptEdits",
|
||||
"bypassPermissions",
|
||||
]);
|
||||
expect(options).toMatchObject([
|
||||
{ optionId: PERMISSION_OPTION_ID.exitPlanDefault, name: "Yes, manually approve edits" },
|
||||
{ optionId: PERMISSION_OPTION_ID.exitPlanAuto, name: "Yes, and use auto mode" },
|
||||
{ optionId: PERMISSION_OPTION_ID.reject, name: "No, keep planning" },
|
||||
]);
|
||||
expect(options[2]?._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
["bypassPermissions", "acceptEdits"],
|
||||
PERMISSION_OPTION_ID.exitPlanBypass,
|
||||
"Yes, and bypass permissions",
|
||||
],
|
||||
[["acceptEdits"], PERMISSION_OPTION_ID.exitPlanAcceptEdits, "Yes, auto-accept edits"],
|
||||
[[], PERMISSION_OPTION_ID.exitPlanAcceptEdits, "Yes, auto-accept edits"],
|
||||
] as const)("falls back through the native ExitPlanMode priority", (modes, optionId, name) => {
|
||||
expect(build("ExitPlanMode", undefined, {}, undefined, true, modes)).toMatchObject([
|
||||
{ optionId: PERMISSION_OPTION_ID.exitPlanDefault, name: "Yes, manually approve edits" },
|
||||
{ optionId, name },
|
||||
{ optionId: PERMISSION_OPTION_ID.reject, name: "No, keep planning" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
["auto", "bypassPermissions", "acceptEdits"],
|
||||
PERMISSION_OPTION_ID.exitPlanClearAuto,
|
||||
"Yes, clear context (73% used) and use auto mode",
|
||||
],
|
||||
[
|
||||
["bypassPermissions", "acceptEdits"],
|
||||
PERMISSION_OPTION_ID.exitPlanClearBypass,
|
||||
"Yes, clear context (73% used) and bypass permissions",
|
||||
],
|
||||
[
|
||||
["acceptEdits"],
|
||||
PERMISSION_OPTION_ID.exitPlanClearAcceptEdits,
|
||||
"Yes, clear context (73% used) and auto-accept edits",
|
||||
],
|
||||
] as const)(
|
||||
"offers one clear-context ExitPlanMode choice using native priority",
|
||||
(modes, optionId, name) => {
|
||||
expect(
|
||||
build("ExitPlanMode", undefined, { plan: "Implement it" }, undefined, true, modes, 73),
|
||||
).toContainEqual(expect.objectContaining({ optionId, name }));
|
||||
},
|
||||
);
|
||||
|
||||
it("does not offer clear-context ExitPlanMode without a plan", () => {
|
||||
expect(
|
||||
build("ExitPlanMode", undefined, {}, undefined, true, ["auto"], 73).map(
|
||||
(option) => option.optionId,
|
||||
),
|
||||
).not.toContain(PERMISSION_OPTION_ID.exitPlanClearAuto);
|
||||
});
|
||||
|
||||
it("recognizes real Computer Use MCP tools and preserves their exact durable suggestion", () => {
|
||||
const toolName = "mcp__computer-use__click";
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
]);
|
||||
const offered = build(toolName, changeSet, {}, "Click");
|
||||
expect(offered.map((option) => option.name)).toEqual([
|
||||
"Yes",
|
||||
"Yes, and don't ask again for Click",
|
||||
"No",
|
||||
]);
|
||||
const result = permissionResult(
|
||||
{
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: PERMISSION_OPTION_ID.allowWithUpdates,
|
||||
},
|
||||
},
|
||||
toolName,
|
||||
{ x: 10, y: 20 },
|
||||
"tool-computer-use",
|
||||
offered,
|
||||
changeSet,
|
||||
);
|
||||
expect(result.behavior === "allow" && result.updatedPermissions).toEqual(changeSet?.updates);
|
||||
});
|
||||
|
||||
it("reuses the Computer Use MCP tool-call title", () => {
|
||||
expect(
|
||||
buildClaudePermissionPresentation({
|
||||
toolName: "mcp__computer-use__screenshot",
|
||||
input: {},
|
||||
toolUseID: "tool-computer-use",
|
||||
})._meta,
|
||||
).toEqual({
|
||||
permission: { version: 1, title: "mcp__computer-use__screenshot" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the provider display name for a matching generic-tool rule", () => {
|
||||
const changeSet = normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "mcp__demo__deploy" }],
|
||||
behavior: "allow",
|
||||
destination: "localSettings",
|
||||
},
|
||||
]);
|
||||
expect(build("mcp__demo__deploy", changeSet, {}, "Deploy")[1]?.name).toBe(
|
||||
"Yes, and don't ask again for Deploy commands",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to route AskUserQuestion through permission options", () => {
|
||||
expect(() => build("AskUserQuestion")).toThrow("handled by ACP elicitation");
|
||||
});
|
||||
});
|
||||
366
src/tests/permission-presentation.test.ts
Normal file
366
src/tests/permission-presentation.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js";
|
||||
import { buildClaudePermissionPresentation } from "../permissions/presentation.js";
|
||||
|
||||
const rule = { toolName: "Bash", ruleContent: "npm test:*" };
|
||||
describe("Claude permission suggestion normalization", () => {
|
||||
it.each([undefined, [], null, "bad"])("omits a durable choice for %j", (suggestions) => {
|
||||
expect(normalizeDurablePermissionChangeSet(suggestions)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["addRules", "replaceRules", "removeRules"] as const)(
|
||||
"supports and snapshots %s",
|
||||
(type) => {
|
||||
const suggestions: PermissionUpdate[] = [
|
||||
{ type, rules: [rule], behavior: "allow", destination: "session" },
|
||||
];
|
||||
const normalized = normalizeDurablePermissionChangeSet(suggestions);
|
||||
expect(normalized?.updates).toEqual(suggestions);
|
||||
expect(normalized?.updates).not.toBe(suggestions);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the approved effect stable if the provider mutates its suggestions later", () => {
|
||||
const suggestions: PermissionUpdate[] = [
|
||||
{ type: "addRules", rules: [rule], behavior: "allow", destination: "session" },
|
||||
];
|
||||
const normalized = normalizeDurablePermissionChangeSet(suggestions)!;
|
||||
suggestions[0] = {
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Bash", ruleContent: "rm:*" }],
|
||||
behavior: "allow",
|
||||
destination: "userSettings",
|
||||
};
|
||||
expect(normalized.updates).toEqual([
|
||||
{ type: "addRules", rules: [rule], behavior: "allow", destination: "session" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"] as const)(
|
||||
"supports setMode %s",
|
||||
(mode) => {
|
||||
expect(
|
||||
normalizeDurablePermissionChangeSet([{ type: "setMode", mode, destination: "session" }])
|
||||
?.updates,
|
||||
).toEqual([{ type: "setMode", mode, destination: "session" }]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["addDirectories", "removeDirectories"] as const)("supports %s", (type) => {
|
||||
expect(
|
||||
normalizeDurablePermissionChangeSet([
|
||||
{ type, directories: ["/one", "/two"], destination: "localSettings" },
|
||||
])?.updates,
|
||||
).toEqual([{ type, directories: ["/one", "/two"], destination: "localSettings" }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ type: "future", destination: "session" }],
|
||||
[{ type: "setMode", mode: "future", destination: "session" }],
|
||||
[{ type: "addRules", rules: [rule], behavior: "future", destination: "session" }],
|
||||
[{ type: "addDirectories", directories: ["/work"], destination: "future" }],
|
||||
[{ type: "addDirectories", directories: [], destination: "session" }],
|
||||
])("fails closed for an unknown or invalid change set", (suggestions) => {
|
||||
expect(normalizeDurablePermissionChangeSet(suggestions)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed when an otherwise valid provider update is not cloneable", () => {
|
||||
expect(
|
||||
normalizeDurablePermissionChangeSet([
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [rule],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
unexpectedFunction: () => undefined,
|
||||
},
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses every durable option for a forced ask", () => {
|
||||
expect(
|
||||
normalizeDurablePermissionChangeSet(
|
||||
[{ type: "addDirectories", directories: ["/work"], destination: "projectSettings" }],
|
||||
true,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude permission ACP v1 presentation", () => {
|
||||
it("uses Approve Plan as the tool title and keeps the question in permission metadata", () => {
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName: "ExitPlanMode",
|
||||
input: { plan: "Implement the change" },
|
||||
toolUseID: "tool-plan",
|
||||
});
|
||||
|
||||
expect(presentation.toolCall.title).toBe("Approve Plan");
|
||||
expect(presentation._meta).toEqual({
|
||||
permission: { version: 1, title: "Ready to code?" },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the CLI's defaultToNo hint in the permission record", () => {
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName: "Bash",
|
||||
input: { command: "rm -rf build" },
|
||||
toolUseID: "tool-1",
|
||||
defaultToNo: true,
|
||||
});
|
||||
expect(presentation._meta).toEqual({
|
||||
permission: { version: 1, title: "rm -rf build", defaultToNo: true },
|
||||
});
|
||||
expect(
|
||||
buildClaudePermissionPresentation({
|
||||
toolName: "Bash",
|
||||
input: { command: "rm -rf build" },
|
||||
toolUseID: "tool-1",
|
||||
defaultToNo: false,
|
||||
})._meta,
|
||||
).toEqual({ permission: { version: 1, title: "rm -rf build" } });
|
||||
});
|
||||
|
||||
it("keeps command descriptions and decision reasons in their presentation fields", () => {
|
||||
const input = { command: "npm test", description: "Run the tests" };
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName: "Bash",
|
||||
input,
|
||||
toolUseID: "tool-1",
|
||||
displayName: "Run command",
|
||||
description: "Run npm tests",
|
||||
decisionReason: "Needed to verify the change.",
|
||||
});
|
||||
expect(presentation._meta).toMatchObject({
|
||||
permission: { description: "Reason: Needed to verify the change." },
|
||||
});
|
||||
expect(presentation.toolCall).toMatchObject({
|
||||
toolCallId: "tool-1",
|
||||
name: "Bash",
|
||||
kind: "execute",
|
||||
status: "pending",
|
||||
rawInput: input,
|
||||
});
|
||||
expect(presentation.toolCall.content).toEqual([
|
||||
{ type: "content", content: { type: "text", text: "Run the tests" } },
|
||||
]);
|
||||
expect(presentation.toolCall.rawInput).toBe(input);
|
||||
});
|
||||
|
||||
// `command` is required, so this only shows while the input is still
|
||||
// streaming; both shells share the standard terminal card in that state.
|
||||
it.each(["Bash", "PowerShell"])(
|
||||
"uses the Terminal fallback for %s when no command is available yet",
|
||||
(toolName) => {
|
||||
const input = {};
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName,
|
||||
input,
|
||||
toolUseID: `tool-${toolName}`,
|
||||
});
|
||||
|
||||
expect(presentation._meta).toEqual({ permission: { version: 1, title: "Terminal" } });
|
||||
expect(presentation.toolCall).toMatchObject({ title: "Terminal", rawInput: input });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["Bash", "ls -la ~/.config/zed"],
|
||||
["PowerShell", "Get-ChildItem $HOME\\.config\\zed"],
|
||||
])(
|
||||
"shows the exact %s command instead of its model-authored description",
|
||||
(toolName, command) => {
|
||||
const input = { command, description: "List files in current directory" };
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName,
|
||||
input,
|
||||
toolUseID: `tool-${toolName}`,
|
||||
});
|
||||
|
||||
expect(presentation._meta).toMatchObject({ permission: { title: command } });
|
||||
expect(presentation.toolCall.title).toBe(command);
|
||||
},
|
||||
);
|
||||
|
||||
describe.each(["Bash", "PowerShell"])("%s command fidelity", (toolName) => {
|
||||
it.each([
|
||||
{ label: "quoted spaces and tabs", command: 'echo "a b\tc"' },
|
||||
{ label: "newlines after comments", command: "echo first # first command\necho second" },
|
||||
{ label: "surrounding whitespace", command: " \techo first\n" },
|
||||
{
|
||||
label: "commands longer than 4,000 characters",
|
||||
command: `echo "${"x".repeat(4_001)}"\necho last`,
|
||||
},
|
||||
])("preserves $label in the approval title", ({ command }) => {
|
||||
const input = { command, description: "Run the requested command" };
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName,
|
||||
input,
|
||||
toolUseID: `tool-${toolName}`,
|
||||
supportsTerminalOutput: true,
|
||||
});
|
||||
|
||||
expect(presentation._meta).toEqual({ permission: { version: 1, title: command } });
|
||||
expect(presentation.toolCall.title).toBe(command);
|
||||
expect(presentation.toolCall.rawInput).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the WebFetch URL in structured tool input", () => {
|
||||
const input = { url: "https://example.com/docs", prompt: "Read the API reference" };
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName: "WebFetch",
|
||||
input,
|
||||
toolUseID: "tool-web-fetch",
|
||||
description: "https://example.com/docs",
|
||||
});
|
||||
|
||||
expect(presentation._meta).toEqual({
|
||||
permission: { version: 1, title: "Fetch https://example.com/docs" },
|
||||
});
|
||||
expect(presentation.toolCall).toMatchObject({
|
||||
kind: "fetch",
|
||||
rawInput: input,
|
||||
});
|
||||
expect(presentation.toolCall.rawInput).toBe(input);
|
||||
});
|
||||
|
||||
it("keeps the WebSearch query out of the permission description", () => {
|
||||
const query = "Agent Client Protocol ACP specification subagents v2";
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName: "WebSearch",
|
||||
input: { query },
|
||||
toolUseID: "tool-web-search",
|
||||
displayName: "WebSearch",
|
||||
description: "Agent Client Protocol ACP specification subagents…",
|
||||
});
|
||||
|
||||
expect(presentation._meta).toEqual({
|
||||
permission: {
|
||||
version: 1,
|
||||
title: 'Search "Agent Client Protocol ACP specification subagents v2"',
|
||||
},
|
||||
});
|
||||
expect(presentation.toolCall.title).toBe(
|
||||
'Search "Agent Client Protocol ACP specification subagents v2"',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Agent", { description: "Find the implementation" }, "Find the implementation"],
|
||||
["Task", { description: "Review the tests" }, "Review the tests"],
|
||||
["ReviewArtifact", {}, "ReviewArtifact"],
|
||||
["Workflow", {}, "Workflow"],
|
||||
["Monitor", {}, "Monitor"],
|
||||
])("reuses the %s tool-call title", (toolName, input, title) => {
|
||||
expect(
|
||||
buildClaudePermissionPresentation({
|
||||
toolName,
|
||||
input,
|
||||
toolUseID: `tool-${toolName}`,
|
||||
displayName: toolName,
|
||||
})._meta,
|
||||
).toEqual({ permission: { version: 1, title } });
|
||||
});
|
||||
|
||||
it("reuses tool-call titles and temporarily exposes decisionReason", () => {
|
||||
expect(
|
||||
buildClaudePermissionPresentation({
|
||||
toolName: "Read",
|
||||
input: { file_path: "/work/a.ts" },
|
||||
toolUseID: "tool-2",
|
||||
title: "Claude wants to read /work/a.ts",
|
||||
description: "Read a.ts",
|
||||
decisionReason: "Needed to inspect the dependency.",
|
||||
})._meta,
|
||||
).toEqual({
|
||||
permission: {
|
||||
version: 1,
|
||||
title: "Read /work/a.ts",
|
||||
description: "Reason: Needed to inspect the dependency.",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
buildClaudePermissionPresentation({
|
||||
toolName: "Read",
|
||||
input: { file_path: "/work/a.ts" },
|
||||
toolUseID: "tool-2b",
|
||||
displayName: "Inspect file",
|
||||
description: "Read a.ts",
|
||||
})._meta,
|
||||
).toEqual({
|
||||
permission: {
|
||||
version: 1,
|
||||
title: "Read /work/a.ts",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
buildClaudePermissionPresentation({
|
||||
toolName: "Read",
|
||||
input: {},
|
||||
toolUseID: "tool-3",
|
||||
decisionReason: "internal_policy_code",
|
||||
})._meta,
|
||||
).toEqual({
|
||||
permission: {
|
||||
version: 1,
|
||||
title: "Read File",
|
||||
description: "Reason: internal_policy_code",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Read", { file_path: "/work/AGENTS.md" }, "Read AGENTS.md"],
|
||||
["Edit", { file_path: "/work/a.ts" }, "Edit a.ts"],
|
||||
["Write", { file_path: "/work/a.ts" }, "Write a.ts"],
|
||||
["NotebookEdit", { notebook_path: "/work/a.ipynb" }, "Edit a.ipynb"],
|
||||
["Glob", { pattern: "**/*.ts" }, "Find **/*.ts"],
|
||||
["Grep", { pattern: "permission" }, "Search for permission"],
|
||||
["Bash", { command: "npm test" }, "Run npm test"],
|
||||
["PowerShell", { command: "Get-ChildItem" }, "List files"],
|
||||
["WebFetch", { url: "https://example.com" }, "https://example.com"],
|
||||
["WebSearch", { query: "ACP permissions" }, "ACP permissions"],
|
||||
["Skill", { skill: "testing" }, "Use testing skill"],
|
||||
["mcp__demo__deploy", { target: "staging" }, "Deploy to staging"],
|
||||
])(
|
||||
"does not use the %s operation subtitle as a permission explanation",
|
||||
(toolName, input, description) => {
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName,
|
||||
input,
|
||||
toolUseID: `tool-${toolName}`,
|
||||
description,
|
||||
});
|
||||
|
||||
expect(presentation._meta?.permission).not.toHaveProperty("description");
|
||||
},
|
||||
);
|
||||
|
||||
it("adds a non-duplicated blocked path to standard locations", () => {
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName: "Read",
|
||||
input: { file_path: "/work/a.ts" },
|
||||
toolUseID: "tool-4",
|
||||
blockedPath: "/outside/b.ts",
|
||||
});
|
||||
expect(presentation.toolCall.locations).toEqual([
|
||||
{ path: "/work/a.ts", line: 1 },
|
||||
{ path: "/outside/b.ts" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reuses the standard title for an unknown tool", () => {
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName: "mcp__demo__deploy",
|
||||
input: { target: "staging" },
|
||||
toolUseID: "tool-5",
|
||||
});
|
||||
expect(presentation.toolCall).toMatchObject({ kind: "other", name: "mcp__demo__deploy" });
|
||||
expect(presentation._meta).toEqual({
|
||||
permission: { version: 1, title: "mcp__demo__deploy" },
|
||||
});
|
||||
});
|
||||
});
|
||||
504
src/tests/providers.test.ts
Normal file
504
src/tests/providers.test.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
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([]),
|
||||
close: vi.fn(),
|
||||
interrupt: vi.fn().mockResolvedValue(undefined),
|
||||
getContextUsage: vi.fn().mockResolvedValue({ totalTokens: 0, rawMaxTokens: 200000 }),
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
})),
|
||||
);
|
||||
|
||||
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 one mutually exclusive provider slot with native routing", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
const response = await agent.unstable_listProviders({});
|
||||
expect(response.providers).toEqual([
|
||||
{
|
||||
providerId: "main",
|
||||
supported: ["anthropic", "bedrock", "vertex"],
|
||||
required: false,
|
||||
current: { apiType: "anthropic", baseUrl: "https://api.anthropic.com" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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("restores native routing after the last override is disabled", 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).toEqual({
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces the active api type when the single slot is set again", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://anthropic.example",
|
||||
});
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "bedrock",
|
||||
baseUrl: "https://bedrock.example",
|
||||
});
|
||||
|
||||
const overridden = await agent.unstable_listProviders({});
|
||||
expect(overridden.providers[0].current).toEqual({
|
||||
apiType: "bedrock",
|
||||
baseUrl: "https://bedrock.example",
|
||||
});
|
||||
|
||||
await agent.unstable_disableProvider({ providerId: "main" });
|
||||
expect((await agent.unstable_listProviders({})).providers[0].current).toEqual({
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
});
|
||||
|
||||
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: "acp-proxy",
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps native session creation unchanged when the providers API is unused", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledOnce();
|
||||
expect(mockQuery.mock.calls[0][0].options.resume).toBeUndefined();
|
||||
expect(mockQuery.mock.results[0].value.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recreates loaded sessions with the new provider between turns", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
const first = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const originalQuery = mockQuery.mock.results[0].value;
|
||||
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
expect(originalQuery.close).toHaveBeenCalledOnce();
|
||||
expect(mockQuery).toHaveBeenCalledTimes(2);
|
||||
expect(mockQuery.mock.calls[1][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
resume: first.sessionId,
|
||||
env: expect.objectContaining({ ANTHROPIC_BASE_URL: "https://gateway.example" }),
|
||||
settings: expect.objectContaining({
|
||||
apiKeyHelper: "",
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_AUTH_TOKEN: "acp-proxy",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await agent.unstable_disableProvider({ providerId: "main" });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledTimes(3);
|
||||
expect(mockQuery.mock.calls[2][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({ resume: first.sessionId }),
|
||||
}),
|
||||
);
|
||||
expect(mockQuery.mock.calls[2][0].options.env.ANTHROPIC_BASE_URL).toBe(
|
||||
process.env.ANTHROPIC_BASE_URL,
|
||||
);
|
||||
});
|
||||
|
||||
it("switches every loaded session through proxy replacements and back to native routing", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
const first = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const second = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://first-gateway.example",
|
||||
});
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://second-gateway.example",
|
||||
});
|
||||
await agent.unstable_disableProvider({ providerId: "main" });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledTimes(8);
|
||||
const resumedCalls = mockQuery.mock.calls.slice(2);
|
||||
for (let update = 0; update < 3; update += 1) {
|
||||
expect(
|
||||
resumedCalls.slice(update * 2, update * 2 + 2).map(([request]) => request.options.resume),
|
||||
).toEqual([first.sessionId, second.sessionId]);
|
||||
}
|
||||
expect(
|
||||
resumedCalls.slice(0, 2).map(([request]) => request.options.env.ANTHROPIC_BASE_URL),
|
||||
).toEqual(["https://first-gateway.example", "https://first-gateway.example"]);
|
||||
expect(
|
||||
resumedCalls.slice(2, 4).map(([request]) => request.options.env.ANTHROPIC_BASE_URL),
|
||||
).toEqual(["https://second-gateway.example", "https://second-gateway.example"]);
|
||||
expect(
|
||||
resumedCalls.slice(4).map(([request]) => request.options.env.ANTHROPIC_BASE_URL),
|
||||
).toEqual([process.env.ANTHROPIC_BASE_URL, process.env.ANTHROPIC_BASE_URL]);
|
||||
for (const result of mockQuery.mock.results.slice(0, 6)) {
|
||||
expect(result.value.close).toHaveBeenCalledOnce();
|
||||
}
|
||||
for (const result of mockQuery.mock.results.slice(6)) {
|
||||
expect(result.value.close).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("overrides user routing settings while preserving unrelated settings", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
settings: {
|
||||
apiKeyHelper: "unsafe-helper",
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://user-gateway.example",
|
||||
ANTHROPIC_API_KEY: "user-secret",
|
||||
UNRELATED_SETTING: "preserved",
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockQuery.mock.calls[0][0].options.settings).toEqual(
|
||||
expect.objectContaining({
|
||||
apiKeyHelper: "",
|
||||
model: "claude-sonnet-4-6",
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_AUTH_TOKEN: "acp-proxy",
|
||||
UNRELATED_SETTING: "preserved",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for an active turn before recreating its session", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
const created = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const originalQuery = mockQuery.mock.results[0].value;
|
||||
let finishTurn!: () => void;
|
||||
const completion = new Promise<void>((resolve) => {
|
||||
finishTurn = resolve;
|
||||
});
|
||||
(agent.sessions[created.sessionId] as any).turnQueue = [{ completion }];
|
||||
|
||||
const update = agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(originalQuery.close).not.toHaveBeenCalled();
|
||||
finishTurn();
|
||||
await update;
|
||||
expect(originalQuery.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
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: "acp-proxy",
|
||||
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).toEqual({
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
270
src/tests/recommended-config-values.test.ts
Normal file
270
src/tests/recommended-config-values.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { buildConfigOptions, clientSupportsRecommendedConfigValue } from "../acp-agent.js";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
|
||||
|
||||
const MODES = {
|
||||
currentModeId: "default",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Manual", description: "Always ask before making changes" },
|
||||
{ id: "plan", name: "Plan", description: "Create a plan before making changes" },
|
||||
],
|
||||
};
|
||||
|
||||
const MODELS = {
|
||||
currentModelId: "default",
|
||||
availableModels: [
|
||||
{ modelId: "default", name: "Default" },
|
||||
{ modelId: "opus", name: "Claude Opus" },
|
||||
{ modelId: "sonnet", name: "Claude Sonnet" },
|
||||
{ modelId: "haiku", name: "Claude Haiku" },
|
||||
],
|
||||
};
|
||||
|
||||
const MODEL_INFOS: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
resolvedModel: "claude-sonnet-4-6",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
resolvedModel: "claude-opus-4-6",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
resolvedModel: "claude-sonnet-4-6",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Claude Haiku",
|
||||
description: "Fastest",
|
||||
resolvedModel: "claude-haiku-4-5",
|
||||
},
|
||||
];
|
||||
|
||||
const RECOMMENDED_PRESENTATION = { useRecommendedValue: true };
|
||||
|
||||
function recommendedMeta(value: string) {
|
||||
return { jetbrains: { air: { version: 1, recommendedValue: value } } };
|
||||
}
|
||||
|
||||
function selectOption(options: ReturnType<typeof buildConfigOptions>, id: string) {
|
||||
const option = options.find((candidate) => candidate.id === id);
|
||||
expect(option).toMatchObject({ id, type: "select" });
|
||||
return option as Extract<(typeof options)[number], { type: "select" }>;
|
||||
}
|
||||
|
||||
describe("recommended config value capability", () => {
|
||||
it("is enabled through the common AIR capability list", () => {
|
||||
const capabilities: ClientCapabilities = {
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: [AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(clientSupportsRecommendedConfigValue(capabilities)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps legacy behavior for omitted and malformed AIR metadata", () => {
|
||||
expect(clientSupportsRecommendedConfigValue(undefined)).toBe(false);
|
||||
expect(clientSupportsRecommendedConfigValue(null)).toBe(false);
|
||||
expect(clientSupportsRecommendedConfigValue({})).toBe(false);
|
||||
expect(
|
||||
clientSupportsRecommendedConfigValue({
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: { version: 1, capabilities: ["someOtherCapability"] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
clientSupportsRecommendedConfigValue({
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: "1",
|
||||
capabilities: [AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConfigOptions recommended values presentation", () => {
|
||||
it("retains default model and effort entries without the capability", () => {
|
||||
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined);
|
||||
|
||||
const model = selectOption(options, "model");
|
||||
expect(model.currentValue).toBe("default");
|
||||
expect(model.options).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
|
||||
);
|
||||
expect(model._meta).toBeUndefined();
|
||||
|
||||
const effort = selectOption(options, "effort");
|
||||
expect(effort.currentValue).toBe("default");
|
||||
expect(effort.options).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
|
||||
);
|
||||
expect(effort._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits default entries and advertises concrete recommendations", () => {
|
||||
const options = buildConfigOptions(
|
||||
MODES,
|
||||
MODELS,
|
||||
MODEL_INFOS,
|
||||
undefined,
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
|
||||
const mode = selectOption(options, "mode");
|
||||
expect(mode.currentValue).toBe("default");
|
||||
expect(mode.options).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
|
||||
);
|
||||
expect(mode._meta).toBeUndefined();
|
||||
|
||||
const model = selectOption(options, "model");
|
||||
expect(model.currentValue).toBe("sonnet");
|
||||
expect(model.options).toHaveLength(3);
|
||||
expect(model.options.map((option) => ("value" in option ? option.value : undefined))).toEqual([
|
||||
"opus",
|
||||
"sonnet",
|
||||
"haiku",
|
||||
]);
|
||||
expect(model.options).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
|
||||
);
|
||||
expect(model._meta).toEqual(recommendedMeta("sonnet"));
|
||||
|
||||
const effort = selectOption(options, "effort");
|
||||
expect(effort.currentValue).toBe("medium");
|
||||
expect(effort.options).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
|
||||
);
|
||||
expect(effort._meta).toEqual(recommendedMeta("medium"));
|
||||
});
|
||||
|
||||
it("preserves explicit selections while keeping independent recommendations", () => {
|
||||
const options = buildConfigOptions(
|
||||
MODES,
|
||||
{ ...MODELS, currentModelId: "opus" },
|
||||
MODEL_INFOS,
|
||||
"high",
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
|
||||
expect(selectOption(options, "model")).toMatchObject({
|
||||
currentValue: "opus",
|
||||
_meta: recommendedMeta("sonnet"),
|
||||
});
|
||||
expect(selectOption(options, "effort")).toMatchObject({
|
||||
currentValue: "high",
|
||||
_meta: recommendedMeta("medium"),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the legacy model sentinel when the SDK recommendation is not selectable", () => {
|
||||
const options = buildConfigOptions(
|
||||
MODES,
|
||||
MODELS,
|
||||
MODEL_INFOS.filter((model) => model.value === "default"),
|
||||
undefined,
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
|
||||
const model = selectOption(options, "model");
|
||||
expect(model.currentValue).toBe("default");
|
||||
expect(model.options).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: "default" })]),
|
||||
);
|
||||
expect(model._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["claude-sonnet-4-6[1m]", "claude-sonnet-5"])(
|
||||
"does not substitute a similar model for the unavailable default %s",
|
||||
(resolvedModel) => {
|
||||
const infos = MODEL_INFOS.map((model) =>
|
||||
model.value === "default" ? { ...model, resolvedModel } : model,
|
||||
);
|
||||
const options = buildConfigOptions(
|
||||
MODES,
|
||||
MODELS,
|
||||
infos,
|
||||
undefined,
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
expect(selectOption(options, "model")).toMatchObject({ currentValue: "default" });
|
||||
expect(selectOption(options, "model")._meta).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("matches equivalent SDK context suffix spellings exactly", () => {
|
||||
const infos = MODEL_INFOS.map((model) => ({
|
||||
...model,
|
||||
resolvedModel:
|
||||
model.value === "default"
|
||||
? "claude-sonnet-4-6-1m"
|
||||
: model.value === "sonnet"
|
||||
? "claude-sonnet-4-6[1m]"
|
||||
: model.resolvedModel,
|
||||
}));
|
||||
const options = buildConfigOptions(
|
||||
MODES,
|
||||
MODELS,
|
||||
infos,
|
||||
undefined,
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
expect(selectOption(options, "model")).toMatchObject({
|
||||
currentValue: "sonnet",
|
||||
_meta: recommendedMeta("sonnet"),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an available effort when medium is not supported", () => {
|
||||
const infos = MODEL_INFOS.map((model) => ({
|
||||
...model,
|
||||
supportedEffortLevels: ["high", "max"] as ModelInfo["supportedEffortLevels"],
|
||||
}));
|
||||
const options = buildConfigOptions(
|
||||
MODES,
|
||||
MODELS,
|
||||
infos,
|
||||
undefined,
|
||||
undefined,
|
||||
RECOMMENDED_PRESENTATION,
|
||||
);
|
||||
expect(selectOption(options, "effort")).toMatchObject({
|
||||
currentValue: "high",
|
||||
_meta: recommendedMeta("high"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { resolvePermissionMode, type Logger } from "../acp-agent.js";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import { resolvePermissionMode } from "../permissions/modes.js";
|
||||
|
||||
function mockLogger() {
|
||||
const error = vi.fn<(...args: any[]) => void>();
|
||||
@@ -19,14 +20,20 @@ describe("resolvePermissionMode", () => {
|
||||
expect(resolvePermissionMode("acceptEdits")).toBe("acceptEdits");
|
||||
expect(resolvePermissionMode("dontAsk")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("plan")).toBe("plan");
|
||||
expect(resolvePermissionMode("bypassPermissions")).toBe("bypassPermissions");
|
||||
expect(resolvePermissionMode("bypassPermissions", console, true)).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("resolves case-insensitive aliases", () => {
|
||||
expect(resolvePermissionMode("DontAsk")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("DONTASK")).toBe("dontAsk");
|
||||
expect(resolvePermissionMode("AcceptEdits")).toBe("acceptEdits");
|
||||
expect(resolvePermissionMode("bypass")).toBe("bypassPermissions");
|
||||
expect(resolvePermissionMode("bypass", console, true)).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
it("clamps bypassPermissions to 'default' and logs when bypass is not allowed", () => {
|
||||
const { logger, error } = mockLogger();
|
||||
expect(resolvePermissionMode("bypassPermissions", logger, false)).toBe("default");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("bypassPermissions"));
|
||||
});
|
||||
|
||||
it("resolves 'manual' as an alias for 'default'", () => {
|
||||
|
||||
49
src/tests/resumed-session.test.ts
Normal file
49
src/tests/resumed-session.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { resumedModelFromTranscript } from "../resumed-session.js";
|
||||
|
||||
function assistant(
|
||||
model: unknown,
|
||||
nesting: Pick<SessionMessage, "parent_tool_use_id" | "parent_agent_id"> = {
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
},
|
||||
): SessionMessage {
|
||||
return {
|
||||
type: "assistant",
|
||||
uuid: crypto.randomUUID(),
|
||||
session_id: "session-id",
|
||||
...nesting,
|
||||
message: { model },
|
||||
};
|
||||
}
|
||||
|
||||
describe("resumedModelFromTranscript", () => {
|
||||
it("returns the last real assistant model", () => {
|
||||
expect(
|
||||
resumedModelFromTranscript([assistant("claude-sonnet-5"), assistant("claude-opus-5")]),
|
||||
).toBe("claude-opus-5");
|
||||
});
|
||||
|
||||
it("skips synthetic assistant records after the real response", () => {
|
||||
expect(resumedModelFromTranscript([assistant("claude-opus-5"), assistant("<synthetic>")])).toBe(
|
||||
"claude-opus-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips nested assistant records that can use a different model", () => {
|
||||
expect(
|
||||
resumedModelFromTranscript([
|
||||
assistant("claude-opus-5"),
|
||||
assistant("claude-haiku-4-5", {
|
||||
parent_tool_use_id: "task-tool-use",
|
||||
parent_agent_id: null,
|
||||
}),
|
||||
]),
|
||||
).toBe("claude-opus-5");
|
||||
});
|
||||
|
||||
it("returns undefined when the transcript has no real assistant model", () => {
|
||||
expect(resumedModelFromTranscript([assistant("<synthetic>")])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { 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";
|
||||
|
||||
const { registerHookCallbackSpy } = vi.hoisted(() => ({
|
||||
registerHookCallbackSpy: vi.fn(),
|
||||
@@ -101,16 +102,15 @@ describe("session config options", () => {
|
||||
applyFlagSettingsSpy = vi.fn();
|
||||
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
|
||||
query: {
|
||||
query: makeMockQuery({
|
||||
setPermissionMode: setPermissionModeSpy,
|
||||
setModel: setModelSpy,
|
||||
applyFlagSettings: applyFlagSettingsSpy,
|
||||
supportedCommands: async () => [],
|
||||
},
|
||||
}),
|
||||
input: null,
|
||||
cancelled: false,
|
||||
permissionMode: "default",
|
||||
settingsManager: {},
|
||||
settingsManager: { getSettings: () => ({}) },
|
||||
modes: structuredClone(MOCK_MODES),
|
||||
models: structuredClone(MOCK_MODELS),
|
||||
modelInfos: MOCK_MODELS.availableModels.map((m): ModelInfo => ({
|
||||
@@ -358,50 +358,6 @@ describe("session config options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionMode sends config_option_update", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("sends config_option_update when mode is changed via setSessionMode", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "acceptEdits" });
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
expect(configUpdate?.update).toMatchObject({
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "mode", currentValue: "acceptEdits" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates stored configOptions currentValue when mode changes", async () => {
|
||||
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
|
||||
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
const modeOption = session.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption?.currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("does not send config_option_update for an invalid mode", async () => {
|
||||
await expect(
|
||||
agent.setSessionMode({ sessionId: SESSION_ID, modeId: "not-a-mode" as any }),
|
||||
).rejects.toThrow("Invalid Mode");
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionConfigOption(model) returns updated configOptions", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
@@ -463,14 +419,18 @@ describe("session config options", () => {
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption).toBeUndefined();
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
// Nothing was pinned at the flag layer (effort was "default"), so there
|
||||
// is nothing to clear — the CLI resolves its own effort for the new model.
|
||||
expect(applyFlagSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clamps effort in returned configOptions when new model has different supported levels", async () => {
|
||||
// Set current effort to "max" which the new model won't support
|
||||
// Set current effort to "max" which the new model won't support —
|
||||
// pinned, as a user's ACP picker choice would be.
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
|
||||
if (effortOpt) effortOpt.currentValue = "max";
|
||||
session.effortPinnedLevel = "max";
|
||||
|
||||
session.modelInfos = [
|
||||
{
|
||||
@@ -502,10 +462,12 @@ describe("session config options", () => {
|
||||
});
|
||||
|
||||
it("preserves effort in returned configOptions when new model supports same level", async () => {
|
||||
// Set effort to "low" first
|
||||
// Set effort to "low" first — pinned, as a user's ACP picker choice
|
||||
// would be (an unpinned value re-seeds from settings on a switch).
|
||||
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";
|
||||
session.effortPinnedLevel = "low";
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
@@ -603,6 +565,23 @@ describe("session config options", () => {
|
||||
expect(effortOption?.currentValue).toBe("medium");
|
||||
});
|
||||
|
||||
it("keeps effort state unchanged when the SDK rejects a direct selection", async () => {
|
||||
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort update failed"));
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
}),
|
||||
).rejects.toThrow("effort update failed");
|
||||
|
||||
const session = agent.sessions[SESSION_ID];
|
||||
expect(session.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("default");
|
||||
expect(session.effortPinnedLevel).toBeUndefined();
|
||||
expect(session.appliedEffortLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws for invalid effort value", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
@@ -645,6 +624,192 @@ describe("session config options", () => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("applies concrete defaults and re-seeds automatic effort from each model's settings", async () => {
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
const session = agent.sessions[SESSION_ID];
|
||||
session.settingsManager.getSettings = () => ({
|
||||
modelSettings: { "claude-opus-4-5": { effortLevel: "high" } },
|
||||
});
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
expect(response.configOptions.find((o) => o.id === "effort")).toMatchObject({
|
||||
currentValue: "medium",
|
||||
_meta: { jetbrains: { air: { recommendedValue: "medium" } } },
|
||||
});
|
||||
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "medium" });
|
||||
expect(session.effortPinnedLevel).toBeUndefined();
|
||||
expect(session.appliedEffortLevel).toBe("medium");
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "high" });
|
||||
expect(session.effortPinnedLevel).toBeUndefined();
|
||||
expect(session.appliedEffortLevel).toBe("high");
|
||||
|
||||
session.modelInfos[1].supportsEffort = false;
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: null });
|
||||
});
|
||||
|
||||
it("re-seeds switches from retained programmatic settings before file settings", async () => {
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
const session = agent.sessions[SESSION_ID];
|
||||
session.settingsManager.getSettings = () => ({ effortLevel: "high" });
|
||||
session.effortSettingsOverride = {
|
||||
effortLevel: "medium",
|
||||
modelSettings: { "claude-sonnet-4-6": { effortLevel: "low" } },
|
||||
};
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low");
|
||||
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "low" });
|
||||
});
|
||||
|
||||
it("clears an unsupported user pin before choosing the new model's concrete effort", async () => {
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
const session = agent.sessions[SESSION_ID];
|
||||
session.modelInfos[0].supportedEffortLevels = ["low", "medium", "high", "max"];
|
||||
session.settingsManager.getSettings = () => ({ effortLevel: "low" });
|
||||
session.configOptions.find((o) => o.id === "effort")!.currentValue = "max";
|
||||
session.effortPinnedLevel = "max";
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low");
|
||||
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: "low" });
|
||||
expect(session.effortPinnedLevel).toBeUndefined();
|
||||
expect(session.appliedEffortLevel).toBe("low");
|
||||
});
|
||||
|
||||
it("clears a legacy pin without promoting persisted effort to a flag override", async () => {
|
||||
const session = agent.sessions[SESSION_ID];
|
||||
session.modelInfos[0].supportedEffortLevels = ["low", "medium", "high", "max"];
|
||||
session.settingsManager.getSettings = () => ({ effortLevel: "low" });
|
||||
session.configOptions.find((o) => o.id === "effort")!.currentValue = "max";
|
||||
session.effortPinnedLevel = "max";
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low");
|
||||
expect(applyFlagSettingsSpy).toHaveBeenLastCalledWith({ effortLevel: null });
|
||||
expect(session.effortPinnedLevel).toBeUndefined();
|
||||
expect(session.appliedEffortLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retains the original pin value when a legacy clamp fails", async () => {
|
||||
const session = agent.sessions[SESSION_ID];
|
||||
session.modelInfos[0].supportedEffortLevels = ["low", "medium", "high", "max"];
|
||||
session.settingsManager.getSettings = () => ({ effortLevel: "low" });
|
||||
session.configOptions.find((o) => o.id === "effort")!.currentValue = "max";
|
||||
session.effortPinnedLevel = "max";
|
||||
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort clear failed"));
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
expect(session.configOptions.find((o) => o.id === "effort")).toBeUndefined();
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("max");
|
||||
expect(session.effortPinnedLevel).toBe("max");
|
||||
});
|
||||
|
||||
it("restores the last applied effort when recommended-value synchronization fails", async () => {
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
const session = agent.sessions[SESSION_ID];
|
||||
session.configOptions.find((o) => o.id === "effort")!.currentValue = "low";
|
||||
session.appliedEffortLevel = "low";
|
||||
session.settingsManager.getSettings = () => ({ effortLevel: "high" });
|
||||
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort sync failed"));
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(response.configOptions.find((o) => o.id === "effort")?.currentValue).toBe("low");
|
||||
expect(session.appliedEffortLevel).toBe("low");
|
||||
expect(session.effortPinnedLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the new model state when effort synchronization fails", async () => {
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort sync failed"));
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
expect(response.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
expect(agent.sessions[SESSION_ID].models.currentModelId).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("publishes the new model state when external-switch effort synchronization fails", async () => {
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
applyFlagSettingsSpy.mockRejectedValueOnce(new Error("effort sync failed"));
|
||||
const session = agent.sessions[SESSION_ID];
|
||||
|
||||
await (agent as any).syncModelAfterExternalSwitch(SESSION_ID, session, "claude-sonnet-4-6");
|
||||
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
expect(session.models.currentModelId).toBe("claude-sonnet-4-6");
|
||||
expect(
|
||||
sessionUpdates
|
||||
.filter((notification) => notification.update.sessionUpdate === "config_option_update")
|
||||
.at(-1)?.update,
|
||||
).toMatchObject({
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "model", currentValue: "claude-sonnet-4-6" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("drops effort option when switching to a model without effort support", async () => {
|
||||
// Make sonnet not support effort
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
@@ -674,7 +839,7 @@ describe("session config options", () => {
|
||||
expect(effortOption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears effort via applyFlagSettings when switching to a model without effort", async () => {
|
||||
it("clears a pinned effort via applyFlagSettings when switching to a model without effort", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modelInfos = [
|
||||
{
|
||||
@@ -692,6 +857,14 @@ describe("session config options", () => {
|
||||
},
|
||||
];
|
||||
|
||||
// Pin an effort the way a user would, then switch to a model that
|
||||
// cannot serve it: the flag layer must be cleared alongside, or the
|
||||
// SDK would keep running the old pin invisibly.
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
});
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
@@ -699,6 +872,8 @@ describe("session config options", () => {
|
||||
});
|
||||
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
// The clamp un-pins: a later switch back re-seeds from settings.
|
||||
expect(session.effortPinnedLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds effort option when switching to a model that supports effort", async () => {
|
||||
@@ -737,9 +912,11 @@ describe("session config options", () => {
|
||||
|
||||
it("clamps effort to valid value when new model has different supported levels", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
// Set current effort to "max" (not supported by sonnet in our mock)
|
||||
// Set current effort to "max" (not supported by sonnet in our mock) —
|
||||
// pinned, as a user's ACP picker choice would be.
|
||||
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
|
||||
if (effortOpt) effortOpt.currentValue = "max";
|
||||
session.effortPinnedLevel = "max";
|
||||
|
||||
session.modelInfos = [
|
||||
{
|
||||
@@ -792,6 +969,48 @@ describe("session config options", () => {
|
||||
// applyFlagSettings was called once for the effort change, but not again for the model switch
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("seeds effort from the new model's persisted modelSettings entry on an unpinned switch", async () => {
|
||||
// The CLI persists /effort per model (settings.modelSettings); with no
|
||||
// user pin this session, the picker should show what the CLI will
|
||||
// actually run on the new model, not drag the old model's value along.
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.settingsManager = {
|
||||
getSettings: () => ({
|
||||
effortLevel: "high",
|
||||
modelSettings: { "claude-sonnet-4-6": { effortLevel: "low" } },
|
||||
}),
|
||||
};
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption?.currentValue).toBe("low");
|
||||
// Display-only: the CLI resolves persisted effort itself; pinning it at
|
||||
// the flag layer would shadow the per-model values on later switches.
|
||||
expect(applyFlagSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the top-level settings effort when the new model has no per-model entry", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.settingsManager = {
|
||||
getSettings: () => ({ effortLevel: "medium" }),
|
||||
};
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption?.currentValue).toBe("medium");
|
||||
expect(applyFlagSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bidirectional consistency", () => {
|
||||
@@ -927,6 +1146,84 @@ describe("session config options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("context window on model change", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
function getSession() {
|
||||
return (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
}
|
||||
|
||||
it("sets the window from text inference on model switch, without any getContextUsage IPC", async () => {
|
||||
// getContextUsage stalls until the session's first prompt turn, so the
|
||||
// switch path must never call it; the window is seeded from the text
|
||||
// heuristic (here via the new model's resolvedModel) and later confirmed
|
||||
// by result.modelUsage.
|
||||
const session = getSession();
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
session.modelInfos = session.modelInfos.map((m: ModelInfo) =>
|
||||
m.value === "claude-sonnet-4-6" ? { ...m, resolvedModel: "claude-sonnet-5[1m]" } : m,
|
||||
);
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
expect(session.contextWindowSize).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("falls back to the default window when inference misses, without any getContextUsage IPC", async () => {
|
||||
const session = getSession();
|
||||
session.contextWindowSize = 1_000_000;
|
||||
// Present but must NOT be called; the switch never consults it.
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
// claude-sonnet-4-6 carries no "1m" token in its id, resolvedModel,
|
||||
// displayName, or description, so inference misses → default window.
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
expect(session.contextWindowSize).toBe(200000);
|
||||
});
|
||||
|
||||
it("does not call getContextUsage even when switching to a fresh model", async () => {
|
||||
const session = getSession();
|
||||
const spy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
session.query.getContextUsage = spy;
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the learned window when re-asserting the current model", async () => {
|
||||
const session = getSession();
|
||||
session.contextWindowSize = 1_000_000;
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 200000 }));
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
expect(session.contextWindowSize).toBe(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto mode availability per model", () => {
|
||||
/**
|
||||
* Augment the session populated by `populateSession()` with a Haiku entry
|
||||
@@ -974,26 +1271,25 @@ describe("session config options", () => {
|
||||
session.modes = {
|
||||
currentModeId,
|
||||
availableModes: [
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Use a model classifier to approve/deny permission prompts",
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
description: "Standard behavior, prompts for dangerous operations",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Auto-accept file edit operations",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
},
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{
|
||||
id: "dontAsk",
|
||||
name: "Don't Ask",
|
||||
description: "Don't prompt for permissions, deny if not pre-approved",
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1033,7 +1329,7 @@ describe("session config options", () => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("drops `auto` from available modes when switching to Haiku", async () => {
|
||||
it("keeps the stable mode catalog when switching to Haiku", async () => {
|
||||
setupHaikuOpusSession("default");
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
@@ -1045,25 +1341,16 @@ describe("session config options", () => {
|
||||
const modeOption = response.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption).toBeDefined();
|
||||
const modeValues = (modeOption as any).options.map((o: any) => o.value);
|
||||
expect(modeValues).not.toContain("auto");
|
||||
expect(modeValues).toEqual(
|
||||
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
|
||||
expect.arrayContaining(["default", "acceptEdits", "plan", "auto"]),
|
||||
);
|
||||
expect(modeValues).not.toContain("dontAsk");
|
||||
});
|
||||
|
||||
it("re-adds `auto` when switching from Haiku back to Opus", async () => {
|
||||
it("keeps the same mode catalog when switching from Haiku back to Opus", async () => {
|
||||
const session = setupHaikuOpusSession("default");
|
||||
// Pretend Haiku is the current model with no `auto`.
|
||||
// Pretend Haiku is the current model; its catalog still advertises Auto.
|
||||
session.models.currentModelId = "claude-haiku-4-5";
|
||||
session.modes.availableModes = session.modes.availableModes.filter(
|
||||
(m: any) => m.id !== "auto",
|
||||
);
|
||||
const modeOpt = session.configOptions.find((o: any) => o.id === "mode");
|
||||
modeOpt.options = session.modes.availableModes.map((m: any) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
}));
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
@@ -1105,8 +1392,8 @@ describe("session config options", () => {
|
||||
expect((modeOption as any).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
|
||||
it("falls back to Accept edits and emits current_mode_update on a Haiku switch", async () => {
|
||||
// Switching Opus(auto) → Haiku changes only the effective mode. 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.
|
||||
@@ -1118,13 +1405,13 @@ describe("session config options", () => {
|
||||
value: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("acceptEdits");
|
||||
|
||||
const modeUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
expect(modeUpdates).toHaveLength(1);
|
||||
expect((modeUpdates[0].update as any).currentModeId).toBe("default");
|
||||
expect((modeUpdates[0].update as any).currentModeId).toBe("acceptEdits");
|
||||
|
||||
// setSessionConfigOption is a request/response API: it returns the new
|
||||
// configOptions in the response rather than emitting a
|
||||
@@ -1136,148 +1423,16 @@ describe("session config options", () => {
|
||||
|
||||
const modeOption = response.configOptions.find((o: any) => o.id === "mode");
|
||||
expect(modeOption).toBeDefined();
|
||||
expect((modeOption as any).currentValue).toBe("default");
|
||||
expect((modeOption as any).options.map((o: any) => o.value)).not.toContain("auto");
|
||||
});
|
||||
|
||||
it("rejects direct setSessionMode to `auto` when the active model does not offer it", async () => {
|
||||
const session = setupHaikuOpusSession("default");
|
||||
session.models.currentModelId = "claude-haiku-4-5";
|
||||
session.modes.availableModes = session.modes.availableModes.filter(
|
||||
(mode: any) => mode.id !== "auto",
|
||||
);
|
||||
|
||||
await expect(agent.setSessionMode({ sessionId: SESSION_ID, modeId: "auto" })).rejects.toThrow(
|
||||
"Mode auto is not available in this session",
|
||||
);
|
||||
|
||||
expect(setPermissionModeSpy).not.toHaveBeenCalledWith("auto");
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExitPlanMode permission options filtered by availableModes", () => {
|
||||
let capturedPermissionRequest: any;
|
||||
let permissionResponse: any;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPermissionRequest = null;
|
||||
permissionResponse = { outcome: { outcome: "cancelled" } };
|
||||
// Replace the default mock client with one that captures the
|
||||
// requestPermission call so we can assert on the offered options.
|
||||
(agent as any).client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
sessionUpdates.push(notification);
|
||||
},
|
||||
requestPermission: async (params: any) => {
|
||||
capturedPermissionRequest = params;
|
||||
return permissionResponse;
|
||||
},
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
};
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("omits the `auto` option on a model without supportsAutoMode", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
// Haiku-shaped session: availableModes does NOT include `auto`.
|
||||
session.modes = {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Default", description: "Standard" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
|
||||
],
|
||||
};
|
||||
|
||||
// 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 {
|
||||
await canUseTool(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal, suggestions: undefined, toolUseID: "toolu_1" },
|
||||
);
|
||||
} catch {
|
||||
// The mock client returns `cancelled`, which makes canUseTool throw.
|
||||
// We only care about the captured requestPermission options.
|
||||
}
|
||||
|
||||
expect(capturedPermissionRequest).not.toBeNull();
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).not.toContain("auto");
|
||||
expect(optionIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"]));
|
||||
});
|
||||
|
||||
it("denies a selected `auto` option if the client did not receive that option", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modes = {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Default", description: "Standard" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
|
||||
],
|
||||
};
|
||||
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(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal: new AbortController().signal, suggestions: undefined, toolUseID: "toolu_2" },
|
||||
);
|
||||
|
||||
expect(capturedPermissionRequest).not.toBeNull();
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).not.toContain("auto");
|
||||
expect(result.behavior).toBe("deny");
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes the `auto` option on a model with supportsAutoMode", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modes = {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "auto", name: "Auto", description: "Use a model classifier" },
|
||||
{ id: "default", name: "Default", description: "Standard" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
|
||||
],
|
||||
};
|
||||
|
||||
// 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 {
|
||||
await canUseTool(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal, suggestions: undefined, toolUseID: "toolu_3" },
|
||||
);
|
||||
} catch {
|
||||
// mock returns cancelled
|
||||
}
|
||||
|
||||
expect(capturedPermissionRequest).not.toBeNull();
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).toContain("auto");
|
||||
expect((modeOption as any).currentValue).toBe("acceptEdits");
|
||||
expect((modeOption as any).options.map((o: any) => o.value)).toContain("auto");
|
||||
expect(
|
||||
sessionUpdates.filter(
|
||||
(n) =>
|
||||
n.update.sessionUpdate === "agent_message_chunk" &&
|
||||
n.update.content.type === "text" &&
|
||||
n.update.content.text.includes("Auto mode unavailable"),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
121
src/tests/session-doubles.ts
Normal file
121
src/tests/session-doubles.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Shared session-level test doubles for the agent suites.
|
||||
*
|
||||
* Distinct from `helpers.ts`, which is deliberately vitest-free so `vi.mock`
|
||||
* factories can `await import` it; these need `vi.fn` spies, so they live apart.
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { SessionTitles } from "../session-titles.js";
|
||||
|
||||
/** Stand-in agent for a `SessionTitles` whose test doesn't care about titles:
|
||||
* swallows the publish and the log, and never matches the identity check. */
|
||||
function inertTitleAgent() {
|
||||
return {
|
||||
client: { sessionUpdate: async () => {} },
|
||||
logger: { error: () => {} },
|
||||
sessions: {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
/** Build the replayed `user` message the SDK echoes back for a pushed prompt,
|
||||
* used by mock generators to promote a turn to active. */
|
||||
export function userEcho(u: any) {
|
||||
return {
|
||||
type: "user",
|
||||
message: u.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: u.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap a mock async generator with the `Query` methods the agent calls outside
|
||||
* of iteration — `close()` (teardown/closeQueryStream), `interrupt()` (cancel),
|
||||
* and `setModel()` — so a bare generator doesn't trip "x is not a function". */
|
||||
export function wrapQuery(generator: AsyncGenerator<any>) {
|
||||
return Object.assign(generator, {
|
||||
interrupt: vi.fn(async () => {}),
|
||||
stopTask: vi.fn(async () => {}),
|
||||
close: vi.fn(),
|
||||
setModel: vi.fn(async () => {}),
|
||||
}) as any;
|
||||
}
|
||||
|
||||
/** The common `Session` mock fields, with per-test overrides spread on top.
|
||||
* Centralizes the boilerplate (usage accumulator, caches, controllers) so a new
|
||||
* Session field is added in one place rather than every inline literal.
|
||||
*
|
||||
* Pass `agent` when the test exercises titles — `SessionTitles` publishes and
|
||||
* logs through it, and compares `agent.sessions[sessionId]` to spot a session
|
||||
* replaced mid-generation. */
|
||||
export function mockSessionState(
|
||||
overrides: Record<string, any> = {},
|
||||
agent?: any,
|
||||
sessionId = "test-session",
|
||||
) {
|
||||
return {
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
titles: new SessionTitles(agent ?? inertTitleAgent(), sessionId),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn(), getSettings: () => ({}) },
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
accumulatedModelUsage: {},
|
||||
lastModelUsageReading: {},
|
||||
configOptions: [],
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
taskState: new Map(),
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
/** One successful turn: echo the pushed prompt, emit a result, go idle. Shared
|
||||
* by suites that only need a session to reach turn-end. */
|
||||
export function successfulResultMessage(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
184
src/tests/session-mode.test.ts
Normal file
184
src/tests/session-mode.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { AUTO_MODE_FALLBACK, type SessionMode, SessionModeManager } from "../session-mode.js";
|
||||
|
||||
const SESSION_ID = "session-1";
|
||||
|
||||
function createSession(overrides: Partial<SessionMode> = {}): SessionMode {
|
||||
return {
|
||||
query: { setPermissionMode: vi.fn() },
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Manual" },
|
||||
{ id: "acceptEdits", name: "Accept edits" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
{ id: "auto", name: "Auto" },
|
||||
],
|
||||
},
|
||||
models: { currentModelId: "opus" },
|
||||
modelInfos: [{ value: "opus", displayName: "Opus", description: "", supportsAutoMode: true }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createNotifications(updates: SessionNotification[]) {
|
||||
return {
|
||||
sessionUpdate: vi.fn(async (update: SessionNotification) => {
|
||||
updates.push(update);
|
||||
}),
|
||||
logError: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createManager(session: SessionMode | undefined, updates: SessionNotification[] = []) {
|
||||
const notifications = createNotifications(updates);
|
||||
const updateConfigOption = vi.fn();
|
||||
const manager = new SessionModeManager({
|
||||
...notifications,
|
||||
getSession: () => session,
|
||||
sessionEndedMessage: "start a new session",
|
||||
updateConfigOption,
|
||||
});
|
||||
return { manager, notifications, updateConfigOption };
|
||||
}
|
||||
|
||||
describe("session mode", () => {
|
||||
it("applies a valid mode to the SDK query", async () => {
|
||||
const session = createSession();
|
||||
const { manager } = createManager(session);
|
||||
await expect(manager.selectMode(SESSION_ID, "plan")).resolves.toBe("plan");
|
||||
expect(session.query.setPermissionMode).toHaveBeenCalledWith("plan");
|
||||
});
|
||||
|
||||
it("rejects invalid and unavailable modes before touching the query", async () => {
|
||||
const session = createSession();
|
||||
const { manager } = createManager(session);
|
||||
await expect(manager.selectMode(SESSION_ID, "future")).rejects.toThrow("Invalid Mode");
|
||||
await expect(manager.selectMode(SESSION_ID, "bypassPermissions")).rejects.toThrow(
|
||||
"Mode bypassPermissions is not available in this session",
|
||||
);
|
||||
expect(session.query.setPermissionMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("detects model-specific Auto availability", () => {
|
||||
const session = createSession({
|
||||
modelInfos: [
|
||||
{ value: "opus", displayName: "Opus", description: "", supportsAutoMode: true },
|
||||
{ value: "haiku", displayName: "Haiku", description: "" },
|
||||
],
|
||||
});
|
||||
const { manager } = createManager(session);
|
||||
expect(manager.effectiveMode(session, "auto")).toBe("auto");
|
||||
session.models.currentModelId = "haiku";
|
||||
expect(manager.effectiveMode(session, "auto")).toBe("acceptEdits");
|
||||
session.models.currentModelId = "unknown";
|
||||
expect(manager.effectiveMode(session, "auto")).toBe("auto");
|
||||
});
|
||||
|
||||
it("falls Auto back to Accept edits and warns only once per session", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const session = createSession({
|
||||
models: { currentModelId: "haiku" },
|
||||
modelInfos: [{ value: "haiku", displayName: "Haiku", description: "" }],
|
||||
});
|
||||
const { manager } = createManager(session, updates);
|
||||
|
||||
await expect(manager.selectMode(SESSION_ID, "auto")).resolves.toBe(AUTO_MODE_FALLBACK);
|
||||
await manager.selectMode(SESSION_ID, "auto");
|
||||
|
||||
expect(session.query.setPermissionMode).toHaveBeenCalledTimes(2);
|
||||
expect(session.query.setPermissionMode).toHaveBeenCalledWith("acceptEdits");
|
||||
expect(
|
||||
updates.filter((notification) => notification.update.sessionUpdate === "agent_message_chunk"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles setSessionMode and publishes the updated config option", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const session = createSession();
|
||||
const { manager, notifications, updateConfigOption } = createManager(session, updates);
|
||||
|
||||
await manager.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
|
||||
|
||||
expect(updateConfigOption).toHaveBeenCalledWith(SESSION_ID, "mode", "plan");
|
||||
expect(notifications.sessionUpdate).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({ sessionUpdate: "current_mode_update" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing and closed sessions", async () => {
|
||||
await expect(
|
||||
createManager(undefined).manager.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" }),
|
||||
).rejects.toThrow("Session not found");
|
||||
|
||||
await expect(
|
||||
createManager(createSession({ queryClosed: true })).manager.setSessionMode({
|
||||
sessionId: SESSION_ID,
|
||||
modeId: "plan",
|
||||
}),
|
||||
).rejects.toThrow("start a new session");
|
||||
});
|
||||
|
||||
it("omits bypassPermissions from the catalog when bypass capability is disabled", async () => {
|
||||
const setPermissionMode = vi.fn();
|
||||
const { manager } = createManager(undefined);
|
||||
const result = await manager.initialize({
|
||||
query: { setPermissionMode },
|
||||
requestedMode: "default",
|
||||
currentModelId: "opus",
|
||||
allowBypass: false,
|
||||
});
|
||||
|
||||
expect(result.modes.availableModes.map((mode) => mode.id)).not.toContain("bypassPermissions");
|
||||
});
|
||||
|
||||
it("initializes the stable mode catalog and falls unsupported Auto back", async () => {
|
||||
const setPermissionMode = vi.fn();
|
||||
const { manager, notifications } = createManager(undefined);
|
||||
const result = await manager.initialize({
|
||||
query: { setPermissionMode },
|
||||
requestedMode: "auto",
|
||||
currentModelInfo: { value: "haiku", displayName: "Haiku", description: "" },
|
||||
currentModelId: "haiku",
|
||||
allowBypass: true,
|
||||
});
|
||||
|
||||
expect(result.modes.currentModeId).toBe("acceptEdits");
|
||||
expect(result.modes.availableModes.map((mode) => mode.id)).toEqual(
|
||||
expect.arrayContaining(["default", "acceptEdits", "plan", "auto"]),
|
||||
);
|
||||
expect(result.autoModeFallbackWarningPending).toBe(true);
|
||||
expect(setPermissionMode).toHaveBeenCalledWith("acceptEdits");
|
||||
expect(notifications.logError).toHaveBeenCalledWith(expect.stringContaining("falling back"));
|
||||
});
|
||||
|
||||
it("keeps mode state and its config option synchronized", () => {
|
||||
const session = {
|
||||
...createSession(),
|
||||
configOptions: [SessionModeManager.configOption(createSession().modes)],
|
||||
};
|
||||
createManager(session).manager.syncConfig(session, "plan");
|
||||
|
||||
expect(session.modes.currentModeId).toBe("plan");
|
||||
expect(session.configOptions[0].currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("reconciles Auto after a model switch", async () => {
|
||||
const session = createSession();
|
||||
session.modes.currentModeId = "auto";
|
||||
const { manager } = createManager(session);
|
||||
|
||||
await expect(
|
||||
manager.reconcileForModel(session, {
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(session.modes.currentModeId).toBe("acceptEdits");
|
||||
expect(session.query.setPermissionMode).toHaveBeenCalledWith("acceptEdits");
|
||||
});
|
||||
});
|
||||
98
src/tests/session-permission.test.ts
Normal file
98
src/tests/session-permission.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const SESSION_ID = "test-session-id";
|
||||
|
||||
describe("session permission updates", () => {
|
||||
let agent: ClaudeAcpAgent;
|
||||
let capturedPermissionRequest: any;
|
||||
let permissionResponse: any;
|
||||
let sessionUpdates: SessionNotification[];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPermissionRequest = null;
|
||||
permissionResponse = { outcome: { outcome: "cancelled" } };
|
||||
sessionUpdates = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
sessionUpdates.push(notification);
|
||||
},
|
||||
requestPermission: async (params: any) => {
|
||||
capturedPermissionRequest = params;
|
||||
return permissionResponse;
|
||||
},
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
agent = new ClaudeAcpAgent(client);
|
||||
|
||||
agent.sessions[SESSION_ID] = {
|
||||
query: makeMockQuery(),
|
||||
cwd: process.cwd(),
|
||||
modes: {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Manual" },
|
||||
{ id: "acceptEdits", name: "Accept edits" },
|
||||
{ id: "plan", name: "Plan" },
|
||||
],
|
||||
},
|
||||
models: { currentModelId: "opus", availableModels: [] },
|
||||
modelInfos: [],
|
||||
configOptions: [],
|
||||
emittedToolCalls: new Set(["toolu_1"]),
|
||||
contextWindowSize: 200_000,
|
||||
toolUseCache: {},
|
||||
} as any;
|
||||
});
|
||||
|
||||
it("maps an ExitPlanMode choice to the selected session mode effect", async () => {
|
||||
permissionResponse = { outcome: { outcome: "selected", optionId: "exit-plan-default" } };
|
||||
|
||||
const result = await (agent as any).canUseTool(SESSION_ID)(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal: new AbortController().signal, toolUseID: "toolu_1" },
|
||||
);
|
||||
|
||||
expect(capturedPermissionRequest.options.map((option: any) => option.optionId)).toEqual([
|
||||
"exit-plan-default",
|
||||
"exit-plan-clear-accept-edits",
|
||||
"exit-plan-accept-edits",
|
||||
"reject",
|
||||
]);
|
||||
expect(capturedPermissionRequest._meta).toEqual({
|
||||
permission: { version: 1, title: "Ready to code?" },
|
||||
});
|
||||
expect(result.updatedPermissions).toEqual([
|
||||
{ type: "setMode", mode: "default", destination: "session" },
|
||||
]);
|
||||
expect(agent.sessions[SESSION_ID].modes.currentModeId).toBe("plan");
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls an Auto permission effect back when the current model cannot use Auto", async () => {
|
||||
const session = agent.sessions[SESSION_ID];
|
||||
session.modes.availableModes.push({ id: "auto", name: "Auto" });
|
||||
session.models.currentModelId = "haiku";
|
||||
session.modelInfos = [{ value: "haiku", displayName: "Haiku", description: "" }];
|
||||
permissionResponse = { outcome: { outcome: "selected", optionId: "exit-plan-auto" } };
|
||||
|
||||
const result = await (agent as any).canUseTool(SESSION_ID)(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal: new AbortController().signal, toolUseID: "toolu_1" },
|
||||
);
|
||||
|
||||
expect(result.updatedPermissions).toEqual([
|
||||
{ type: "setMode", mode: "acceptEdits", destination: "session" },
|
||||
]);
|
||||
expect(
|
||||
sessionUpdates.filter(
|
||||
(notification) => notification.update.sessionUpdate === "agent_message_chunk",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
17
src/tests/session-timing.test.ts
Normal file
17
src/tests/session-timing.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionTiming } from "../session-timing.js";
|
||||
|
||||
describe("SessionTiming", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("includes work completed before construction when given a start time", () => {
|
||||
vi.spyOn(performance, "now").mockReturnValue(175);
|
||||
const log = vi.fn();
|
||||
|
||||
new SessionTiming({ log }, "create", "session-id", 100).phase("validate-cwd");
|
||||
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
"[session/create] sessionId=session-id phase=validate-cwd durationMs=75 totalMs=75",
|
||||
);
|
||||
});
|
||||
});
|
||||
338
src/tests/session-titles.test.ts
Normal file
338
src/tests/session-titles.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { appendTitleContext } from "../session-titles.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
userEcho,
|
||||
wrapQuery,
|
||||
} from "./session-doubles.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
return { ...actual, getSessionInfo: vi.fn() };
|
||||
});
|
||||
|
||||
describe("SDK title generation contract", () => {
|
||||
// Smoke test for the one undeclared SDK method this feature rests on:
|
||||
// `generateSessionTitle` is on the runtime `Query` class but absent from
|
||||
// `sdk.d.ts`, so nothing else would catch its removal. Method names survive
|
||||
// bundling (only module-scope identifiers get mangled), so an SDK upgrade that
|
||||
// renames or drops it fails here instead of silently degrading titles back to
|
||||
// raw prompts. Runs unconditionally — it reads the shipped bundle and never
|
||||
// spawns the CLI.
|
||||
it("SDK still ships generateSessionTitle and its control subtype", async () => {
|
||||
const sdkEntry = createRequire(import.meta.url).resolve("@anthropic-ai/claude-agent-sdk");
|
||||
const bundle = await readFile(sdkEntry, "utf8");
|
||||
expect({
|
||||
method: bundle.includes("async generateSessionTitle("),
|
||||
subtype: bundle.includes('subtype:"generate_session_title"'),
|
||||
}).toEqual({ method: true, subtype: true });
|
||||
});
|
||||
|
||||
it("keeps only the trailing title context", () => {
|
||||
expect(appendTitleContext("abc", "def")).toBe("abcdef");
|
||||
expect(appendTitleContext(undefined, "abc")).toBe("abc");
|
||||
const overflowed = appendTitleContext("x".repeat(1000), "tail");
|
||||
expect(overflowed).toHaveLength(1000);
|
||||
expect(overflowed.endsWith("tail")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session titles at turn-end", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSessionInfo).mockReset();
|
||||
});
|
||||
|
||||
/** Collect every `session_info_update` an agent pushes. */
|
||||
function titleRecorder() {
|
||||
const updates: any[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (u: any) => {
|
||||
if (u.update?.sessionUpdate === "session_info_update") updates.push(u.update);
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
return { client, titles: () => updates.map((u) => u.title) };
|
||||
}
|
||||
|
||||
/** `wrapQuery` plus the undeclared `generateSessionTitle` the real `Query`
|
||||
* exposes, so turn-end takes the generate branch. */
|
||||
function wrapTitleQuery(generator: AsyncGenerator<any>, title: string | null) {
|
||||
const generateSessionTitle = vi.fn(async (_d: string, _o?: { persist?: boolean }) => title);
|
||||
return {
|
||||
query: Object.assign(wrapQuery(generator), { generateSessionTitle }),
|
||||
generateSessionTitle,
|
||||
};
|
||||
}
|
||||
|
||||
/** One turn: echo the pushed prompt, succeed, go idle. */
|
||||
async function* oneTurn(input: Pushable<any>, turns = 1) {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
for (let i = 0; i < turns; i++) {
|
||||
const { value: userMessage } = await iter.next();
|
||||
yield userEcho(userMessage);
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
}
|
||||
|
||||
function newAgent(client: AcpClient) {
|
||||
return new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
const LONG_PROMPT = "Explain what the add function in hello.py does, in one sentence";
|
||||
|
||||
it("pushes a session_info_update when the SDK generates a title at turn-end", async () => {
|
||||
const sessionUpdates: any[] = [];
|
||||
const agent = newAgent({
|
||||
sessionUpdate: async (u: any) => {
|
||||
sessionUpdates.push(u);
|
||||
},
|
||||
} as unknown as AcpClient);
|
||||
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: "Fix the flaky title test",
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
agent.sessions["test-session"] = mockSessionState(
|
||||
{
|
||||
query: wrapQuery(oneTurn(input)),
|
||||
input,
|
||||
},
|
||||
agent,
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
const titleUpdate = sessionUpdates.find(
|
||||
(u) => u.update?.sessionUpdate === "session_info_update",
|
||||
);
|
||||
expect(titleUpdate?.update).toEqual({
|
||||
sessionUpdate: "session_info_update",
|
||||
title: "Fix the flaky title test",
|
||||
updatedAt: new Date(1_700_000_000_000).toISOString(),
|
||||
});
|
||||
expect(getSessionInfo).toHaveBeenCalledWith("test-session", { dir: "/test" });
|
||||
});
|
||||
|
||||
it("does not re-push session_info_update when the title is unchanged", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: "Stable title",
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
agent.sessions["test-session"] = mockSessionState(
|
||||
{
|
||||
query: wrapQuery(oneTurn(input, 2)),
|
||||
input,
|
||||
},
|
||||
agent,
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "one" }] });
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "two" }] });
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
expect(titles()).toEqual(["Stable title"]);
|
||||
});
|
||||
|
||||
it("generates a title at turn-end instead of publishing the raw prompt", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
// What an SDK-driven session really looks like: nothing has written a title,
|
||||
// so `summary` is just the opening prompt.
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: LONG_PROMPT,
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
const { query, generateSessionTitle } = wrapTitleQuery(
|
||||
oneTurn(input),
|
||||
"Explain add() in hello.py",
|
||||
);
|
||||
agent.sessions["test-session"] = mockSessionState({ query, input }, agent);
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: LONG_PROMPT }],
|
||||
});
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
// Generation is fired off the turn-end path, so it lands after `idle`.
|
||||
await vi.waitFor(() => {
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(generateSessionTitle).toHaveBeenCalledWith(LONG_PROMPT, { persist: true });
|
||||
// The raw prompt was never published — only the generated title.
|
||||
expect(titles()).toEqual(["Explain add() in hello.py"]);
|
||||
});
|
||||
|
||||
it("adopts a stored title and never generates over it", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
// The SDK folds a `/rename` and an earlier generated title into the same
|
||||
// field, so a non-empty `customTitle` means hands off.
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: "Renamed by the user",
|
||||
customTitle: "Renamed by the user",
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
const { query, generateSessionTitle } = wrapTitleQuery(oneTurn(input), "Generated instead");
|
||||
agent.sessions["test-session"] = mockSessionState({ query, input }, agent);
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: LONG_PROMPT }],
|
||||
});
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
expect(generateSessionTitle).not.toHaveBeenCalled();
|
||||
expect(titles()).toEqual(["Renamed by the user"]);
|
||||
});
|
||||
|
||||
it("generates once per session, and a later turn can't fall back to the prompt", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
// `getSessionInfo` never reports the persisted title here, so the
|
||||
// once-per-session latch is the only thing holding the second turn back.
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: LONG_PROMPT,
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
const { query, generateSessionTitle } = wrapTitleQuery(
|
||||
oneTurn(input, 2),
|
||||
"Explain add() in hello.py",
|
||||
);
|
||||
agent.sessions["test-session"] = mockSessionState({ query, input }, agent);
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: LONG_PROMPT }],
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "And what is the capital of France?" }],
|
||||
});
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
expect(titles()).toEqual(["Explain add() in hello.py"]);
|
||||
});
|
||||
|
||||
it("releases the latch when generation yields no title", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: LONG_PROMPT,
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
const { query, generateSessionTitle } = wrapTitleQuery(oneTurn(input, 2), null);
|
||||
agent.sessions["test-session"] = mockSessionState({ query, input }, agent);
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: LONG_PROMPT }],
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "And what is the capital of France?" }],
|
||||
});
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
await vi.waitFor(() => {
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Nothing generated, so the client still gets the summary fallback.
|
||||
expect(titles()).toEqual([LONG_PROMPT]);
|
||||
});
|
||||
|
||||
it("skips argument-less slash commands and bash openers when building the title context", async () => {
|
||||
const { client } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: "/compact",
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
const { query, generateSessionTitle } = wrapTitleQuery(oneTurn(input), "Compact the session");
|
||||
agent.sessions["test-session"] = mockSessionState({ query, input }, agent);
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
});
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
expect(generateSessionTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes slash-command arguments in the title context", async () => {
|
||||
const { client, titles } = titleRecorder();
|
||||
const agent = newAgent(client);
|
||||
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({
|
||||
sessionId: "test-session",
|
||||
summary: "/investigate-alert kafka lag on prod-3",
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
|
||||
const input = new Pushable<any>();
|
||||
const { query, generateSessionTitle } = wrapTitleQuery(
|
||||
oneTurn(input),
|
||||
"Investigate Kafka lag alert on prod-3",
|
||||
);
|
||||
agent.sessions["test-session"] = mockSessionState({ query, input }, agent);
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "/investigate-alert kafka lag on prod-3" }],
|
||||
});
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(generateSessionTitle).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/investigate-alert kafka lag on prod-3"),
|
||||
{ persist: true },
|
||||
);
|
||||
expect(titles()).toEqual(["Investigate Kafka lag alert on prod-3"]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
22
src/tool-result-meta.ts
Normal file
22
src/tool-result-meta.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type ToolResultMeta = {
|
||||
nonExecutionKind: string;
|
||||
userFeedback?: string;
|
||||
};
|
||||
|
||||
/** Validate the SDK's currently untyped tool_result_meta sidecar. Unknown
|
||||
* non-execution kinds are preserved so newer CLIs remain forward-compatible. */
|
||||
export function parseToolResultMeta(raw: unknown): Map<string, ToolResultMeta> | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
|
||||
let byToolUseId: Map<string, ToolResultMeta> | undefined;
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== "object" || entry === null) continue;
|
||||
const { id, non_execution_kind, user_feedback } = entry as Record<string, unknown>;
|
||||
if (typeof id !== "string" || typeof non_execution_kind !== "string") continue;
|
||||
(byToolUseId ??= new Map()).set(id, {
|
||||
nonExecutionKind: non_execution_kind,
|
||||
...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}),
|
||||
});
|
||||
}
|
||||
return byToolUseId;
|
||||
}
|
||||
744
src/tools.ts
744
src/tools.ts
@@ -8,22 +8,29 @@ import {
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
AskUserQuestionInput,
|
||||
BashInput,
|
||||
BashOutput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileReadOutput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
ReportFindingsInput,
|
||||
TaskCreateInput,
|
||||
TaskCreateOutput,
|
||||
TaskListOutput,
|
||||
TaskUpdateInput,
|
||||
TaskUpdateOutput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
WebSearchOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
DocumentBlockParam,
|
||||
ImageBlockParam,
|
||||
TextBlockParam,
|
||||
ToolResultBlockParam,
|
||||
@@ -56,7 +63,6 @@ import {
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import path from "node:path";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
|
||||
@@ -64,6 +70,7 @@ import { Logger } from "./acp-agent.js";
|
||||
*/
|
||||
type ToolResultContent =
|
||||
| TextBlockParam
|
||||
| DocumentBlockParam
|
||||
| ImageBlockParam
|
||||
| BetaImageBlockParam
|
||||
| BetaToolReferenceBlock
|
||||
@@ -149,7 +156,8 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "Bash": {
|
||||
case "Bash":
|
||||
case "PowerShell": {
|
||||
const input = toolUse.input as BashInput | undefined;
|
||||
return {
|
||||
title: input?.command ? input.command : "Terminal",
|
||||
@@ -213,7 +221,7 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: displayPath ? `Write ${displayPath}` : "Write",
|
||||
title: displayPath ? `Write ${displayPath}` : "Preparing file…",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
@@ -344,18 +352,9 @@ export function toolInfoFromToolUse(
|
||||
|
||||
case "WebSearch": {
|
||||
const input = toolUse.input as WebSearchInput | undefined;
|
||||
let label = input?.query ? `"${input.query}"` : "Web search";
|
||||
|
||||
if (input?.allowed_domains && input.allowed_domains.length > 0) {
|
||||
label += ` (allowed: ${input.allowed_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
if (input?.blocked_domains && input.blocked_domains.length > 0) {
|
||||
label += ` (blocked: ${input.blocked_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
return {
|
||||
title: label,
|
||||
title: input?.query ? `Search "${input.query}"` : "Web search",
|
||||
kind: "fetch",
|
||||
content: [],
|
||||
};
|
||||
@@ -428,7 +427,7 @@ export function toolInfoFromToolUse(
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string } | undefined;
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
title: "Approve Plan",
|
||||
kind: "switch_mode",
|
||||
content: planInput?.plan
|
||||
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
|
||||
@@ -436,6 +435,16 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "Skill": {
|
||||
const input = toolUse.input as { skill?: string; args?: string } | undefined;
|
||||
const skillName = input?.skill;
|
||||
return {
|
||||
title: skillName ? `Load skill: ${skillName}` : "Load skill",
|
||||
kind: "other",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "AskUserQuestion": {
|
||||
const input = toolUse.input as Partial<AskUserQuestionInput> | undefined;
|
||||
const questions = Array.isArray(input?.questions) ? input.questions : [];
|
||||
@@ -486,6 +495,132 @@ 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;
|
||||
}
|
||||
|
||||
/** Leading model-directed note the CLI prepends to a subagent's report when
|
||||
* the agent stopped at its maxTurns limit (CLI 2.1.246+); the result still
|
||||
* ships as `status: "completed"`. Two body variants follow this prefix, and
|
||||
* the trailing "Send the agent a message (SendMessage) …" sentence is
|
||||
* omitted for some agent types — anchor only the stable prefix so a format
|
||||
* change makes the replacement stop matching rather than mangle a report. */
|
||||
const PARTIAL_OUTPUT_NOTE = /^NOTE: this agent stopped at its \d+-turn limit before finishing\./;
|
||||
|
||||
/** Client-facing replacement: the partial-output fact matters to the user,
|
||||
* but the SendMessage continuation instruction is model-directed and
|
||||
* meaningless over ACP. */
|
||||
const PARTIAL_OUTPUT_LABEL = "[Agent stopped at its turn limit — the output below is partial]";
|
||||
|
||||
/** Replace a leading partial-output note paragraph with the concise
|
||||
* client-facing label, leaving the report that follows intact. */
|
||||
function replacePartialNoteInText(text: string): string {
|
||||
if (!PARTIAL_OUTPUT_NOTE.test(text)) return text;
|
||||
const paragraphEnd = text.indexOf("\n\n");
|
||||
const report = paragraphEnd === -1 ? "" : text.slice(paragraphEnd + 2).trimStart();
|
||||
return report ? `${PARTIAL_OUTPUT_LABEL}\n\n${report}` : PARTIAL_OUTPUT_LABEL;
|
||||
}
|
||||
|
||||
/** Apply {@link replacePartialNoteInText} to an Agent/Task result `content`.
|
||||
* In the structured AgentOutput lane the note is its own leading text block;
|
||||
* in the raw lane it is the first paragraph of the text — both reduce to
|
||||
* transforming the first text block (or the plain string). */
|
||||
function replacePartialOutputNote(content: unknown): unknown {
|
||||
if (typeof content === "string") {
|
||||
return replacePartialNoteInText(content);
|
||||
}
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
const [first, ...rest] = content;
|
||||
if (
|
||||
first !== null &&
|
||||
typeof first === "object" &&
|
||||
first.type === "text" &&
|
||||
typeof first.text === "string"
|
||||
) {
|
||||
return [{ ...first, text: replacePartialNoteInText(first.text) }, ...rest];
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult:
|
||||
| ToolResultBlockParam
|
||||
@@ -500,20 +635,76 @@ export function toolUpdateFromToolResult(
|
||||
| BetaToolSearchToolResultBlockParam,
|
||||
toolUse: any | undefined,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
toolUseResult?: unknown,
|
||||
): ToolUpdate {
|
||||
if (
|
||||
"is_error" in toolResult &&
|
||||
toolResult.is_error &&
|
||||
toolResult.content &&
|
||||
toolResult.content.length > 0 &&
|
||||
!(toolUse?.name === "Bash" && supportsTerminalOutput)
|
||||
!((toolUse?.name === "Bash" || toolUse?.name === "PowerShell") && supportsTerminalOutput)
|
||||
) {
|
||||
// Only return errors
|
||||
return toAcpContentUpdate(toolResult.content, true);
|
||||
}
|
||||
|
||||
// Shared raw-text fallback: renders the tool_result content the model saw.
|
||||
// The structured cases below fall back to this when `tool_use_result` is
|
||||
// absent or fails its shape guard (older CLIs, replayed sessions).
|
||||
const rawContentUpdate = () =>
|
||||
toAcpContentUpdate(toolResult.content, "is_error" in toolResult ? toolResult.is_error : false);
|
||||
|
||||
switch (toolUse?.name) {
|
||||
case "Read":
|
||||
case "Read": {
|
||||
// The raw tool_result text is the model-facing view: line-numbered
|
||||
// content plus any appended <system-reminder> blocks (malicious-code
|
||||
// checks, memory staleness notes, …) that clients shouldn't see. The
|
||||
// structured FileReadOutput carries the clean content — rebuild the
|
||||
// line-numbered view from it. Non-text variants (image/notebook/pdf)
|
||||
// fall back to the raw content blocks, which already render fine.
|
||||
const structuredRead = structuredResult<FileReadOutput>(toolUseResult);
|
||||
if (
|
||||
structuredRead?.type === "text" &&
|
||||
typeof structuredRead.file?.content === "string" &&
|
||||
// An empty file has nothing to line-number; keep the raw view (the
|
||||
// model-facing "file is empty" note) rather than a phantom blank line.
|
||||
structuredRead.file.content.length > 0
|
||||
) {
|
||||
// startLine is typed non-optional but defended anyway; a Read's
|
||||
// `offset` input is the same 1-based starting line, so it beats a
|
||||
// blind 1 when an emitter omits the field.
|
||||
const startLine =
|
||||
structuredRead.file.startLine ??
|
||||
(toolUse.input as FileReadInput | undefined)?.offset ??
|
||||
1;
|
||||
// A trailing newline is a line terminator, not an extra line — don't
|
||||
// number a phantom empty line after it.
|
||||
let numbered = structuredRead.file.content
|
||||
.replace(/\n$/, "")
|
||||
.split("\n")
|
||||
.map((line, i) => `${startLine + i}\t${line}`)
|
||||
.join("\n");
|
||||
// The model-facing truncation banner doesn't survive reconstruction
|
||||
// from file.content (the SDK flag exists for exactly this case) —
|
||||
// re-establish it so a partial first page doesn't read as the whole
|
||||
// file.
|
||||
if (structuredRead.file.truncatedByTokenCap) {
|
||||
const { numLines, totalLines } = structuredRead.file;
|
||||
const detail =
|
||||
typeof numLines === "number" && typeof totalLines === "number"
|
||||
? `: showing ${numLines} of ${totalLines} lines`
|
||||
: "";
|
||||
numbered += `\n[File truncated${detail}]`;
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: markdownEscape(numbered) },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
|
||||
return {
|
||||
content: toolResult.content.map((content: any) => ({
|
||||
@@ -541,22 +732,78 @@ export function toolUpdateFromToolResult(
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Bash": {
|
||||
case "Bash":
|
||||
case "PowerShell": {
|
||||
const result = toolResult.content;
|
||||
const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
|
||||
// The terminal was announced under the tool_use's own id (see
|
||||
// `toolInfoFromToolUse`), so key the output/exit metas off that: it is the
|
||||
// id the client actually created a terminal for. `toolResult.tool_use_id`
|
||||
// is the same value whenever present — the caller looks the tool_use up by
|
||||
// it — so preferring `toolUse.id` only adds a source for the case where the
|
||||
// result block carries no id at all. Anything that isn't a non-empty
|
||||
// string is no id at all: `""` matches no terminal, and stringifying a
|
||||
// present-but-undefined field would invent the literal `"undefined"`.
|
||||
const terminalIdOf = (id: unknown): string | undefined =>
|
||||
typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
const terminalId: string | undefined =
|
||||
terminalIdOf(toolUse?.id) ??
|
||||
terminalIdOf("tool_use_id" in toolResult ? toolResult.tool_use_id : undefined);
|
||||
const isError = "is_error" in toolResult && toolResult.is_error;
|
||||
|
||||
// Extract output and exit code from either format:
|
||||
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
|
||||
// 2. Plain string content from a regular tool_result
|
||||
// 3. Array content (e.g. [{ type: "text", text: "..." }] for stdout,
|
||||
// 1. The structured BashOutput (message-level tool_use_result): its
|
||||
// stdout/stderr exclude the model-directed suffixes the raw text
|
||||
// carries (stale-read hints, gh rate-limit hints, the
|
||||
// persisted-output wrapper for too-large outputs — the interruption
|
||||
// and truncation facts those carried are re-established from the
|
||||
// structured flags below). Skipped for image output (the raw content
|
||||
// array carries the actual image blocks) and backgrounded commands
|
||||
// (the raw text carries the background-task notice; structured
|
||||
// stdout may be empty).
|
||||
// 2. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
|
||||
// 3. Plain string content from a regular tool_result
|
||||
// 4. Array content (e.g. [{ type: "text", text: "..." }] for stdout,
|
||||
// or [{ type: "image", source: {...} }] when the local Bash tool
|
||||
// produces an image, e.g. piping a base64 data URI)
|
||||
let output = "";
|
||||
let exitCode = isError ? 1 : 0;
|
||||
|
||||
const structuredBash = structuredResult<BashOutput>(toolUseResult);
|
||||
if (
|
||||
structuredBash &&
|
||||
typeof structuredBash.stdout === "string" &&
|
||||
typeof structuredBash.stderr === "string" &&
|
||||
!structuredBash.isImage &&
|
||||
structuredBash.backgroundTaskId === undefined
|
||||
) {
|
||||
output = [structuredBash.stdout, structuredBash.stderr].filter(Boolean).join("\n");
|
||||
// Two raw-text notices don't survive the structured stdout/stderr —
|
||||
// re-establish them so the client isn't shown a clean-looking result:
|
||||
// the CLI appends its abort marker only to the model-facing text, and
|
||||
// an aborted command isn't a success, so synthesize a failing exit
|
||||
// code when the result wasn't already an error.
|
||||
if (structuredBash.interrupted) {
|
||||
output = [output, "[Command was aborted before completion]"].filter(Boolean).join("\n");
|
||||
exitCode = 1;
|
||||
}
|
||||
// Structured stdout is clipped (~30k chars) when the full output was
|
||||
// persisted to disk; without this note the clip is silent and the
|
||||
// path to the full output is lost.
|
||||
if (typeof structuredBash.persistedOutputPath === "string") {
|
||||
const size =
|
||||
typeof structuredBash.persistedOutputSize === "number"
|
||||
? ` (${structuredBash.persistedOutputSize} bytes total)`
|
||||
: "";
|
||||
output = [
|
||||
output,
|
||||
`[Output truncated${size}: full output saved to ${structuredBash.persistedOutputPath}]`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
} else if (
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"type" in result &&
|
||||
@@ -583,7 +830,14 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
}
|
||||
|
||||
if (supportsTerminalOutput) {
|
||||
// Without a terminal id there is nothing the client can reconcile these
|
||||
// metas against, and emitting them anyway strands the output: a client that
|
||||
// buffers output/exit for terminals it has not been told about (Zed keeps
|
||||
// them in `pending_terminal_output`/`pending_terminal_exit`, drained only on
|
||||
// a matching create) would hold them forever behind an id that never
|
||||
// arrives, showing an empty terminal. Fall through to the code-block
|
||||
// rendering below instead.
|
||||
if (supportsTerminalOutput && terminalId !== undefined) {
|
||||
return {
|
||||
content: [{ type: "terminal" as const, terminalId }],
|
||||
_meta: {
|
||||
@@ -619,6 +873,54 @@ 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(
|
||||
// A maxTurns-stopped subagent still completes, with a model-directed
|
||||
// partial-output note prepended to its report — swap it for a
|
||||
// client-facing label (see PARTIAL_OUTPUT_NOTE).
|
||||
replacePartialOutputNote(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(
|
||||
// Head and tail cleanups are independent: the partial-output note
|
||||
// leads the raw text the same way it leads the structured content.
|
||||
replacePartialOutputNote(stripAgentTrailerFromContent(toolResult.content)),
|
||||
"is_error" in toolResult ? toolResult.is_error : false,
|
||||
);
|
||||
}
|
||||
|
||||
case "Skill": {
|
||||
return {};
|
||||
}
|
||||
|
||||
case "Edit": // Edit is handled in hooks
|
||||
case "Write": {
|
||||
return {};
|
||||
@@ -628,15 +930,62 @@ export function toolUpdateFromToolResult(
|
||||
return { title: "Exited Plan Mode" };
|
||||
}
|
||||
|
||||
case "WebSearch": {
|
||||
// The raw tool_result text is a model-directed dump ("Web search
|
||||
// results for query: …\n\nLinks: [{…json…}]"). The structured
|
||||
// WebSearchOutput carries the hits — render them the way server-side
|
||||
// web_search_result blocks render ("Title (url)").
|
||||
const structuredSearch = structuredResult<WebSearchOutput>(toolUseResult);
|
||||
if (structuredSearch && Array.isArray(structuredSearch.results)) {
|
||||
const lines = structuredSearch.results.flatMap((entry) =>
|
||||
typeof entry === "string"
|
||||
? [entry]
|
||||
: Array.isArray(entry?.content)
|
||||
? // tool_use_result arrives untyped across CLI version skew —
|
||||
// skip off-spec hits rather than rendering
|
||||
// "undefined (undefined)" lines.
|
||||
entry.content.flatMap((hit) =>
|
||||
typeof hit?.title === "string" && typeof hit?.url === "string"
|
||||
? [formatWebSearchHit(hit)]
|
||||
: [],
|
||||
)
|
||||
: [],
|
||||
);
|
||||
if (lines.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: lines.join("\n") },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
return rawContentUpdate();
|
||||
}
|
||||
|
||||
default: {
|
||||
return toAcpContentUpdate(
|
||||
toolResult.content,
|
||||
"is_error" in toolResult ? toolResult.is_error : false,
|
||||
);
|
||||
return rawContentUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One display format for a web-search hit, shared by the structured
|
||||
* WebSearchOutput render and the server-side `web_search_result` block so
|
||||
* the two paths can't drift. */
|
||||
function formatWebSearchHit(hit: { title: string; url: string }): string {
|
||||
return `${hit.title} (${hit.url})`;
|
||||
}
|
||||
|
||||
/** Human-readable size for the document placeholder ("312 B", "2.4 KB",
|
||||
* "1.3 MB"). */
|
||||
function formatByteSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function toAcpContentUpdate(
|
||||
content: any,
|
||||
isError: boolean = false,
|
||||
@@ -700,6 +1049,33 @@ function toAcpContentBlock(content: ToolResultContent, isError: boolean): Conten
|
||||
: "[image: file reference]",
|
||||
);
|
||||
|
||||
case "document": {
|
||||
// A PDF Read delivers its raw `document` block inside the tool_result
|
||||
// content (SDK 0.3.243 moved it here from a separate follow-up user
|
||||
// message; the MultiRead documents lane always lived here). ACP has no
|
||||
// document block and the base64 payload can be megabytes — render a
|
||||
// compact placeholder, never the data, matching the CLI's own compact
|
||||
// "Read PDF (size)" rendering.
|
||||
const title =
|
||||
typeof content.title === "string" && content.title.length > 0 ? ` "${content.title}"` : "";
|
||||
const source = content.source;
|
||||
switch (source.type) {
|
||||
case "url":
|
||||
return wrapText(`[document${title}: ${source.url}]`);
|
||||
case "base64":
|
||||
case "text":
|
||||
// base64 inflates the byte count by 4/3; plain text is 1:1.
|
||||
return wrapText(
|
||||
`[document${title}: ${source.media_type}, ${formatByteSize(
|
||||
source.type === "base64"
|
||||
? Math.floor((source.data.length * 3) / 4)
|
||||
: source.data.length,
|
||||
)}]`,
|
||||
);
|
||||
default:
|
||||
return wrapText(`[document${title}]`);
|
||||
}
|
||||
}
|
||||
case "tool_reference":
|
||||
return wrapText(`Tool: ${content.tool_name}`);
|
||||
case "tool_search_tool_search_result":
|
||||
@@ -711,7 +1087,7 @@ function toAcpContentBlock(content: ToolResultContent, isError: boolean): Conten
|
||||
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
|
||||
);
|
||||
case "web_search_result":
|
||||
return wrapText(`${content.title} (${content.url})`);
|
||||
return wrapText(formatWebSearchHit(content));
|
||||
case "web_search_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "web_fetch_result":
|
||||
@@ -749,7 +1125,7 @@ export type ClaudePlanEntry = {
|
||||
|
||||
export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): PlanEntry[] {
|
||||
return (input?.todos ?? []).map((todo) => ({
|
||||
content: todo.content,
|
||||
content: todo.status === "in_progress" && todo.activeForm ? todo.activeForm : todo.content,
|
||||
status: todo.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
@@ -771,31 +1147,29 @@ export type TaskEntry = {
|
||||
export type TaskState = Map<string, TaskEntry>;
|
||||
|
||||
/**
|
||||
* Best-effort parse of a TaskCreate tool_result content into the structured
|
||||
* TaskCreateOutput. The SDK delivers tool outputs either as a string or as
|
||||
* an array of TextBlockParam-like blocks containing JSON text; try both.
|
||||
* Best-effort parse of a structured Task* tool_result. The SDK delivers tool
|
||||
* outputs either as a string or as an array of TextBlockParam-like blocks
|
||||
* containing JSON text; try both.
|
||||
*/
|
||||
export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | undefined {
|
||||
const tryParse = (text: string): TaskCreateOutput | undefined => {
|
||||
function parseJsonToolOutput<T>(
|
||||
content: unknown,
|
||||
isExpectedOutput: (value: unknown) => value is T,
|
||||
): T | undefined {
|
||||
const tryParse = (text: string): T | undefined => {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.task &&
|
||||
typeof parsed.task.id === "string"
|
||||
) {
|
||||
return parsed as TaskCreateOutput;
|
||||
}
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return isExpectedOutput(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
// ignore
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (typeof content === "string") {
|
||||
return tryParse(content);
|
||||
}
|
||||
if (content && typeof content === "object" && !Array.isArray(content)) {
|
||||
return isExpectedOutput(content) ? content : undefined;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && "type" in block && block.type === "text") {
|
||||
@@ -810,6 +1184,148 @@ export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toolOutputTexts(content: unknown): string[] {
|
||||
if (typeof content === "string") return [content];
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.flatMap((block) =>
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
block.type === "text" &&
|
||||
"text" in block &&
|
||||
typeof block.text === "string"
|
||||
? [block.text]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | undefined {
|
||||
const structured = parseJsonToolOutput(content, (parsed): parsed is TaskCreateOutput =>
|
||||
Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"task" in parsed &&
|
||||
parsed.task &&
|
||||
typeof parsed.task === "object" &&
|
||||
"id" in parsed.task &&
|
||||
typeof parsed.task.id === "string",
|
||||
),
|
||||
);
|
||||
if (structured) return structured;
|
||||
|
||||
for (const text of toolOutputTexts(content)) {
|
||||
const match = /^Task #(\S+) created successfully: (.+)$/.exec(text.trim());
|
||||
if (match) return { task: { id: match[1], subject: match[2] } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseTaskListOutput(content: unknown): TaskListOutput | undefined {
|
||||
const validStatuses = new Set(["pending", "in_progress", "completed"]);
|
||||
const structured = parseJsonToolOutput(content, (parsed): parsed is TaskListOutput =>
|
||||
Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"tasks" in parsed &&
|
||||
Array.isArray(parsed.tasks) &&
|
||||
parsed.tasks.every(
|
||||
(task) =>
|
||||
task &&
|
||||
typeof task === "object" &&
|
||||
typeof task.id === "string" &&
|
||||
typeof task.subject === "string" &&
|
||||
typeof task.status === "string" &&
|
||||
validStatuses.has(task.status),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (structured) return structured;
|
||||
|
||||
for (const text of toolOutputTexts(content)) {
|
||||
if (text.trim() === "No tasks found") return { tasks: [] };
|
||||
|
||||
const tasks: TaskListOutput["tasks"] = [];
|
||||
const lines = text.trim().split("\n");
|
||||
for (const line of lines) {
|
||||
const match = /^#(\S+) \[(pending|in_progress|completed)\] (.+)$/.exec(line);
|
||||
if (!match) {
|
||||
tasks.length = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
let subject = match[3];
|
||||
let owner: string | undefined;
|
||||
let blockedBy: string[] = [];
|
||||
|
||||
const blockedMarker = " [blocked by ";
|
||||
const blockedStart = subject.lastIndexOf(blockedMarker);
|
||||
if (blockedStart > 0 && subject.endsWith("]")) {
|
||||
const dependencies = subject.slice(blockedStart + blockedMarker.length, -1).split(", ");
|
||||
if (
|
||||
dependencies.every(
|
||||
(dependency) =>
|
||||
dependency.length > 1 &&
|
||||
dependency.startsWith("#") &&
|
||||
!dependency.includes(",") &&
|
||||
!dependency.includes("]"),
|
||||
)
|
||||
) {
|
||||
subject = subject.slice(0, blockedStart);
|
||||
blockedBy = dependencies.map((dependency) => dependency.slice(1));
|
||||
}
|
||||
}
|
||||
|
||||
const ownerStart = subject.lastIndexOf(" (");
|
||||
if (ownerStart > 0 && subject.endsWith(")")) {
|
||||
const candidate = subject.slice(ownerStart + 2, -1);
|
||||
if (!candidate.includes("(") && !candidate.includes(")")) {
|
||||
subject = subject.slice(0, ownerStart);
|
||||
owner = candidate || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
id: match[1],
|
||||
subject,
|
||||
status: match[2] as TaskListOutput["tasks"][number]["status"],
|
||||
...(owner ? { owner } : {}),
|
||||
blockedBy,
|
||||
});
|
||||
}
|
||||
if (tasks.length > 0) return { tasks };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseTaskUpdateOutput(
|
||||
content: unknown,
|
||||
expectedTaskId?: string,
|
||||
): TaskUpdateOutput | undefined {
|
||||
const structured = parseJsonToolOutput(content, (parsed): parsed is TaskUpdateOutput =>
|
||||
Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"success" in parsed &&
|
||||
typeof parsed.success === "boolean" &&
|
||||
"taskId" in parsed &&
|
||||
typeof parsed.taskId === "string" &&
|
||||
"updatedFields" in parsed &&
|
||||
Array.isArray(parsed.updatedFields) &&
|
||||
parsed.updatedFields.every((field) => typeof field === "string"),
|
||||
),
|
||||
);
|
||||
if (structured) return structured;
|
||||
|
||||
for (const text of toolOutputTexts(content)) {
|
||||
const notFound = /^Task #(\S+) not found$/.exec(text.trim());
|
||||
const taskId = notFound?.[1] ?? expectedTaskId;
|
||||
if (taskId && (notFound || text.trim() === "Failed to delete task")) {
|
||||
return { success: false, taskId, updatedFields: [], error: text.trim() };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyTaskCreate(
|
||||
state: TaskState,
|
||||
input: TaskCreateInput | undefined,
|
||||
@@ -844,9 +1360,23 @@ export function applyTaskUpdate(state: TaskState, input: TaskUpdateInput | undef
|
||||
});
|
||||
}
|
||||
|
||||
export function applyTaskList(state: TaskState, output: TaskListOutput): void {
|
||||
const previous = new Map(state);
|
||||
state.clear();
|
||||
for (const task of output.tasks) {
|
||||
const existing = previous.get(task.id);
|
||||
state.set(task.id, {
|
||||
subject: task.subject,
|
||||
status: task.status,
|
||||
activeForm: existing?.activeForm,
|
||||
description: existing?.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function taskStateToPlanEntries(state: TaskState): PlanEntry[] {
|
||||
return Array.from(state.values()).map((task) => ({
|
||||
content: task.subject,
|
||||
content: task.status === "in_progress" && task.activeForm ? task.activeForm : task.subject,
|
||||
status: task.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
@@ -862,77 +1392,20 @@ export function markdownEscape(text: string): string {
|
||||
return escape + "\n" + text + (text.endsWith("\n") ? "" : "\n") + escape;
|
||||
}
|
||||
|
||||
interface DiffToolResponseHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface DiffToolResponse {
|
||||
filePath?: string;
|
||||
structuredPatch?: DiffToolResponseHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds diff ToolUpdate content from the structured toolResponse provided by
|
||||
* the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing
|
||||
* the plain unified diff string, this uses the pre-parsed structuredPatch
|
||||
* which supports multiple replacement sites (replaceAll) and always includes
|
||||
* context lines for better readability.
|
||||
*/
|
||||
export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
} {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
if (!response.filePath || !Array.isArray(response.structuredPatch)) return {};
|
||||
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
|
||||
for (const { lines, newStart } of response.structuredPatch) {
|
||||
const oldText: string[] = [];
|
||||
const newText: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("-")) {
|
||||
oldText.push(line.slice(1));
|
||||
} else if (line.startsWith("+")) {
|
||||
newText.push(line.slice(1));
|
||||
} else {
|
||||
oldText.push(line.slice(1));
|
||||
newText.push(line.slice(1));
|
||||
}
|
||||
}
|
||||
if (oldText.length > 0 || newText.length > 0) {
|
||||
locations.push({ path: response.filePath, line: newStart });
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: response.filePath,
|
||||
oldText: oldText.join("\n") || null,
|
||||
newText: newText.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {};
|
||||
if (content.length > 0) result.content = content;
|
||||
if (locations.length > 0) result.locations = locations;
|
||||
return result;
|
||||
}
|
||||
|
||||
/* A global variable to store callbacks that should be executed when receiving hooks from Claude Code */
|
||||
const toolUseCallbacks: {
|
||||
[toolUseId: string]: {
|
||||
/* Callbacks are keyed globally because the SDK hook is process-wide, but each
|
||||
* entry retains its owning ACP session so cancellation/teardown can release it. */
|
||||
const toolUseCallbacks = new Map<
|
||||
string,
|
||||
{
|
||||
ownerId?: string;
|
||||
cleanupTimer?: ReturnType<typeof setTimeout>;
|
||||
onPostToolUseHook?: (
|
||||
toolUseID: string,
|
||||
toolInput: unknown,
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
};
|
||||
} = {};
|
||||
}
|
||||
>();
|
||||
|
||||
/* Setup callbacks that will be called when receiving hooks from Claude Code */
|
||||
export const registerHookCallback = (
|
||||
@@ -946,20 +1419,39 @@ export const registerHookCallback = (
|
||||
toolResponse: unknown,
|
||||
) => Promise<void>;
|
||||
},
|
||||
ownerId?: string,
|
||||
) => {
|
||||
toolUseCallbacks[toolUseID] = {
|
||||
unregisterHookCallback(toolUseID);
|
||||
toolUseCallbacks.set(toolUseID, {
|
||||
ownerId,
|
||||
onPostToolUseHook,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export function unregisterHookCallback(toolUseID: string): void {
|
||||
const callback = toolUseCallbacks.get(toolUseID);
|
||||
if (callback?.cleanupTimer) clearTimeout(callback.cleanupTimer);
|
||||
toolUseCallbacks.delete(toolUseID);
|
||||
}
|
||||
|
||||
/** PostToolUse normally follows tool_result, so keep the callback for a short
|
||||
* grace period while still bounding retention when the hook never arrives. */
|
||||
export function completeHookCallback(toolUseID: string): void {
|
||||
const callback = toolUseCallbacks.get(toolUseID);
|
||||
if (!callback || callback.cleanupTimer) return;
|
||||
callback.cleanupTimer = setTimeout(() => unregisterHookCallback(toolUseID), 30_000);
|
||||
callback.cleanupTimer.unref?.();
|
||||
}
|
||||
|
||||
export function clearHookCallbacks(ownerId: string): void {
|
||||
for (const [toolUseID, callback] of toolUseCallbacks) {
|
||||
if (callback.ownerId === ownerId) unregisterHookCallback(toolUseID);
|
||||
}
|
||||
}
|
||||
|
||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
||||
export const createPostToolUseHook =
|
||||
(
|
||||
logger: Logger = console,
|
||||
options?: {
|
||||
onEnterPlanMode?: () => Promise<void>;
|
||||
},
|
||||
): HookCallback =>
|
||||
(options?: { onEnterPlanMode?: () => Promise<void> }): HookCallback =>
|
||||
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse") {
|
||||
// Handle EnterPlanMode tool - notify client of mode change after successful execution
|
||||
@@ -968,13 +1460,13 @@ export const createPostToolUseHook =
|
||||
}
|
||||
|
||||
if (toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
const onPostToolUseHook = toolUseCallbacks.get(toolUseID)?.onPostToolUseHook;
|
||||
try {
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
}
|
||||
} finally {
|
||||
unregisterHookCallback(toolUseID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
214
src/usage-markdown.ts
Normal file
214
src/usage-markdown.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { SDKControlGetUsageResponse } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const countSchema = z.number().finite().nonnegative();
|
||||
const percentSchema = countSchema.max(100);
|
||||
const usageWindowSchema = z
|
||||
.object({ utilization: percentSchema.nullable(), resets_at: z.string().nullable() })
|
||||
.nullable()
|
||||
.optional();
|
||||
const contributionSchema = z.object({ name: z.string(), pct: percentSchema });
|
||||
const behaviorPeriodSchema = z.object({
|
||||
request_count: countSchema,
|
||||
session_count: countSchema,
|
||||
mcp_servers: z.array(contributionSchema),
|
||||
});
|
||||
const modelUsageSchema = z.object({
|
||||
inputTokens: countSchema,
|
||||
outputTokens: countSchema,
|
||||
cacheReadInputTokens: countSchema,
|
||||
cacheCreationInputTokens: countSchema,
|
||||
});
|
||||
|
||||
const usageResponseSchema = z.object({
|
||||
session: z.object({
|
||||
total_cost_usd: countSchema,
|
||||
total_api_duration_ms: countSchema,
|
||||
total_duration_ms: countSchema,
|
||||
model_usage: z.record(z.string(), modelUsageSchema),
|
||||
}),
|
||||
subscription_type: z.string().nullable(),
|
||||
rate_limits_available: z.boolean(),
|
||||
rate_limits: z
|
||||
.object({
|
||||
five_hour: usageWindowSchema,
|
||||
seven_day: usageWindowSchema,
|
||||
seven_day_oauth_apps: usageWindowSchema,
|
||||
seven_day_opus: usageWindowSchema,
|
||||
seven_day_sonnet: usageWindowSchema,
|
||||
model_scoped: z
|
||||
.array(
|
||||
z.object({
|
||||
display_name: z.string(),
|
||||
utilization: percentSchema.nullable(),
|
||||
resets_at: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
extra_usage: z
|
||||
.object({
|
||||
is_enabled: z.boolean(),
|
||||
monthly_limit: countSchema.nullable(),
|
||||
used_credits: countSchema.nullable(),
|
||||
utilization: percentSchema.nullable(),
|
||||
currency: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.nullable(),
|
||||
behaviors: z.object({ day: behaviorPeriodSchema, week: behaviorPeriodSchema }).nullable(),
|
||||
});
|
||||
|
||||
/** Validate the experimental SDK response at the runtime boundary. */
|
||||
export function parseUsageResponse(value: unknown): SDKControlGetUsageResponse | null {
|
||||
const parsed = usageResponseSchema.safeParse(value);
|
||||
// Validate only the fields the renderer reads, but preserve the complete
|
||||
// response. The experimental API may add values to unused subtrees without
|
||||
// making the useful, already-validated portion unsafe to render.
|
||||
return parsed.success ? (value as SDKControlGetUsageResponse) : null;
|
||||
}
|
||||
|
||||
export function isUsageCommandText(text: string): boolean {
|
||||
return text.trim() === "/usage";
|
||||
}
|
||||
|
||||
function usageBar(percent: number): string {
|
||||
const cells = 20;
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const filled = clamped === 0 ? 0 : Math.max(1, Math.round((clamped / 100) * cells));
|
||||
return `${"█".repeat(filled)}${"░".repeat(cells - filled)}`;
|
||||
}
|
||||
|
||||
function escapeMarkdown(value: string): string {
|
||||
return value.replace(/([\\`*_[\]<>|])/g, "\\$1").replace(/[\r\n]+/g, " ");
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds: number): string {
|
||||
const seconds = Math.max(0, Math.round(milliseconds / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
|
||||
}
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function formatReset(value: string | null): string {
|
||||
if (!value) return "";
|
||||
const reset = new Date(value);
|
||||
if (Number.isNaN(reset.getTime())) return ` · Resets ${escapeMarkdown(value)}`;
|
||||
return ` · Resets ${new Intl.DateTimeFormat("en", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
}).format(reset)}`;
|
||||
}
|
||||
|
||||
function appendLimit(
|
||||
lines: string[],
|
||||
label: string,
|
||||
window: { utilization: number | null; resets_at: string | null } | null | undefined,
|
||||
): void {
|
||||
if (!window || window.utilization === null) return;
|
||||
lines.push(
|
||||
`**${escapeMarkdown(label)}** — **${window.utilization}%**${formatReset(window.resets_at)}`,
|
||||
"",
|
||||
`\`${usageBar(window.utilization)}\``,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
function appendContributions(
|
||||
lines: string[],
|
||||
label: string,
|
||||
period: {
|
||||
request_count: number;
|
||||
session_count: number;
|
||||
mcp_servers: { name: string; pct: number }[];
|
||||
},
|
||||
): void {
|
||||
lines.push(
|
||||
"",
|
||||
`**${label}** · ${period.request_count} requests · ${period.session_count} sessions`,
|
||||
);
|
||||
if (period.mcp_servers.length === 0) return;
|
||||
lines.push("", "| MCP server | Usage |", "|:--|--:|");
|
||||
for (const server of [...period.mcp_servers].sort((a, b) => b.pct - a.pct).slice(0, 3)) {
|
||||
lines.push(`| ${escapeMarkdown(server.name)} | \`${usageBar(server.pct)}\` ${server.pct}% |`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the SDK's structured `/usage` response as Markdown. */
|
||||
export function formatUsageResponse(usage: SDKControlGetUsageResponse): string {
|
||||
const lines = ["## Usage"];
|
||||
if (usage.subscription_type) {
|
||||
lines.push("", `> Claude ${escapeMarkdown(usage.subscription_type)} subscription usage`);
|
||||
}
|
||||
|
||||
if (usage.rate_limits_available && usage.rate_limits) {
|
||||
const limitLines: string[] = [];
|
||||
appendLimit(limitLines, "5-hour limit", usage.rate_limits.five_hour);
|
||||
appendLimit(limitLines, "Weekly · all models", usage.rate_limits.seven_day);
|
||||
const modelWindows = usage.rate_limits.model_scoped ?? [];
|
||||
for (const model of modelWindows) {
|
||||
appendLimit(limitLines, `Weekly · ${model.display_name}`, model);
|
||||
}
|
||||
if (modelWindows.length === 0) {
|
||||
appendLimit(limitLines, "Weekly · Opus", usage.rate_limits.seven_day_opus);
|
||||
appendLimit(limitLines, "Weekly · Sonnet", usage.rate_limits.seven_day_sonnet);
|
||||
}
|
||||
if (limitLines.length > 0) {
|
||||
if (limitLines.at(-1) === "") limitLines.pop();
|
||||
lines.push("", "### Limits", "", ...limitLines);
|
||||
}
|
||||
}
|
||||
|
||||
const models = Object.values(usage.session.model_usage);
|
||||
const totals = models.reduce(
|
||||
(sum, model) => ({
|
||||
input: sum.input + model.inputTokens,
|
||||
output: sum.output + model.outputTokens,
|
||||
cacheRead: sum.cacheRead + model.cacheReadInputTokens,
|
||||
cacheWrite: sum.cacheWrite + model.cacheCreationInputTokens,
|
||||
}),
|
||||
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
);
|
||||
lines.push(
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"### This session",
|
||||
"",
|
||||
"| Cost | API time | Active |",
|
||||
"|:--|:--|:--|",
|
||||
`| $${usage.session.total_cost_usd.toFixed(2)} | ${formatDuration(usage.session.total_api_duration_ms)} | ${formatDuration(usage.session.total_duration_ms)} |`,
|
||||
"",
|
||||
"| Breakdown | Tokens |",
|
||||
"|:--|--:|",
|
||||
`| Input | ${formatCount(totals.input)} |`,
|
||||
`| Output | ${formatCount(totals.output)} |`,
|
||||
`| Cache read | ${formatCount(totals.cacheRead)} |`,
|
||||
`| Cache write | ${formatCount(totals.cacheWrite)} |`,
|
||||
);
|
||||
|
||||
if (usage.behaviors) {
|
||||
lines.push(
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"### What’s using your limits?",
|
||||
"",
|
||||
"> Approximate, overlapping measures · this machine only · excludes claude.ai",
|
||||
);
|
||||
appendContributions(lines, "Last 24h", usage.behaviors.day);
|
||||
appendContributions(lines, "Last 7d", usage.behaviors.week);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -5,7 +5,12 @@ export default defineConfig({
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
include: [
|
||||
"src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}",
|
||||
// Release tooling lives in scripts/ rather than src/ so it stays outside tsc's
|
||||
// rootDir and out of the published tarball; its tests have to follow it there.
|
||||
"scripts/**/*.{test,spec}.mjs",
|
||||
],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
|
||||
Reference in New Issue
Block a user