mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-12 12:22:20 +00:00
Compare commits
104 Commits
v0.75.0
...
v0.87.1-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0304f743f8 | ||
|
|
6a162baf8b | ||
|
|
b2dbc8f5a1 | ||
|
|
390b0b9f8b | ||
|
|
0724b17c58 | ||
|
|
0e83a06c4a | ||
|
|
7b5c61a4ed | ||
|
|
5598efbc8a | ||
|
|
07c7ed3752 | ||
|
|
3a61172e19 | ||
|
|
4ddda8f37e | ||
|
|
96acfeccf9 | ||
|
|
52a92be959 | ||
|
|
8589a9cf15 | ||
|
|
a44c486019 | ||
|
|
d599333b72 | ||
|
|
b1d6cfc6c9 | ||
|
|
3d9bae941c | ||
|
|
98b17c28c8 | ||
|
|
22139f73c4 | ||
|
|
bd8a161c2e | ||
|
|
63aa3b8428 | ||
|
|
b9a6ec00a6 | ||
|
|
c1c8c0377d | ||
|
|
23b8ebf8c2 | ||
|
|
2b7fab7d98 | ||
|
|
d3e673b263 | ||
|
|
d83f28a403 | ||
|
|
ba5ed170ae | ||
|
|
d24272c6b0 | ||
|
|
190a00f68f | ||
|
|
ddd5f54187 | ||
|
|
3d4017b15a | ||
|
|
6b69852cfc | ||
|
|
c60fa48fd8 | ||
|
|
686c0c99b3 | ||
|
|
8e893f51ea | ||
|
|
0c862e2949 | ||
|
|
4ffa625977 | ||
|
|
7f8d5471e2 | ||
|
|
c84845272f | ||
|
|
af0b43de94 | ||
|
|
f53b0d7f40 | ||
|
|
4af807968c | ||
|
|
bdb50ad984 | ||
|
|
d5e6f33046 | ||
|
|
691328a919 | ||
|
|
49858b5273 | ||
|
|
77ceab0339 | ||
|
|
18de376240 | ||
|
|
875d75f2a4 | ||
|
|
e6681d2a57 | ||
|
|
5dbb453c63 | ||
|
|
b495e55a22 | ||
|
|
a2c362c948 | ||
|
|
b264b52bee | ||
|
|
da11f1feb2 | ||
|
|
df38cccae0 | ||
|
|
39062b63ff | ||
|
|
29a4e98477 | ||
|
|
6e97e071c5 | ||
|
|
2b0201a801 | ||
|
|
97394d93b6 | ||
|
|
388a0f5191 | ||
|
|
9392c69cc6 | ||
|
|
281e47eb81 | ||
|
|
43c27e97aa | ||
|
|
bd059c10e0 | ||
|
|
58c5db1fdc | ||
|
|
a2f4239543 | ||
|
|
90cde4b397 | ||
|
|
d571358e26 | ||
|
|
eb989e93f4 | ||
|
|
79851b8668 | ||
|
|
16c9d1a3af | ||
|
|
a05aca6b81 | ||
|
|
5bc3b4e560 | ||
|
|
c6f9668c12 | ||
|
|
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 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
src/tests/acp-scenarios/__snapshots__/** linguist-generated=true
|
||||
src/tests/acp-scenarios/origin-main/** linguist-generated=true
|
||||
184
.github/workflows/publish.yml
vendored
184
.github/workflows/publish.yml
vendored
@@ -5,12 +5,20 @@ on:
|
||||
- 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
|
||||
description: Publish the package before updating the registry (stable only)
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
@@ -54,11 +62,13 @@ jobs:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ always() && ((github.event_name == 'workflow_dispatch' && inputs.publish_npm) || needs.release-please.outputs.release_created == 'true') }}
|
||||
if: ${{ 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@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
@@ -70,15 +80,179 @@ jobs:
|
||||
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
|
||||
if: ${{ always() && (needs.publish-npm.result == 'success' || (github.event_name == 'workflow_dispatch' && !inputs.publish_npm)) }}
|
||||
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
|
||||
|
||||
@@ -5,3 +5,7 @@ dist
|
||||
.release-please-manifest.json
|
||||
release-please-config.json
|
||||
CHANGELOG.md
|
||||
|
||||
# Recorded ACP traffic, written by src/tests/acp-scenarios.test.ts.
|
||||
src/tests/acp-scenarios/__snapshots__
|
||||
src/tests/acp-scenarios/origin-main
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.75.0"
|
||||
".": "0.87.0"
|
||||
}
|
||||
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -28,7 +28,7 @@ patch, and `chore:`/`ci:`/`test:` and friends do not release at all.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are fully automated by release-please. There is no manual release
|
||||
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.
|
||||
|
||||
@@ -38,5 +38,11 @@ gh pr merge <pr-number> --squash
|
||||
```
|
||||
|
||||
The preflight is the guard-list as code; if it exits non-zero, follow what it
|
||||
prints rather than merging. Full runbook, including how to recover a stalled
|
||||
release: [`docs/RELEASES.md`](docs/RELEASES.md).
|
||||
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).
|
||||
|
||||
201
CHANGELOG.md
201
CHANGELOG.md
@@ -1,5 +1,206 @@
|
||||
# Changelog
|
||||
|
||||
## [0.87.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.86.0...v0.87.0) (2026-10-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump the minor group with 2 updates ([#1260](https://github.com/agentclientprotocol/claude-agent-acp/issues/1260)) ([0724b17](https://github.com/agentclientprotocol/claude-agent-acp/commit/0724b17c581e30b1242cfbec7bce44c21df99525))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* async task routing, Monitor tasks, structured task ids, and abandoned tool calls ([#1249](https://github.com/agentclientprotocol/claude-agent-acp/issues/1249)) ([0e83a06](https://github.com/agentclientprotocol/claude-agent-acp/commit/0e83a06c4a8825ba654bddf1ab909d1a773942e2))
|
||||
* settle prompts folded into a task-notification cycle ([#1233](https://github.com/agentclientprotocol/claude-agent-acp/issues/1233)) ([390b0b9](https://github.com/agentclientprotocol/claude-agent-acp/commit/390b0b9f8bf0cc0aa7d9273a7b614172c916d3a6))
|
||||
|
||||
## [0.86.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.1...v0.86.0) (2026-10-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* answer /mcp in chat with the MCP server status and a reconnect ([#1218](https://github.com/agentclientprotocol/claude-agent-acp/issues/1218)) ([190a00f](https://github.com/agentclientprotocol/claude-agent-acp/commit/190a00f68f773315e93630464397944e645616ff))
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.287 ([#1224](https://github.com/agentclientprotocol/claude-agent-acp/issues/1224)) ([ddd5f54](https://github.com/agentclientprotocol/claude-agent-acp/commit/ddd5f541879118edb18df7ff09cd6682d455c710))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* count a subagent's permission requests as the turn waiting on the user ([#1241](https://github.com/agentclientprotocol/claude-agent-acp/issues/1241)) ([d599333](https://github.com/agentclientprotocol/claude-agent-acp/commit/d599333b722a951553c0efd40a76559b1f32e6ab))
|
||||
* drop queued prompts a cancel ends from Claude Code's queue ([#1228](https://github.com/agentclientprotocol/claude-agent-acp/issues/1228)) ([d83f28a](https://github.com/agentclientprotocol/claude-agent-acp/commit/d83f28a4035c5a5e54950fd58c7b73bfc2313664))
|
||||
* end a cancelled turn when the force-cancel fires during an update ([#1243](https://github.com/agentclientprotocol/claude-agent-acp/issues/1243)) ([a44c486](https://github.com/agentclientprotocol/claude-agent-acp/commit/a44c486019e98ad478c549c4902e7092f1418d0b))
|
||||
* end a closed session's turns before session/close answers ([#1242](https://github.com/agentclientprotocol/claude-agent-acp/issues/1242)) ([b1d6cfc](https://github.com/agentclientprotocol/claude-agent-acp/commit/b1d6cfc6c9fc9ec397e607b68ac100f4d48745b3))
|
||||
* end a held turn cancelled mid-followup at its interrupt's idle ([#1229](https://github.com/agentclientprotocol/claude-agent-acp/issues/1229)) ([d3e673b](https://github.com/agentclientprotocol/claude-agent-acp/commit/d3e673b263cf47603a35a50bb3da8b483199f690))
|
||||
* excessive message/token_count API calls when probing context window ([#1258](https://github.com/agentclientprotocol/claude-agent-acp/issues/1258)) ([5598efb](https://github.com/agentclientprotocol/claude-agent-acp/commit/5598efbc8ab16720ea79f5c97db40771ad0b317b))
|
||||
* replay a session with a Write on the experimental ACP v2 surface ([#1238](https://github.com/agentclientprotocol/claude-agent-acp/issues/1238)) ([22139f7](https://github.com/agentclientprotocol/claude-agent-acp/commit/22139f73c48e621752089a91fb095269493896b1))
|
||||
* report a command's exit code only when the tool result says it ([#1252](https://github.com/agentclientprotocol/claude-agent-acp/issues/1252)) ([52a92be](https://github.com/agentclientprotocol/claude-agent-acp/commit/52a92be959f7f8ea90ab08d54bae6900596ab435))
|
||||
* restore main-thread agent config option ([#1250](https://github.com/agentclientprotocol/claude-agent-acp/issues/1250)) ([8589a9c](https://github.com/agentclientprotocol/claude-agent-acp/commit/8589a9cf150d8deca6d2f8ef24404d927b3d4828))
|
||||
|
||||
## [0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1) (2026-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* answer session/close without waiting for the interrupt reply ([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216)) ([0c862e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c862e29496bb018e7a4400697bd2315dfd5b845))
|
||||
* fail turns with unfinished tools ([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212)) ([7f8d547](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f8d5471e24b981b129b3c9c2d6736c4ba3f8011))
|
||||
* restore background task stops on replay instead of showing them as prompts ([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205)) ([4ffa625](https://github.com/agentclientprotocol/claude-agent-acp/commit/4ffa625977af7e85526b9a3098f4dee5844467b4))
|
||||
|
||||
## [0.85.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.84.0...v0.85.0) (2026-10-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.286 ([#1211](https://github.com/agentclientprotocol/claude-agent-acp/issues/1211)) ([f53b0d7](https://github.com/agentclientprotocol/claude-agent-acp/commit/f53b0d7f40bcd0e9bddace85f123ff4851afcb1f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt to SDK 0.3.284 replay and ultracode changes ([#1208](https://github.com/agentclientprotocol/claude-agent-acp/issues/1208)) ([4af8079](https://github.com/agentclientprotocol/claude-agent-acp/commit/4af807968c7f0813bcb20fe2bcab2370320e9074))
|
||||
* send the legacy custom answer key to AIR ([#1210](https://github.com/agentclientprotocol/claude-agent-acp/issues/1210)) ([af0b43d](https://github.com/agentclientprotocol/claude-agent-acp/commit/af0b43de944e36f81343f40b7aa1a96cd48c3218))
|
||||
|
||||
## [0.84.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.83.0...v0.84.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1189](https://github.com/agentclientprotocol/claude-agent-acp/issues/1189)) ([d5e6f33](https://github.com/agentclientprotocol/claude-agent-acp/commit/d5e6f33046614fca4798d49f0c77edba951a6771))
|
||||
|
||||
## [0.83.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.82.0...v0.83.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.283 ([#1186](https://github.com/agentclientprotocol/claude-agent-acp/issues/1186)) ([49858b5](https://github.com/agentclientprotocol/claude-agent-acp/commit/49858b527397a08d343fe2c148a5fe9e3eb84547))
|
||||
|
||||
## [0.82.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.2...v0.82.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* AIR tool call contract, exact git patches, plan file, and fixes for every client ([#1153](https://github.com/agentclientprotocol/claude-agent-acp/issues/1153)) ([875d75f](https://github.com/agentclientprotocol/claude-agent-acp/commit/875d75f2a4941d9c6313ed51f89da0f42443ac2f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use ACP auth errors without access updates ([#1179](https://github.com/agentclientprotocol/claude-agent-acp/issues/1179)) ([e6681d2](https://github.com/agentclientprotocol/claude-agent-acp/commit/e6681d2a5734857727352474c8c9aa848f9210ee))
|
||||
|
||||
## [0.81.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.1...v0.81.2) (2026-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* announce resumed subagent generations when they start ([#1173](https://github.com/agentclientprotocol/claude-agent-acp/issues/1173)) ([b495e55](https://github.com/agentclientprotocol/claude-agent-acp/commit/b495e55a2249f066dab76e56316080f151df99be))
|
||||
* continue clear-context plans approved in a background followup ([#1170](https://github.com/agentclientprotocol/claude-agent-acp/issues/1170)) ([a2c362c](https://github.com/agentclientprotocol/claude-agent-acp/commit/a2c362c948511b203b43982f13ab068ba14741ea))
|
||||
|
||||
## [0.81.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.0...v0.81.1) (2026-09-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* gate login result text on error state ([#1103](https://github.com/agentclientprotocol/claude-agent-acp/issues/1103)) ([6e97e07](https://github.com/agentclientprotocol/claude-agent-acp/commit/6e97e071c581002b43d9556dbd1d1376ebf9516f))
|
||||
* honor permissions.disableBypassPermissionsMode ([#1165](https://github.com/agentclientprotocol/claude-agent-acp/issues/1165)) ([281e47e](https://github.com/agentclientprotocol/claude-agent-acp/commit/281e47eb810da748b9e66798086f0010dbb4dc4b))
|
||||
* include model on usage updates ([#1032](https://github.com/agentclientprotocol/claude-agent-acp/issues/1032)) ([da11f1f](https://github.com/agentclientprotocol/claude-agent-acp/commit/da11f1feb269b93f13fde7470f2b5a1b070867a9))
|
||||
* keep the agent starting when the managed-policy tier cannot be read ([#1146](https://github.com/agentclientprotocol/claude-agent-acp/issues/1146)) ([58c5db1](https://github.com/agentclientprotocol/claude-agent-acp/commit/58c5db1fdc1b6fbce27224523fddebd3c52a65dd))
|
||||
* mark informational chunks with metadata ([#1055](https://github.com/agentclientprotocol/claude-agent-acp/issues/1055)) ([bd059c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/bd059c10e042c126933f27abd33f5523f39fab72))
|
||||
* offer bypass alongside auto in plan approval ([#1164](https://github.com/agentclientprotocol/claude-agent-acp/issues/1164)) ([43c27e9](https://github.com/agentclientprotocol/claude-agent-acp/commit/43c27e97aacd9b711aefcb4b84271bf872c2eea7))
|
||||
* preserve context usage when quota is exhausted ([#1132](https://github.com/agentclientprotocol/claude-agent-acp/issues/1132)) ([39062b6](https://github.com/agentclientprotocol/claude-agent-acp/commit/39062b63ff3087fc7ce2655a9738450f5f61682c))
|
||||
* preserve native Vertex endpoint defaults ([#1104](https://github.com/agentclientprotocol/claude-agent-acp/issues/1104)) ([29a4e98](https://github.com/agentclientprotocol/claude-agent-acp/commit/29a4e9847761837ab85ea42d4bab9267be606755))
|
||||
* publish effective mode after plan approval ([#1163](https://github.com/agentclientprotocol/claude-agent-acp/issues/1163)) ([a2f4239](https://github.com/agentclientprotocol/claude-agent-acp/commit/a2f4239543b22d0928fbdbef7a9c7a4854b3d61d))
|
||||
* recreate resumed sessions when any session option changes ([#1097](https://github.com/agentclientprotocol/claude-agent-acp/issues/1097)) ([df38ccc](https://github.com/agentclientprotocol/claude-agent-acp/commit/df38cccae04ce4abb525447c5e8d9db78ffec61c))
|
||||
* render Write tool calls that use path/file_text aliases ([#1161](https://github.com/agentclientprotocol/claude-agent-acp/issues/1161)) ([90cde4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/90cde4b3974656794fbb274ac6ad8e57f701e618))
|
||||
* replay marker-only slash skill prompts ([#1035](https://github.com/agentclientprotocol/claude-agent-acp/issues/1035)) ([97394d9](https://github.com/agentclientprotocol/claude-agent-acp/commit/97394d93b695c141eda71b85025bc4e22141b8c4))
|
||||
* **session:** recreate query when configured skills change ([#968](https://github.com/agentclientprotocol/claude-agent-acp/issues/968)) ([388a0f5](https://github.com/agentclientprotocol/claude-agent-acp/commit/388a0f51914ef59f2168007b633927e1a9d6ec48)), closes [#955](https://github.com/agentclientprotocol/claude-agent-acp/issues/955)
|
||||
* settle steered turns on the result that answers the steer ([#1166](https://github.com/agentclientprotocol/claude-agent-acp/issues/1166)) ([9392c69](https://github.com/agentclientprotocol/claude-agent-acp/commit/9392c69cc66591ade56a08200d807577180aa682))
|
||||
|
||||
## [0.81.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.80.0...v0.81.0) (2026-09-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1157](https://github.com/agentclientprotocol/claude-agent-acp/issues/1157)) ([eb989e9](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb989e93f4e20315a85405ef099f97eec899df13))
|
||||
* Send experimental session notices for live advisories ([#1155](https://github.com/agentclientprotocol/claude-agent-acp/issues/1155)) ([79851b8](https://github.com/agentclientprotocol/claude-agent-acp/commit/79851b86685b8a0673dec54d85d6fa2a3b749cfb))
|
||||
|
||||
## [0.80.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.79.0...v0.80.0) (2026-09-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump claude-agent-sdk to 0.3.278 ([#1147](https://github.com/agentclientprotocol/claude-agent-acp/issues/1147)) ([5bc3b4e](https://github.com/agentclientprotocol/claude-agent-acp/commit/5bc3b4e5609c7e4290fa1bca50c30c7340de6bbf))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* close interrupted compaction lifecycles ([#1154](https://github.com/agentclientprotocol/claude-agent-acp/issues/1154)) ([a05aca6](https://github.com/agentclientprotocol/claude-agent-acp/commit/a05aca6b81bfd172ab47064dd21e835da95fc72b))
|
||||
* prefer terminal output deltas ([#1150](https://github.com/agentclientprotocol/claude-agent-acp/issues/1150)) ([c6f9668](https://github.com/agentclientprotocol/claude-agent-acp/commit/c6f9668c1234b43888bc44aaf63daf8be1a745e2))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -9,6 +9,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- Context @-mentions
|
||||
- Images
|
||||
- Tool calls (with permission requests)
|
||||
- Compact file changes through the negotiated [AIR diff patch extension](docs/air-extensions.md#diff-patch)
|
||||
- Following
|
||||
- Edit review
|
||||
- TODO lists
|
||||
@@ -16,12 +17,24 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- 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)
|
||||
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/permission-extension.md)
|
||||
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
|
||||
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)
|
||||
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/air-extensions.md#recommended-config-values)
|
||||
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/air-extensions.md#permission-presentation)
|
||||
- One fact per field in every tool call report, as the [ACP tool call contract](docs/air-extensions.md#tool-call-contract) defines
|
||||
- All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.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
|
||||
@@ -31,7 +44,7 @@ in its initialize response. The canonical field remains supported and takes prec
|
||||
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.
|
||||
the flattened child transcript behavior. See [AIR extensions](docs/air-extensions.md#native-subagent-sessions).
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
|
||||
126
docs/RELEASES.md
126
docs/RELEASES.md
@@ -12,6 +12,10 @@ 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
|
||||
@@ -41,6 +45,89 @@ gh release view "v<version>"
|
||||
npm view "@agentclientprotocol/claude-agent-acp@<version>"
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -64,6 +151,14 @@ 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
|
||||
@@ -108,6 +203,31 @@ 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 |
|
||||
@@ -117,3 +237,9 @@ and only re-dispatches the registry update.
|
||||
|
||||
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.
|
||||
|
||||
1294
docs/air-extensions.md
Normal file
1294
docs/air-extensions.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
||||
# Goal extension
|
||||
|
||||
This document defines a provider-neutral experimental ACP extension implemented by `claude-agent-acp`. It is intentionally shaped like a possible future first-class ACP API: implementations publish `_meta.goal`, not provider-specific metadata such as `_meta.claudeCode.goal`.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
An agent advertises support in its `initialize` response:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"goal": {
|
||||
"version": 1,
|
||||
"controlMethod": "_session/goal",
|
||||
"actions": ["clear"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`actions` is the implementation-supported subset of `set`, `pause`, `resume`, and `clear`. Clients must not infer support for an action that is not advertised. The control request contains `sessionId` and `action`; a future version may add action-specific fields such as an objective for `set`.
|
||||
|
||||
## Session state
|
||||
|
||||
The current snapshot is published in `session_info_update._meta.goal`. Clearing a goal publishes `goal: null`.
|
||||
|
||||
```json
|
||||
{
|
||||
"objective": "Ship the change",
|
||||
"status": "active",
|
||||
"iterations": 3,
|
||||
"lastReason": "Tests still need work",
|
||||
"createdAt": 1710000000123,
|
||||
"controlMethod": "_session/goal"
|
||||
}
|
||||
```
|
||||
|
||||
Common statuses are `active`, `paused`, `blocked`, `limited`, and `complete`. Optional fields allow implementations to report budgets, usage, iteration count, and the last continuation reason. Timestamps are Unix milliseconds.
|
||||
|
||||
## Lifecycle architecture
|
||||
|
||||
A goal belongs to the ACP session, not to an individual `session/prompt` request. Goal activity and prompt activity are independent:
|
||||
|
||||
- `status: active` means the persistent objective can drive more work; it does not mean an ACP prompt is currently executing.
|
||||
- A prompt completes when its current backend turn reaches a quiescent boundary, even when the goal remains active.
|
||||
- A later autonomous cycle may publish more session updates outside that completed prompt.
|
||||
- While a turn is running, clients use steering or prompt queueing when advertised. While the session is quiescent, clients may send an ordinary `session/prompt`.
|
||||
|
||||
This separation prevents a persistent goal from monopolizing the session's prompt slot and lets clients model “working now” independently from “objective remains active.”
|
||||
|
||||
## Claude mapping
|
||||
|
||||
Claude's session-scoped `/goal` command installs a Stop hook. The runtime emits internal `active_goal` messages when that hook updates or clears its state; the adapter maps them into the neutral snapshot before the exhaustive SDK message switch. `condition`, `iterations`, `set_at`, and `last_reason` become `objective`, `iterations`, `createdAt`, and `lastReason`. Provider-only bookkeeping such as `tokens_at_start` is not exposed.
|
||||
|
||||
Some Claude runtime versions persist the goal and begin the Stop-hook workflow without emitting the initial `active_goal` message on the SDK stream. To avoid leaving clients without visible state for the whole long-running prompt, the adapter publishes a minimal active snapshot as soon as it submits `/goal <condition>`, and publishes `goal: null` for `/goal clear`. While that command is pending, late updates for the previous goal are ignored. A matching runtime `active_goal` remains authoritative and enriches the optimistic snapshot with iteration, reason, and timestamp fields; a failed command restores the previously published state.
|
||||
|
||||
`set` and `clear` are advertised for out-of-band control; pause and resume are not. When a goal turn is running, the adapter injects the corresponding `/goal` command at steering priority `now` so it cannot remain queued behind the Stop-hook workflow; on an idle session it uses the ordinary prompt lifecycle. Cancelling a turn does not clear the session-scoped goal: clients use the goal control method when they intend to replace or remove it.
|
||||
@@ -1,243 +0,0 @@
|
||||
# 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. |
|
||||
|
||||
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`, `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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
@@ -1,208 +0,0 @@
|
||||
# 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` |
|
||||
| `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
|
||||
```
|
||||
977
package-lock.json
generated
977
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.75.0",
|
||||
"version": "0.87.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -65,23 +65,26 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.4.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.257",
|
||||
"zod": "^4.0.0"
|
||||
"@agentclientprotocol/sdk": "1.7.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.293",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.122.0",
|
||||
"@anthropic-ai/sdk": "0.132.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.69.0",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"eslint": "10.9.1",
|
||||
"@types/node": "26.6.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.1",
|
||||
"@typescript-eslint/parser": "8.71.1",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.12.0",
|
||||
"prettier": "3.9.6",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.11"
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
5125
src/acp-agent.ts
5125
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,12 @@ export type SubagentSpawnedUpdate = {
|
||||
subagentSessionId: string;
|
||||
name: string;
|
||||
task: string;
|
||||
/**
|
||||
* Adapter extension: the exact prompt of the subagent. A client can show it
|
||||
* as the first user message of the subagent session. It is absent when the
|
||||
* adapter does not know the prompt.
|
||||
*/
|
||||
prompt?: string;
|
||||
capabilities: SubagentSessionCapabilities;
|
||||
_meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
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_PATCH_CAPABILITY = "diffPatch";
|
||||
/** AIR renders `rawInput` itself and needs no display copy of the input. */
|
||||
export const AIR_RAW_INPUT_RENDERING_CAPABILITY = "rawInputRendering";
|
||||
/**
|
||||
* The plan of an ExitPlanMode is a file. `rawInput.planFilePath` names the
|
||||
* file, and AIR reads the plan from it.
|
||||
*/
|
||||
export const AIR_PLAN_FILE_CAPABILITY = "planFile";
|
||||
|
||||
/** The `_meta.jetbrains.air` keys that the ACP tool call contract defines. */
|
||||
export const AIR_COMMAND_TITLE_KEY = "commandTitle";
|
||||
export const AIR_SUBAGENT_KEY = "subagent";
|
||||
export const AIR_SKILL_KEY = "skill";
|
||||
export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction";
|
||||
export const AIR_GOAL_KEY = "goal";
|
||||
export const AIR_KIND_KEY = "kind";
|
||||
/** The absolute path of the `SKILL.md` of a skill command in `available_commands_update`. */
|
||||
export const AIR_SKILL_PATH_KEY = "skillPath";
|
||||
export const AIR_PERMISSION_KEY = "permission";
|
||||
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
|
||||
/**
|
||||
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released
|
||||
* AIR versions read. Remove it when the ACP registry no longer serves AIR
|
||||
* versions that read only this spelling.
|
||||
*/
|
||||
export const LEGACY_AIR_CUSTOM_ANSWER_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
@@ -40,6 +67,31 @@ export function withAirMeta(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client is JetBrains AIR: it declared `_meta.jetbrains.air` in
|
||||
* its capabilities.
|
||||
*
|
||||
* Only AIR gets the AIR extensions of `docs/air-extensions.md`. Every other
|
||||
* client, Zed too, gets the fields and the upstream `_meta` keys of the
|
||||
* upstream adapter, and no key that exists only for AIR.
|
||||
*/
|
||||
export function isAirClient(capabilities: unknown): boolean {
|
||||
return airExtensionMeta(asRecord(capabilities)._meta) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta` of an AIR client with one more AIR payload, or undefined for
|
||||
* every other client: a client that is not AIR gets no AIR key.
|
||||
*/
|
||||
export function airOnlyMeta(
|
||||
airClient: boolean,
|
||||
capability: string,
|
||||
payload: unknown,
|
||||
meta?: Record<string, unknown> | null,
|
||||
): Record<string, unknown> | undefined {
|
||||
return airClient ? withAirMeta(meta, capability, payload) : (meta ?? undefined);
|
||||
}
|
||||
|
||||
/** 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];
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import type { AcpSessionNotification, AsyncTaskState } from "./acp-subagents.js";
|
||||
import { AIR_ASYNC_TASKS_CAPABILITY, clientSupportsAirCapability } from "./air-extension.js";
|
||||
import { noticeOrTranscriptUpdate } from "./session-notices.js";
|
||||
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
/** Moves an update to its owner session, or drops it with `null`. */
|
||||
type Route = (notification: AcpSessionNotification) => AcpSessionNotification | null;
|
||||
type TerminalSource = "event" | "level" | "shutdown";
|
||||
|
||||
type AsyncTask = {
|
||||
@@ -13,7 +16,19 @@ type AsyncTask = {
|
||||
showInTranscript: boolean;
|
||||
outputFilePath?: string;
|
||||
toolCallId?: string;
|
||||
/**
|
||||
* The route to the session of the tool call that started the task. The
|
||||
* spawn sets it, so every update of the task goes to one session. A task
|
||||
* without a route stays in the root session.
|
||||
*/
|
||||
route?: Route;
|
||||
announced: boolean;
|
||||
/**
|
||||
* Whether the spawn waits for the tool call id. The updates of a held task
|
||||
* wait in {@link AsyncTask.deferred} and follow the spawn in order.
|
||||
*/
|
||||
held: boolean;
|
||||
deferred: (() => Promise<void>)[];
|
||||
ignored: boolean;
|
||||
startedObserved: boolean;
|
||||
panelOnlyRecovery: boolean;
|
||||
@@ -22,10 +37,14 @@ type AsyncTask = {
|
||||
state: AsyncTaskState;
|
||||
terminalSummary?: string;
|
||||
terminalSource?: TerminalSource;
|
||||
/** The optional fields that the client holds now, as JSON. */
|
||||
published: Map<string, string>;
|
||||
};
|
||||
|
||||
type TaskIdentity = { taskId?: unknown; task_id?: unknown };
|
||||
|
||||
const MAX_TOOL_RESULT_PATHS = 500;
|
||||
|
||||
export type AsyncTaskStarted = TaskIdentity & {
|
||||
taskType?: unknown;
|
||||
task_type?: unknown;
|
||||
@@ -58,6 +77,8 @@ type TaskPatch = {
|
||||
|
||||
export type AsyncTaskNotification = TaskIdentity & {
|
||||
status?: unknown;
|
||||
toolCallId?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
summary?: unknown;
|
||||
outputFilePath?: unknown;
|
||||
output_file?: unknown;
|
||||
@@ -65,6 +86,8 @@ export type AsyncTaskNotification = TaskIdentity & {
|
||||
|
||||
type TaskProgress = TaskIdentity & {
|
||||
description?: unknown;
|
||||
toolCallId?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
summary?: unknown;
|
||||
lastToolName?: unknown;
|
||||
last_tool_name?: unknown;
|
||||
@@ -91,11 +114,29 @@ export class AsyncTaskRuntime {
|
||||
* a command was backgrounded can arrive after its terminal SDK edge.
|
||||
*/
|
||||
private readonly tasks = new Map<string, AsyncTask>();
|
||||
/**
|
||||
* The task output paths in the text of each tool result, by tool call
|
||||
* id. A task whose id and tool call are known takes its output path from
|
||||
* here. The tool result can come before or after the SDK names the task.
|
||||
* The oldest entries are dropped.
|
||||
*/
|
||||
private readonly outputPathsByToolCall = new Map<string, string[]>();
|
||||
|
||||
constructor(
|
||||
readonly enabled: boolean,
|
||||
private readonly sessionId: string,
|
||||
private readonly publish: Publish,
|
||||
/** `notices`: the client can present `notice` updates, so the stop
|
||||
* acknowledgement need not be a transcript line. `routeOf`: the route of
|
||||
* the tasks that a tool call in another session started, for example in
|
||||
* a native subagent session. `toolNameOf`: the name of the tool of a tool
|
||||
* call, or `undefined` when the tool call is unknown. A task that a
|
||||
* `Monitor` tool call started is ignored. */
|
||||
private readonly options: {
|
||||
notices?: boolean;
|
||||
routeOf?: (toolCallId: string) => Route | undefined;
|
||||
toolNameOf?: (toolCallId: string) => string | undefined;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
async taskStarted(message: AsyncTaskStarted): Promise<void> {
|
||||
@@ -103,8 +144,8 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,7 +157,8 @@ export class AsyncTaskRuntime {
|
||||
isBackgroundTask(
|
||||
field(message, "isBackgrounded", "is_backgrounded"),
|
||||
field(message, "taskType", "task_type"),
|
||||
)
|
||||
) ||
|
||||
task.held
|
||||
) {
|
||||
await this.announce(task);
|
||||
}
|
||||
@@ -133,8 +175,8 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,7 +225,8 @@ export class AsyncTaskRuntime {
|
||||
}
|
||||
if (task.state !== state) {
|
||||
task.state = state;
|
||||
if (task.announced) await this.publishState(task, state, nonBlankString(patch.error));
|
||||
const error = nonBlankString(patch.error);
|
||||
await this.whenAnnounced(task, () => this.publishState(task, state, error));
|
||||
} else if (task.announced && task.outputFilePath !== previousOutputFilePath) {
|
||||
await this.publishMetadata(task);
|
||||
}
|
||||
@@ -198,10 +241,12 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task || !task.announced || task.ignored) return;
|
||||
if (!task || task.ignored || !(task.announced || task.held)) return;
|
||||
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
const previousToolCallId = task.toolCallId;
|
||||
this.mergeOutputFilePath(task, message);
|
||||
this.mergeToolCallId(task, message);
|
||||
const outputChanged = task.outputFilePath !== previousOutputFilePath;
|
||||
if (isTerminal(task.state)) {
|
||||
if (outputChanged) await this.publishMetadata(task);
|
||||
@@ -212,13 +257,20 @@ export class AsyncTaskRuntime {
|
||||
const description = nonBlankString(message.description);
|
||||
const summary = nonBlankString(message.summary);
|
||||
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
|
||||
await this.publishProgress(task, {
|
||||
const progress = {
|
||||
...(description ? { description } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(lastToolName ? { lastToolName } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(outputChanged && task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
});
|
||||
...(task.toolCallId !== previousToolCallId && task.toolCallId
|
||||
? { toolCallId: task.toolCallId }
|
||||
: {}),
|
||||
};
|
||||
await this.whenAnnounced(task, () => this.publishProgress(task, progress));
|
||||
// The progress brought the tool call id of a held task: send the spawn,
|
||||
// then the progress beats that waited for it.
|
||||
if (task.held && task.toolCallId) await this.announce(task);
|
||||
}
|
||||
|
||||
async taskNotification(message: AsyncTaskNotification): Promise<void>;
|
||||
@@ -253,6 +305,7 @@ export class AsyncTaskRuntime {
|
||||
const correctsLevelState = wasTerminal && task.terminalSource === "level";
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
this.mergeOutputFilePath(task, message);
|
||||
this.mergeToolCallId(task, message);
|
||||
await this.finish(task, state, nonBlankString(message.summary), "event");
|
||||
if (
|
||||
wasTerminal &&
|
||||
@@ -289,8 +342,9 @@ export class AsyncTaskRuntime {
|
||||
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;
|
||||
const levelTaskType = field(item, "taskType", "task_type");
|
||||
if (levelTaskType === "local_agent" || levelTaskType === "local_monitor") {
|
||||
this.ignore(task);
|
||||
continue;
|
||||
}
|
||||
if (!task.startedObserved) {
|
||||
@@ -306,11 +360,18 @@ export class AsyncTaskRuntime {
|
||||
}
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.announced && !task.ignored && !isTerminal(task.state) && !live.has(task.id)) {
|
||||
if (
|
||||
(task.announced || task.held) &&
|
||||
!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.
|
||||
// permanent running card. Since SDK 0.3.292 a finishing task's level
|
||||
// follows its terminal edges, so this is a lost or late edge; a late
|
||||
// task_notification still corrects the best-effort stopped state to
|
||||
// completed/failed.
|
||||
await this.finish(task, "stopped", undefined, "level");
|
||||
}
|
||||
}
|
||||
@@ -319,7 +380,7 @@ export class AsyncTaskRuntime {
|
||||
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)) {
|
||||
if ((task.announced || task.held) && !task.ignored && !isTerminal(task.state)) {
|
||||
try {
|
||||
await this.finish(task, state, undefined, "shutdown");
|
||||
} catch (error) {
|
||||
@@ -354,9 +415,10 @@ export class AsyncTaskRuntime {
|
||||
// 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.
|
||||
// This acknowledgement is the only one the user gets -- the SDK injects
|
||||
// nothing into the model's context for a stopped shell task. A client on
|
||||
// the notice contract shows it as a live `notice`; otherwise the
|
||||
// transcript is where it has to land.
|
||||
//
|
||||
// Terminal state deliberately does not gate this. The SDK's own
|
||||
// `task_notification` routinely wins the race against the `stopTask`
|
||||
@@ -367,17 +429,76 @@ export class AsyncTaskRuntime {
|
||||
// `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}.` },
|
||||
},
|
||||
});
|
||||
await this.send(
|
||||
task,
|
||||
noticeOrTranscriptUpdate(
|
||||
{ severity: "info", title: "Task stopped by user", description: `${task.name}.` },
|
||||
this.options.notices ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a task of the tool call went to the background, so the client gets it. */
|
||||
isBackgroundedToolCall(toolCallId: string): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.toolCallId === toolCallId && !task.ignored && (task.announced || task.held)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the tool results in `content` for the output path of a known task.
|
||||
* Only a path that ends with `tasks/<task id>.output` in the result of
|
||||
* the task's own tool call counts. A tool result never creates a task.
|
||||
*/
|
||||
async toolResults(content: unknown): Promise<void> {
|
||||
if (!this.enabled || !Array.isArray(content)) return;
|
||||
for (const result of content.flatMap(toolResultBlock)) {
|
||||
const text = textContent(result.content);
|
||||
const paths = text ? taskOutputPaths(text) : [];
|
||||
if (paths.length === 0) continue;
|
||||
this.outputPathsByToolCall.delete(result.toolUseId);
|
||||
this.outputPathsByToolCall.set(result.toolUseId, paths);
|
||||
if (this.outputPathsByToolCall.size > MAX_TOOL_RESULT_PATHS) {
|
||||
const oldest = this.outputPathsByToolCall.keys().next().value;
|
||||
if (oldest !== undefined) this.outputPathsByToolCall.delete(oldest);
|
||||
}
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.toolCallId !== result.toolUseId || task.ignored || task.outputFilePath) continue;
|
||||
this.mergeToolResultPath(task);
|
||||
if (task.outputFilePath && task.announced) await this.publishMetadata(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the spawn of each held task without a tool call id. The prompt
|
||||
* result ends the model turn, and the result of the tool call that started a
|
||||
* task comes before it. So a held task is never held for longer than one turn.
|
||||
*/
|
||||
async releaseHeld(): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.held) await this.announce(task, { withoutToolCall: true });
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tasks.clear();
|
||||
this.outputPathsByToolCall.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a task that the client never sees. Only a task that is not announced
|
||||
* can become ignored, so the client never holds a task without its end.
|
||||
*/
|
||||
private ignore(task: AsyncTask): void {
|
||||
if (task.announced) return;
|
||||
task.ignored = true;
|
||||
task.held = false;
|
||||
task.deferred = [];
|
||||
}
|
||||
|
||||
private task(taskId: string): AsyncTask {
|
||||
@@ -390,12 +511,15 @@ export class AsyncTaskRuntime {
|
||||
description: "Background task",
|
||||
showInTranscript: true,
|
||||
announced: false,
|
||||
held: false,
|
||||
deferred: [],
|
||||
ignored: false,
|
||||
startedObserved: false,
|
||||
panelOnlyRecovery: false,
|
||||
stopping: false,
|
||||
stopAnnounced: false,
|
||||
state: "running",
|
||||
published: new Map(),
|
||||
};
|
||||
this.tasks.set(taskId, task);
|
||||
return task;
|
||||
@@ -422,6 +546,32 @@ export class AsyncTaskRuntime {
|
||||
this.mergeOutputFilePath(task, message);
|
||||
const toolCallId = nonBlankString(field(message, "toolCallId", "tool_use_id"));
|
||||
if (toolCallId) task.toolCallId = toolCallId;
|
||||
this.mergeToolResultPath(task);
|
||||
}
|
||||
|
||||
/** Keeps the first tool call id that a later SDK message brings. */
|
||||
private mergeToolCallId(
|
||||
task: AsyncTask,
|
||||
value: { toolCallId?: unknown; tool_use_id?: unknown },
|
||||
): void {
|
||||
task.toolCallId ??= nonBlankString(field(value, "toolCallId", "tool_use_id"));
|
||||
this.mergeToolResultPath(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the output path from the result of the task's tool call when no
|
||||
* structured source gave one. A structured path later replaces it.
|
||||
*/
|
||||
private mergeToolResultPath(task: AsyncTask): void {
|
||||
if (task.outputFilePath || !task.toolCallId) return;
|
||||
const paths = this.outputPathsByToolCall.get(task.toolCallId);
|
||||
if (paths) task.outputFilePath = outputFilePathOf(paths, task.id);
|
||||
}
|
||||
|
||||
/** Sends an update of an announced task. A held task sends it after its spawn. */
|
||||
private async whenAnnounced(task: AsyncTask, send: () => Promise<void>): Promise<void> {
|
||||
if (task.announced) await send();
|
||||
else if (task.held) task.deferred.push(send);
|
||||
}
|
||||
|
||||
private mergePatch(task: AsyncTask, patch: TaskPatch): void {
|
||||
@@ -451,23 +601,54 @@ export class AsyncTaskRuntime {
|
||||
if (outputFilePath) task.outputFilePath = outputFilePath;
|
||||
}
|
||||
|
||||
private async announce(task: AsyncTask): Promise<void> {
|
||||
/**
|
||||
* Sends the spawn of the task. The spawn names the tool call that started the
|
||||
* task, so it waits for the tool call id: the task is held until the id
|
||||
* arrives. The id comes as `tool_use_id` of the SDK `task_started`,
|
||||
* `task_progress`, or `task_notification`, or from the Bash result of a
|
||||
* backgrounded command. A held task gets its spawn without the id when it
|
||||
* ends first, or when the prompt result ends the model turn
|
||||
* ({@link releaseHeld}). No guess by the command text binds a task.
|
||||
*
|
||||
* A task that a `Monitor` tool call started is ignored: Monitor streams its
|
||||
* output to the model only, so the task has nothing for the client. The
|
||||
* tool_use streams before the tool runs, so the tool name is known when the
|
||||
* spawn has the tool call id.
|
||||
*/
|
||||
private async announce(
|
||||
task: AsyncTask,
|
||||
options: { withoutToolCall?: boolean } = {},
|
||||
): 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 } : {}),
|
||||
},
|
||||
const toolName = task.toolCallId ? this.options.toolNameOf?.(task.toolCallId) : undefined;
|
||||
if (toolName !== undefined && HIDDEN_TASK_TOOLS.has(toolName)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
if (!task.toolCallId && !options.withoutToolCall && !isTerminal(task.state)) {
|
||||
task.held = true;
|
||||
return;
|
||||
}
|
||||
if (task.toolCallId) task.route ??= this.options.routeOf?.(task.toolCallId);
|
||||
await this.send(task, {
|
||||
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;
|
||||
task.held = false;
|
||||
recordPublished(task, {
|
||||
description: task.description,
|
||||
outputFilePath: task.outputFilePath,
|
||||
toolCallId: task.toolCallId,
|
||||
});
|
||||
for (const send of task.deferred.splice(0)) await send();
|
||||
if (isTerminal(task.state)) {
|
||||
await this.publishState(task, task.state, task.terminalSummary);
|
||||
}
|
||||
@@ -481,8 +662,9 @@ export class AsyncTaskRuntime {
|
||||
): 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.
|
||||
// A level event can precede a late terminal edge (older CLIs sent the
|
||||
// level first). Correct its best-effort stopped state if that edge
|
||||
// later arrives.
|
||||
if (task.terminalSource !== "level" || source !== "event") return;
|
||||
}
|
||||
const previous = {
|
||||
@@ -495,6 +677,9 @@ export class AsyncTaskRuntime {
|
||||
task.terminalSource = source;
|
||||
try {
|
||||
if (task.announced) await this.publishState(task, state, summary);
|
||||
// The task ended before its tool call id arrived. The spawn goes out now
|
||||
// without the id, so the task still appears.
|
||||
else if (task.held) await this.announce(task);
|
||||
} catch (error) {
|
||||
task.state = previous.state;
|
||||
task.terminalSummary = previous.terminalSummary;
|
||||
@@ -516,6 +701,10 @@ export class AsyncTaskRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the progress fields that changed since the last report of the
|
||||
* task. A beat with nothing new is not sent.
|
||||
*/
|
||||
private async publishProgress(
|
||||
task: AsyncTask,
|
||||
update: {
|
||||
@@ -527,36 +716,72 @@ export class AsyncTaskRuntime {
|
||||
toolCallId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_progress",
|
||||
asyncTaskId: task.id,
|
||||
...update,
|
||||
},
|
||||
const changed = changedFields(task, update);
|
||||
if (Object.keys(changed).length === 0) return;
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_progress",
|
||||
asyncTaskId: task.id,
|
||||
...changed,
|
||||
});
|
||||
recordPublished(task, changed);
|
||||
}
|
||||
|
||||
/** Publishes a state, with the output path and the tool call only when they changed. */
|
||||
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 } : {}),
|
||||
},
|
||||
const changed = changedFields(task, {
|
||||
outputFilePath: task.outputFilePath,
|
||||
toolCallId: task.toolCallId,
|
||||
});
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: task.id,
|
||||
state,
|
||||
...(summary ? { summary } : {}),
|
||||
...changed,
|
||||
});
|
||||
recordPublished(task, changed);
|
||||
}
|
||||
|
||||
/** Publishes an update of the task in the session that owns the task. */
|
||||
private async send(task: AsyncTask, update: AcpSessionNotification["update"]): Promise<void> {
|
||||
const notification: AcpSessionNotification = { sessionId: this.sessionId, update };
|
||||
const routed = task.route ? task.route(notification) : notification;
|
||||
if (routed) await this.publish(routed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recovers background Bash lifecycle data exposed only on its tool result. */
|
||||
/**
|
||||
* The fields whose value differs from what the client holds. An undefined
|
||||
* value is not a field. The caller records the fields with
|
||||
* {@link recordPublished} after the send succeeds, so a retry after a failed
|
||||
* send carries them again.
|
||||
*/
|
||||
function changedFields<T extends Record<string, unknown>>(task: AsyncTask, fields: T): Partial<T> {
|
||||
const changed: Partial<T> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value === undefined) continue;
|
||||
if (task.published.get(key) === JSON.stringify(value)) continue;
|
||||
(changed as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Records the fields that the client holds now. An undefined value is not a field. */
|
||||
function recordPublished(task: AsyncTask, fields: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value !== undefined) task.published.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovers a background Bash task from the structured `backgroundTaskId` of
|
||||
* its tool result. The text of a tool result never gives a task id: a
|
||||
* foreground command can print any text.
|
||||
*/
|
||||
export function backgroundBashTaskFromToolResult(
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
@@ -568,12 +793,7 @@ export function backgroundBashTaskFromToolResult(
|
||||
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),
|
||||
]);
|
||||
const distinctTaskIds = new Set(backgroundResults.map((result) => result.taskId));
|
||||
if (distinctTaskIds.size !== 1) return undefined;
|
||||
const taskId = [...distinctTaskIds][0];
|
||||
if (!taskId) return undefined;
|
||||
@@ -607,15 +827,36 @@ export function backgroundBashTaskFromToolResult(
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
/**
|
||||
* The Bash tool calls in `content` whose command went to the background. Only
|
||||
* structured data counts: the task from the structured tool result, a task
|
||||
* that the runtime knows for the tool call, or a `run_in_background` input of
|
||||
* a call that did not fail.
|
||||
*/
|
||||
export function backgroundedBashToolCallIds(
|
||||
content: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
runtime: AsyncTaskRuntime,
|
||||
structuredTask?: AsyncTaskStarted,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (!runtime.enabled || !Array.isArray(content)) return ids;
|
||||
const structuredToolCallId = structuredTask
|
||||
? nonBlankString(field(structuredTask, "toolCallId", "tool_use_id"))
|
||||
: undefined;
|
||||
for (const result of content.flatMap(toolResultBlock)) {
|
||||
const toolUse = toolUses[result.toolUseId];
|
||||
if (toolUse?.name !== "Bash") continue;
|
||||
const input = isRecord(toolUse.input) ? toolUse.input : undefined;
|
||||
if (
|
||||
result.toolUseId === structuredToolCallId ||
|
||||
runtime.isBackgroundedToolCall(result.toolUseId) ||
|
||||
(input?.run_in_background === true && !result.isError)
|
||||
) {
|
||||
ids.add(result.toolUseId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function structuredBackgroundResults(
|
||||
@@ -647,27 +888,73 @@ function structuredBackgroundResults(
|
||||
return results;
|
||||
}
|
||||
|
||||
function toolResultBlock(value: unknown): { toolUseId: string; content?: unknown }[] {
|
||||
function toolResultBlock(
|
||||
value: unknown,
|
||||
): { toolUseId: string; content?: unknown; isError: boolean }[] {
|
||||
if (!isRecord(value) || value.type !== "tool_result") return [];
|
||||
const toolUseId = nonBlankString(field(value, "toolUseId", "tool_use_id"));
|
||||
return toolUseId ? [{ toolUseId, content: value.content }] : [];
|
||||
return toolUseId
|
||||
? [
|
||||
{
|
||||
toolUseId,
|
||||
content: value.content,
|
||||
isError: field(value, "isError", "is_error") === true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
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;
|
||||
return text ? outputFilePathOf(taskOutputPaths(text), taskId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a task output path: `tasks/<id>.output`, followed by a sentence
|
||||
* period, whitespace or the end of the text. Claude Code writes the output of
|
||||
* a background task there.
|
||||
*/
|
||||
const TASK_OUTPUT_SUFFIX = /[/\\]tasks[/\\][^\s/\\]+?\.output(?=\.(?!\w)|\s|$)/g;
|
||||
/** An absolute POSIX, Windows drive or UNC path. */
|
||||
const ABSOLUTE_PATH = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/;
|
||||
|
||||
/**
|
||||
* The task output paths in a tool result text. Each text of Claude Code puts
|
||||
* the path after "Output is being written to: ", so a path starts after the
|
||||
* nearest `": "` before its end. A drive colon has no space after it, so a
|
||||
* path can hold spaces and a drive. The search for the start stops at a line
|
||||
* break and at the end of the previous path end, so a path never crosses a
|
||||
* line or holds another path. Each character is read at most once, so the
|
||||
* parse is linear in the text length. A path that is not absolute does not
|
||||
* count.
|
||||
*/
|
||||
function taskOutputPaths(text: string): string[] {
|
||||
const paths: string[] = [];
|
||||
let previousEnd = 0;
|
||||
for (const match of text.matchAll(TASK_OUTPUT_SUFFIX)) {
|
||||
const end = match.index + match[0].length;
|
||||
let start = -1;
|
||||
for (let i = match.index - 1; i >= previousEnd; i--) {
|
||||
const char = text[i];
|
||||
if (char === "\n" || char === "\r") break;
|
||||
if (char === ":" && text[i + 1] === " ") {
|
||||
start = i + 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
previousEnd = end;
|
||||
if (start < 0) continue;
|
||||
const path = text.slice(start, end);
|
||||
if (ABSOLUTE_PATH.test(path)) paths.push(path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** The output path of the task `taskId` among the paths, if one names it. */
|
||||
function outputFilePathOf(paths: readonly string[], taskId: string): string | undefined {
|
||||
const posix = `/tasks/${taskId}.output`;
|
||||
const windows = `\\tasks\\${taskId}.output`;
|
||||
return paths.find((path) => path.endsWith(posix) || path.endsWith(windows));
|
||||
}
|
||||
|
||||
function textContent(value: unknown): string | undefined {
|
||||
@@ -698,6 +985,13 @@ function isBackgroundTask(isBackgrounded: unknown, taskType: unknown): boolean {
|
||||
return taskType !== "local_bash" && taskType !== "local_agent";
|
||||
}
|
||||
|
||||
/** The tools whose async tasks the client never sees. */
|
||||
const HIDDEN_TASK_TOOLS = new Set(["Monitor"]);
|
||||
|
||||
function isMonitorTask(message: AsyncTaskStarted): boolean {
|
||||
return field(message, "taskType", "task_type") === "local_monitor";
|
||||
}
|
||||
|
||||
function isSubagentTask(message: AsyncTaskStarted): boolean {
|
||||
return (
|
||||
field(message, "subagentType", "subagent_type") !== undefined ||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PermissionMode,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
||||
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
|
||||
export type ClearContextReset = {
|
||||
toolUseId: string;
|
||||
@@ -39,6 +39,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
|
||||
configOptions: SessionConfigOption[];
|
||||
currentAgent: string;
|
||||
fastModeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
turnQueue?: Turn[];
|
||||
pendingExitPlanContextReset?: ClearContextReset;
|
||||
@@ -69,9 +70,6 @@ export type ClearContextCoordinatorHost<
|
||||
|
||||
function restartParams<Session extends ClearContextSession>(session: Session): NewSessionRequest {
|
||||
const originalParams = session.creationParams ?? { cwd: session.cwd, mcpServers: [] };
|
||||
const currentEffort = session.configOptions.find(
|
||||
(option) => option.id === EFFORT_CONFIG_ID,
|
||||
)?.currentValue;
|
||||
const originalMeta = originalParams._meta as
|
||||
({ claudeCode?: { options?: Options } } & Record<string, unknown>) | undefined;
|
||||
const originalOptions = originalMeta?.claudeCode?.options;
|
||||
@@ -92,8 +90,8 @@ function restartParams<Session extends ClearContextSession>(session: Session): N
|
||||
? { model: session.models.currentModelId }
|
||||
: {}),
|
||||
...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}),
|
||||
...(typeof currentEffort === "string" && currentEffort !== "default"
|
||||
? { effort: currentEffort as EffortLevel }
|
||||
...(session.effortPinnedLevel !== undefined
|
||||
? { effort: session.effortPinnedLevel as EffortLevel }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const CONTEXT_COMPACTION_META_KEY = "contextCompaction";
|
||||
import { AIR_CONTEXT_COMPACTION_KEY, withAirMeta } from "./air-extension.js";
|
||||
|
||||
export const CONTEXT_COMPACTION_META_VERSION = 1;
|
||||
|
||||
export type ContextCompactionTrigger = "manual" | "automatic";
|
||||
@@ -13,17 +14,17 @@ export interface ContextCompactionMetadata {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The `_meta` of a context compaction report:
|
||||
* `_meta.jetbrains.air.contextCompaction`, as `docs/air-extensions.md`
|
||||
* defines. The standard toolCallId and status fields own lifecycle identity
|
||||
* and phase; this extension carries only compaction-specific facts. Only AIR
|
||||
* gets it.
|
||||
*/
|
||||
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,
|
||||
},
|
||||
};
|
||||
): Record<string, unknown> {
|
||||
return withAirMeta(undefined, AIR_CONTEXT_COMPACTION_KEY, {
|
||||
version: CONTEXT_COMPACTION_META_VERSION,
|
||||
...metadata,
|
||||
} satisfies ContextCompactionMetadata);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,138 @@
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
ContextCompactionMetadata,
|
||||
createContextCompactionMeta,
|
||||
} from "./context-compaction-meta.js";
|
||||
import { compactionToolCall } from "./tool-calls/reporters/compaction.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 = {
|
||||
toolCallId: string;
|
||||
terminalStatus?: CompactionStatus;
|
||||
/** Doubles as the toolCallId under the legacy presentation. */
|
||||
compactionId: string;
|
||||
terminalStatus?: TerminalStatus;
|
||||
heartbeatSent: boolean;
|
||||
/** The summary text that went out as chunks. */
|
||||
streamedSummary?: string;
|
||||
/** 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;
|
||||
/** The client is AIR. Only AIR gets the compaction facts, under `_meta.jetbrains.air`. */
|
||||
airClient?: boolean;
|
||||
};
|
||||
|
||||
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]*$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Translates Claude's compaction signals into one idempotent ACP tool lifecycle.
|
||||
* 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
|
||||
@@ -26,22 +143,85 @@ export class ContextCompactionLifecycle {
|
||||
private activeCompaction: CompactionState | undefined;
|
||||
private outputDelivered = false;
|
||||
private duplicateErrorOutput: string | undefined;
|
||||
/** After interruption, uncorrelated terminal frames and hooks may still
|
||||
* arrive from the abandoned work, including an opening we never consumed.
|
||||
* Wait for a known new turn before accepting them again. */
|
||||
private interrupted = false;
|
||||
/** 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;
|
||||
private readonly airClient: boolean;
|
||||
|
||||
constructor(private readonly sendUpdate: SendUpdate) {}
|
||||
constructor(
|
||||
private readonly sendUpdate: SendUpdate,
|
||||
options: ContextCompactionLifecycleOptions,
|
||||
) {
|
||||
this.sessionId = options.sessionId;
|
||||
this.presentation = options.presentation ?? "tool_call";
|
||||
this.logError = options.logError ?? (() => {});
|
||||
this.airClient = options.airClient ?? false;
|
||||
}
|
||||
|
||||
get hasDeliveredOutput(): boolean {
|
||||
return this.outputDelivered;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
/**
|
||||
* 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",
|
||||
);
|
||||
}
|
||||
|
||||
/** Abandon the current work before settling its prompt or tearing down the
|
||||
* stream. Unlike a normal result boundary, its pending hooks are not safe
|
||||
* to carry into the next compaction. A hook has no command ID, so even an
|
||||
* early hook for the next compaction must be omitted until the stream gives
|
||||
* us a new live turn boundary. Idempotent and best-effort. */
|
||||
async interrupt(): Promise<void> {
|
||||
if (this.presentation === "compaction_update") {
|
||||
this.interrupted = true;
|
||||
this.pendingSummary = undefined;
|
||||
}
|
||||
await this.reset();
|
||||
}
|
||||
|
||||
/** A live command's dispatch/echo proves the interrupted command's tail
|
||||
* has drained in the SDK's FIFO stream. Do not reset normal lifecycle
|
||||
* state: compaction may already have started before the user echo. */
|
||||
resume(): void {
|
||||
this.interrupted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude also emits a failed manual compaction's error as local-command
|
||||
* stdout. Consume that one duplicate after the tool lifecycle carried it,
|
||||
* stdout. Consume that one duplicate after the lifecycle carried it,
|
||||
* without hiding unrelated command output.
|
||||
*/
|
||||
consumeDuplicateErrorOutput(content: string): boolean {
|
||||
@@ -55,101 +235,177 @@ export class ContextCompactionLifecycle {
|
||||
return true;
|
||||
}
|
||||
|
||||
async start(sessionId: string, toolCallId: string): Promise<CompactionState> {
|
||||
async start(compactionId: string): Promise<void> {
|
||||
if (this.interrupted) return;
|
||||
if (this.activeCompaction && !this.activeCompaction.terminalStatus) {
|
||||
return this.activeCompaction;
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeCompaction = { toolCallId, heartbeatSent: false };
|
||||
this.outputDelivered = true;
|
||||
await this.sendUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
this.open(compactionId);
|
||||
if (this.presentation === "compaction_update") {
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "in_progress",
|
||||
_meta: compactionToolMeta(),
|
||||
},
|
||||
});
|
||||
return this.activeCompaction;
|
||||
...(this.airClient ? { _meta: createContextCompactionMeta() } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.send(compactionToolCall.started(compactionId, this.airClient));
|
||||
}
|
||||
|
||||
async heartbeat(sessionId: string, fallbackId: string): Promise<void> {
|
||||
const state = this.activeCompaction ?? (await this.start(sessionId, fallbackId));
|
||||
if (state.terminalStatus || state.heartbeatSent) return;
|
||||
|
||||
state.heartbeatSent = true;
|
||||
await this.sendUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: state.toolCallId,
|
||||
status: "in_progress",
|
||||
_meta: compactionToolMeta(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async finish(
|
||||
sessionId: string,
|
||||
fallbackId: string,
|
||||
status: CompactionStatus,
|
||||
metadata: Omit<ContextCompactionMetadata, "version"> = {},
|
||||
enrichTerminal = false,
|
||||
): Promise<void> {
|
||||
const rawOutput = Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
if (!this.activeCompaction) {
|
||||
this.activeCompaction = {
|
||||
toolCallId: fallbackId,
|
||||
heartbeatSent: false,
|
||||
terminalStatus: status,
|
||||
};
|
||||
this.outputDelivered = true;
|
||||
if (status === "failed" && metadata.error) {
|
||||
this.duplicateErrorOutput = metadata.error;
|
||||
}
|
||||
await this.sendUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: fallbackId,
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
status,
|
||||
...(status === "failed" && metadata.error
|
||||
? { content: [compactionErrorContent(metadata.error)] }
|
||||
: {}),
|
||||
...(rawOutput ? { rawOutput } : {}),
|
||||
_meta: compactionToolMeta(metadata),
|
||||
},
|
||||
/**
|
||||
* 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;
|
||||
state.streamedSummary = (state.streamedSummary ?? "") + summaryChunk;
|
||||
await this.send({
|
||||
sessionUpdate: "compaction_summary_chunk",
|
||||
compactionId: state.compactionId,
|
||||
content: { type: "text", text: summaryChunk },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeCompaction) await this.start(fallbackId);
|
||||
const state = this.activeCompaction;
|
||||
if (state.terminalStatus && !enrichTerminal) return;
|
||||
if (!state || state.terminalStatus || state.heartbeatSent) return;
|
||||
state.heartbeatSent = true;
|
||||
await this.send(compactionToolCall.inProgress(state.compactionId, this.airClient));
|
||||
}
|
||||
|
||||
const firstTerminal = state.terminalStatus === undefined;
|
||||
if (firstTerminal) state.terminalStatus = status;
|
||||
/**
|
||||
* 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.interrupted) return false;
|
||||
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> {
|
||||
if (this.interrupted) return;
|
||||
// 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;
|
||||
}
|
||||
await this.sendUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: state.toolCallId,
|
||||
...(firstTerminal ? { status } : {}),
|
||||
...(status === "failed" && metadata.error
|
||||
? { content: [compactionErrorContent(metadata.error)] }
|
||||
const terminalStatus = state.terminalStatus ?? status;
|
||||
const hasMetadata = Object.keys(metadata).length > 0;
|
||||
|
||||
if (this.presentation === "compaction_update") {
|
||||
// A summary that went out as chunks is not sent again in full. The
|
||||
// chunks are the raw API text and the summary is the cleaned hook text,
|
||||
// so the summary is left out only when both hold the same text.
|
||||
const summaryStreamed = state.streamedSummary === state.summary;
|
||||
const summary =
|
||||
firstTerminal && terminalStatus === "completed" && !summaryStreamed
|
||||
? 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.
|
||||
// The standard `error` field carries the error, so `_meta` does not.
|
||||
// Only AIR gets the compaction facts.
|
||||
...(this.airClient && (firstTerminal || hasMetadata)
|
||||
? { _meta: createContextCompactionMeta(withoutError(metadata)) }
|
||||
: {}),
|
||||
...(rawOutput ? { rawOutput } : {}),
|
||||
_meta: compactionToolMeta(metadata),
|
||||
},
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.send(
|
||||
compactionToolCall.finished(
|
||||
state.compactionId,
|
||||
opened || firstTerminal ? status : undefined,
|
||||
metadata,
|
||||
opened,
|
||||
this.airClient,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withoutError({
|
||||
error: _error,
|
||||
...facts
|
||||
}: Omit<ContextCompactionMetadata, "version">): Omit<ContextCompactionMetadata, "version"> {
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function contextCompactionMetadataFromBoundary(compactMetadata: {
|
||||
@@ -169,22 +425,3 @@ export function contextCompactionMetadataFromBoundary(compactMetadata: {
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
707
src/diff.ts
Normal file
707
src/diff.ts
Normal file
@@ -0,0 +1,707 @@
|
||||
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { structuredPatch } from "diff";
|
||||
import { constants } from "node:fs";
|
||||
import { type FileHandle, open } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { AIR_DIFF_PATCH_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
import { normalizeWriteInput } from "./tool-calls/reporters/file-edit.js";
|
||||
|
||||
/**
|
||||
* The largest file, in bytes, that the adapter turns into a git patch.
|
||||
*
|
||||
* A larger file, a larger new text, or an Edit whose result can be larger
|
||||
* gets no patch. The tool call then keeps its standard ACP content. The limit
|
||||
* bounds the file read, the replacement, and the line diff that run while
|
||||
* Claude waits for an approval.
|
||||
*/
|
||||
export const MAX_PATCH_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The wall-clock budget, in milliseconds, of one line diff for a patch.
|
||||
*
|
||||
* The diff runs synchronously, so the budget is also the longest block of the
|
||||
* event loop.
|
||||
*/
|
||||
const PATCH_DIFF_TIMEOUT_MS = 100;
|
||||
|
||||
/** The number of context lines around a change in a hunk. */
|
||||
const PATCH_CONTEXT_LINES = 3;
|
||||
|
||||
/** Git reads this many leading bytes to decide that a file is binary. */
|
||||
const BINARY_SNIFF_BYTES = 8000;
|
||||
|
||||
const NO_NEWLINE_MARKER = "\\ No newline at end of file";
|
||||
|
||||
/**
|
||||
* One unified-diff hunk in the `diff` package convention.
|
||||
*
|
||||
* A side with zero lines stores the number of the line after the change as its
|
||||
* start. {@link hunkHeader} converts that start to the git convention.
|
||||
*/
|
||||
interface PatchHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface DiffToolResponse {
|
||||
filePath?: string;
|
||||
structuredPatch?: PatchHunk[];
|
||||
/** 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;
|
||||
/** The pre-change content. It is null on a Write create, or on a Write
|
||||
* update whose previous content was too large to include. An Edit that
|
||||
* creates a file reports an empty string, as for an existing empty file. */
|
||||
originalFile?: string | null;
|
||||
/** FileEditOutput only: the text that the Edit replaced. It is empty when
|
||||
* the Edit created the file or filled an existing empty file. */
|
||||
oldString?: string;
|
||||
}
|
||||
|
||||
interface EditPreviewInput {
|
||||
file_path?: unknown;
|
||||
old_string?: unknown;
|
||||
new_string?: unknown;
|
||||
replace_all?: unknown;
|
||||
}
|
||||
|
||||
/** The kind of file change that a git patch describes. */
|
||||
type FileChange = "create" | "update";
|
||||
|
||||
/** A file change and the hunks of its git patch. */
|
||||
interface FilePatch {
|
||||
change: FileChange;
|
||||
hunks: PatchHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff that carries an exact patch: AIR's `diffPatch` extension of a v1
|
||||
* diff, or an ACP v2 diff.
|
||||
*/
|
||||
export type PatchForm = "air" | "v2";
|
||||
|
||||
/**
|
||||
* How a git patch names its file. `git` is git's own form: the leading slash
|
||||
* dropped and the `a/` and `b/` prefixes added. `absolute` is the form of an
|
||||
* ACP v2 `git_patch`, whose paths must be absolute: the path itself.
|
||||
*/
|
||||
type GitPatchPaths = "git" | "absolute";
|
||||
|
||||
/** An ACP v2 diff, as tool call content. */
|
||||
type V2DiffContent = v2.Diff & { type: "diff" };
|
||||
|
||||
/**
|
||||
* Builds the exact patch shown before Claude runs an Edit or Write tool.
|
||||
*
|
||||
* Returns undefined when the adapter cannot predict the file change exactly.
|
||||
* The caller then keeps the standard tool-call content. The adapter declines
|
||||
* a file that is missing for an Edit, too large, binary, or that has CR line
|
||||
* endings. Claude converts the line endings of an edited file, so an in-memory
|
||||
* replacement would not match the bytes that Claude writes. It also declines
|
||||
* an `old_string` that does not match exactly once, because Claude then
|
||||
* normalizes quotes or fails.
|
||||
*
|
||||
* A Write always gets content that shows the change: the patch, the standard
|
||||
* diff (in v2, the change alone) of a text that cannot have an exact patch, or
|
||||
* a notice that the Write overwrites a file that the adapter cannot show.
|
||||
*
|
||||
* The preview uses the tool input as it is. Claude can still remove the
|
||||
* trailing whitespace of a line before it writes. The PostToolUse hook then
|
||||
* sends the patch of the written file.
|
||||
*/
|
||||
export async function previewPatchContent(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
cwd?: string,
|
||||
form: PatchForm = "air",
|
||||
): Promise<ToolCallContent[] | undefined> {
|
||||
if (toolName === "Edit") {
|
||||
const edit = input as EditPreviewInput;
|
||||
if (
|
||||
typeof edit.file_path !== "string" ||
|
||||
typeof edit.old_string !== "string" ||
|
||||
typeof edit.new_string !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const oldString = edit.old_string;
|
||||
const filePath = resolveToolPath(edit.file_path, cwd);
|
||||
const oldText = await readPatchSource(filePath);
|
||||
if (oldText === undefined) return undefined;
|
||||
const newString = edit.new_string;
|
||||
if (oldString === newString || !isPatchableText(newString)) return undefined;
|
||||
if (oldString.length === 0) {
|
||||
// An empty old_string creates the file, or fills an existing file that
|
||||
// holds only whitespace. `trim` also removes a byte order mark, whose
|
||||
// handling the adapter does not predict, so such a file is declined.
|
||||
if (oldText !== null && (oldText.trim() !== "" || oldText.includes("\uFEFF"))) {
|
||||
return undefined;
|
||||
}
|
||||
return exactPatch(filePath, oldText, newString, form);
|
||||
}
|
||||
if (oldText === null) return undefined;
|
||||
const occurrences = oldText.split(oldString).length - 1;
|
||||
if (occurrences === 0 || (edit.replace_all !== true && occurrences !== 1)) return undefined;
|
||||
if (replacedTextBytesBound(oldText, oldString, newString, occurrences) > MAX_PATCH_FILE_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
const newText = replacedText(oldText, oldString, newString, edit.replace_all === true);
|
||||
return exactPatch(filePath, oldText, newText, form);
|
||||
}
|
||||
|
||||
if (toolName === "Write") {
|
||||
const write = normalizeWriteInput(input);
|
||||
if (typeof write?.file_path !== "string" || typeof write.content !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
// The Write tool call shows no diff, so the approval is the only place that
|
||||
// shows what the Write changes.
|
||||
const content = write.content;
|
||||
const filePath = resolveToolPath(write.file_path, cwd);
|
||||
const oldText = await readPatchSource(filePath);
|
||||
if (oldText === undefined) {
|
||||
return [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Overwrites the existing file \`${write.file_path}\`. The adapter cannot show its current content.`,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (oldText === content) return undefined;
|
||||
const standard: ToolCallContent[] = [
|
||||
form === "v2"
|
||||
? v2DiffContent(filePath, oldText === null ? "create" : "update")
|
||||
: { type: "diff", path: write.file_path, oldText, newText: content },
|
||||
];
|
||||
if (!isPatchableText(content)) return standard;
|
||||
// A dense change can exceed the diff budget. The approval then shows the standard diff.
|
||||
return exactPatch(filePath, oldText, content, form) ?? standard;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* An upper bound of the UTF-8 size, in bytes, of {@link replacedText}.
|
||||
*
|
||||
* The caller checks the bound before it builds the text. A `replace_all` of
|
||||
* many short matches with a long `newString` can otherwise build a text of
|
||||
* gigabytes before the timed line diff starts.
|
||||
*/
|
||||
function replacedTextBytesBound(
|
||||
fileText: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
occurrences: number,
|
||||
): number {
|
||||
const growth = Buffer.byteLength(newString, "utf8") - Buffer.byteLength(oldString, "utf8");
|
||||
return Buffer.byteLength(fileText, "utf8") + occurrences * Math.max(0, growth);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file text after Claude replaces `oldString` with `newString`.
|
||||
*
|
||||
* An empty `newString` deletes a line: when `oldString` does not end with a
|
||||
* line break and the file holds `oldString` and a line break, Claude also
|
||||
* removes that line break. With `replaceAll`, it then replaces only the
|
||||
* occurrences that a line break follows.
|
||||
*/
|
||||
function replacedText(
|
||||
fileText: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
): string {
|
||||
const target =
|
||||
newString === "" && !oldString.endsWith("\n") && fileText.includes(`${oldString}\n`)
|
||||
? `${oldString}\n`
|
||||
: oldString;
|
||||
return replaceAll
|
||||
? fileText.split(target).join(newString)
|
||||
: fileText.replace(target, () => newString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the git patch for a finished Edit or Write from the file on disk.
|
||||
*
|
||||
* The Claude SDK `structuredPatch` is display data: Claude converts leading
|
||||
* tabs to spaces and CRLF line endings to LF before it computes the hunks. A
|
||||
* patch built from those hunks does not match the file. This function diffs
|
||||
* `originalFile` against the written file instead. It returns undefined when
|
||||
* it cannot build an exact patch, and the caller then sends the standard diff.
|
||||
* A written file that contains a CR or a byte order mark is declined, because
|
||||
* Claude removed those bytes from `originalFile`.
|
||||
*/
|
||||
export async function patchUpdateFromDiffToolResponse(
|
||||
toolResponse: unknown,
|
||||
form: PatchForm = "air",
|
||||
): Promise<{ content: ToolCallContent[]; locations: ToolCallLocation[] } | undefined> {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return undefined;
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
if (typeof response.filePath !== "string") return undefined;
|
||||
// An Edit with an empty old_string reports "" as the original of a created
|
||||
// file and of an existing empty file. The response does not tell the two
|
||||
// apart, so the caller keeps the standard diff.
|
||||
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
|
||||
return undefined;
|
||||
}
|
||||
// A Write whose content is the previous text changed nothing. The file on
|
||||
// disk can hold a later change, and that change is not the change of this Write.
|
||||
if (
|
||||
response.type === "update" &&
|
||||
typeof response.content === "string" &&
|
||||
response.originalFile === response.content
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const oldText =
|
||||
response.type === "create"
|
||||
? null
|
||||
: typeof response.originalFile === "string"
|
||||
? response.originalFile
|
||||
: undefined;
|
||||
if (oldText === undefined) return undefined;
|
||||
// The same size limit as the new side: a larger text gets the standard diff.
|
||||
if (oldText !== null && Buffer.byteLength(oldText, "utf8") > MAX_PATCH_FILE_BYTES)
|
||||
return undefined;
|
||||
const newText = await readPatchSource(response.filePath);
|
||||
if (typeof newText !== "string" || newText.startsWith("\uFEFF")) return undefined;
|
||||
if (response.type === "create" && newText !== response.content) return undefined;
|
||||
const patch = filePatch(oldText, newText);
|
||||
if (!patch) return undefined;
|
||||
return {
|
||||
content: [patchContent(response.filePath, patch, form)],
|
||||
// A created file keeps the location of its Write tool call.
|
||||
locations:
|
||||
oldText === null
|
||||
? [{ path: response.filePath }]
|
||||
: patch.hunks.map(({ newStart }) => ({ path: response.filePath!, line: newStart })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds standard ACP diff 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 if (line === NO_NEWLINE_MARKER) {
|
||||
continue;
|
||||
} 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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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. A `create` needs nothing here: the
|
||||
// tool_use-time diff shows the created file, and a client with diffPatch
|
||||
// gets it from the Write reporter.
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a finished Edit or Write: the exact patch of
|
||||
* {@link patchUpdateFromDiffToolResponse} when it can be built, else the
|
||||
* change with a patch from what the toolResponse holds.
|
||||
*
|
||||
* The fallback patch of an update comes from the `structuredPatch`, which is
|
||||
* display data (see {@link patchUpdateFromDiffToolResponse}). That is enough
|
||||
* for a v2 patch, which is renderable text, not a patch to apply. An update
|
||||
* without hunks, such as one whose previous content was too large to diff,
|
||||
* gets the change alone. An Edit with an empty `old_string` reports the same
|
||||
* response for a created file and a filled empty one, so it gets no diff.
|
||||
*/
|
||||
export async function v2UpdateFromDiffToolResponse(toolResponse: unknown): Promise<{
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
}> {
|
||||
const exact = await patchUpdateFromDiffToolResponse(toolResponse, "v2");
|
||||
if (exact) return exact;
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
const filePath = response.filePath;
|
||||
if (typeof filePath !== "string") return {};
|
||||
if (response.type === "create") {
|
||||
const hunk =
|
||||
typeof response.content === "string" && isPatchableText(response.content)
|
||||
? wholeFileHunk(response.content)
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "create", hunk && [hunk])],
|
||||
locations: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
|
||||
return {};
|
||||
}
|
||||
// A Write whose content is the previous text changed nothing.
|
||||
if (response.type === "update" && response.content === response.originalFile) return {};
|
||||
const hunks =
|
||||
Array.isArray(response.structuredPatch) && response.structuredPatch.length > 0
|
||||
? response.structuredPatch
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "update", hunks)],
|
||||
locations: hunks
|
||||
? hunks.map(({ newStart }) => ({ path: filePath, line: newStart }))
|
||||
: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of one git patch for `filePath`.
|
||||
*
|
||||
* The headers follow `git diff`: the path is quoted when git would quote it,
|
||||
* and a created file gets its mode line and a `/dev/null` side. With `git`
|
||||
* paths, the path loses its leading slash and gets the `a/` and `b/` prefixes.
|
||||
*/
|
||||
export function gitPatchText(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks: PatchHunk[],
|
||||
paths: GitPatchPaths = "git",
|
||||
): string {
|
||||
return [
|
||||
...gitPatchHeader(filePath, change, paths),
|
||||
...hunks.flatMap((hunk) => [hunkHeader(hunk), ...hunk.lines]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function gitPatchHeader(filePath: string, change: FileChange, paths: GitPatchPaths): string[] {
|
||||
// A Windows path gets forward slashes so the header names the same file on
|
||||
// every platform.
|
||||
const slashed = filePath.replaceAll("\\", "/");
|
||||
// git drops one leading slash of an absolute path.
|
||||
const name = paths === "git" ? slashed.replace(/^\/+/u, "") : slashed;
|
||||
const oldName = quoteGitPath(paths === "git" ? "a/" : "", name);
|
||||
const newName = quoteGitPath(paths === "git" ? "b/" : "", name);
|
||||
// git ends a ---/+++ name that contains a space with a tab, for GNU patch.
|
||||
const tab = name.includes(" ") ? "\t" : "";
|
||||
return [
|
||||
`diff --git ${oldName} ${newName}`,
|
||||
...(change === "create" ? ["new file mode 100644"] : []),
|
||||
`--- ${change === "create" ? "/dev/null" : `${oldName}${tab}`}`,
|
||||
`+++ ${newName}${tab}`,
|
||||
];
|
||||
}
|
||||
|
||||
const GIT_PATH_ESCAPES: Record<number, string> = {
|
||||
0x07: "a",
|
||||
0x08: "b",
|
||||
0x09: "t",
|
||||
0x0a: "n",
|
||||
0x0b: "v",
|
||||
0x0c: "f",
|
||||
0x0d: "r",
|
||||
0x22: '"',
|
||||
0x5c: "\\",
|
||||
};
|
||||
|
||||
/**
|
||||
* Quotes `prefix + name` like git with the default `core.quotePath`.
|
||||
*
|
||||
* A double quote, a backslash, a control byte, or a non-ASCII byte makes git
|
||||
* put the whole name in double quotes. git then writes a C escape or a
|
||||
* three-digit octal escape for each such UTF-8 byte.
|
||||
*/
|
||||
function quoteGitPath(prefix: string, name: string): string {
|
||||
const full = `${prefix}${name}`;
|
||||
let quoted = "";
|
||||
let needsQuotes = false;
|
||||
for (const byte of Buffer.from(full, "utf8")) {
|
||||
const escape = GIT_PATH_ESCAPES[byte];
|
||||
if (escape !== undefined) {
|
||||
quoted += `\\${escape}`;
|
||||
needsQuotes = true;
|
||||
} else if (byte < 0x20 || byte >= 0x7f) {
|
||||
quoted += `\\${byte.toString(8).padStart(3, "0")}`;
|
||||
needsQuotes = true;
|
||||
} else {
|
||||
quoted += String.fromCharCode(byte);
|
||||
}
|
||||
}
|
||||
return needsQuotes ? `"${quoted}"` : full;
|
||||
}
|
||||
|
||||
function hunkHeader({ oldStart, oldLines, newStart, newLines }: PatchHunk): string {
|
||||
return `@@ -${hunkRange(oldStart, oldLines)} +${hunkRange(newStart, newLines)} @@`;
|
||||
}
|
||||
|
||||
/** A git hunk range. A side with zero lines names the line before the change. */
|
||||
function hunkRange(start: number, count: number): string {
|
||||
if (count === 0) return `${Math.max(0, start - 1)},0`;
|
||||
return count === 1 ? String(start) : `${start},${count}`;
|
||||
}
|
||||
|
||||
/** The hunk that adds every line of `text` to an empty file. */
|
||||
function wholeFileHunk(text: string): PatchHunk | undefined {
|
||||
if (text.length === 0) return undefined;
|
||||
const terminated = text.endsWith("\n");
|
||||
const lines = (terminated ? text.slice(0, -1) : text).split("\n");
|
||||
return {
|
||||
oldStart: 1,
|
||||
oldLines: 0,
|
||||
newStart: 1,
|
||||
newLines: lines.length,
|
||||
lines: [...lines.map((line) => `+${line}`), ...(terminated ? [] : [NO_NEWLINE_MARKER])],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The line-diff hunks between two texts. Returns undefined when the diff runs
|
||||
* out of its time budget.
|
||||
*/
|
||||
function diffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
|
||||
if (oldText === null) {
|
||||
const hunk = wholeFileHunk(newText);
|
||||
return hunk ? [hunk] : [];
|
||||
}
|
||||
const oldLines = textLines(oldText);
|
||||
const newLines = textLines(newText);
|
||||
// The diff gets only the changed middle and its context lines. An edit
|
||||
// usually changes a small part of a large file.
|
||||
const common = Math.min(oldLines.length, newLines.length);
|
||||
let prefix = 0;
|
||||
while (prefix < common && oldLines[prefix] === newLines[prefix]) prefix++;
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < common - prefix &&
|
||||
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
||||
) {
|
||||
suffix++;
|
||||
}
|
||||
const skipped = Math.max(0, prefix - PATCH_CONTEXT_LINES);
|
||||
const skippedEnd = Math.max(0, suffix - PATCH_CONTEXT_LINES);
|
||||
const patch = structuredPatch(
|
||||
"",
|
||||
"",
|
||||
oldLines.slice(skipped, oldLines.length - skippedEnd).join(""),
|
||||
newLines.slice(skipped, newLines.length - skippedEnd).join(""),
|
||||
"",
|
||||
"",
|
||||
{ context: PATCH_CONTEXT_LINES, timeout: PATCH_DIFF_TIMEOUT_MS },
|
||||
);
|
||||
return patch?.hunks.map((hunk) => ({
|
||||
...hunk,
|
||||
oldStart: hunk.oldStart + skipped,
|
||||
newStart: hunk.newStart + skipped,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The lines of a text, each with its line break. The last line can have none. */
|
||||
function textLines(text: string): string[] {
|
||||
return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch of a change from `oldText`, null for a missing file, to
|
||||
* `newText`. Returns undefined when nothing changed or the diff runs out of
|
||||
* its time budget.
|
||||
*/
|
||||
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
|
||||
const hunks = diffHunks(oldText, newText);
|
||||
if (!hunks || hunks.length === 0) return undefined;
|
||||
return { change: oldText === null ? "create" : "update", hunks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file as patch input.
|
||||
*
|
||||
* Returns null when the file does not exist. Returns undefined when the file
|
||||
* cannot be read, is not a regular file, is larger than
|
||||
* {@link MAX_PATCH_FILE_BYTES}, is binary, is not valid UTF-8, or contains a
|
||||
* CR.
|
||||
*/
|
||||
async function readPatchSource(filePath: string): Promise<string | null | undefined> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
// O_NONBLOCK keeps the open of a FIFO from waiting for a writer. It does not change a regular file.
|
||||
handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
return isMissingFileError(error) ? null : undefined;
|
||||
}
|
||||
try {
|
||||
// The checks and the read use one open file, so a swap of the path between them has no effect.
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.size > MAX_PATCH_FILE_BYTES) return undefined;
|
||||
// One byte more than the checked size shows a file that grew after the check.
|
||||
const buffer = Buffer.alloc(stats.size + 1);
|
||||
let length = 0;
|
||||
while (length < buffer.length) {
|
||||
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
||||
if (bytesRead === 0) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > stats.size) return undefined;
|
||||
const text = decodeFileText(buffer.subarray(0, length));
|
||||
return text?.includes("\r") ? undefined : text;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The text of a file of at most {@link MAX_PATCH_FILE_BYTES} that is not binary and is valid UTF-8. */
|
||||
function decodeFileText(bytes: Uint8Array): string | undefined {
|
||||
if (bytes.length > MAX_PATCH_FILE_BYTES) return undefined;
|
||||
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return undefined;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tool input text can be the new side of an exact patch. */
|
||||
function isPatchableText(text: string): boolean {
|
||||
return (
|
||||
Buffer.byteLength(text, "utf8") <= MAX_PATCH_FILE_BYTES &&
|
||||
!text.includes("\0") &&
|
||||
!text.includes("\r")
|
||||
);
|
||||
}
|
||||
|
||||
/** The exact patch content of a change, or undefined when it has no patch. */
|
||||
function exactPatch(
|
||||
filePath: string,
|
||||
oldText: string | null,
|
||||
newText: string,
|
||||
form: PatchForm,
|
||||
): ToolCallContent[] | undefined {
|
||||
const patch = filePatch(oldText, newText);
|
||||
return patch && [patchContent(filePath, patch, form)];
|
||||
}
|
||||
|
||||
/** The content that carries a patch, in the given form. */
|
||||
function patchContent(filePath: string, patch: FilePatch, form: PatchForm): ToolCallContent {
|
||||
return form === "v2"
|
||||
? v2DiffContent(filePath, patch.change, patch.hunks)
|
||||
: airPatchContent(filePath, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* A diff block in the AIR patch form.
|
||||
*
|
||||
* `oldText: null` and `newText: ""` only satisfy the ACP schema. The adapter
|
||||
* sends this form only to a client that advertised `diffPatch`.
|
||||
*/
|
||||
function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCallContent {
|
||||
return {
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText: null,
|
||||
newText: "",
|
||||
_meta: withAirMeta(undefined, AIR_DIFF_PATCH_CAPABILITY, {
|
||||
version: 1,
|
||||
format: "git_patch",
|
||||
text: gitPatchText(filePath, change, hunks),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a change to the text file `filePath`: the change and,
|
||||
* given hunks, its git patch.
|
||||
*
|
||||
* The agent builds tool call content in v1 types, and v1 cannot express a v2
|
||||
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
|
||||
* diff, and only a v2 client gets one: the v2 surface sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
export function v2DiffContent(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks?: PatchHunk[],
|
||||
): ToolCallContent {
|
||||
const fileChange: v2.DiffChange =
|
||||
change === "create"
|
||||
? { operation: "add", path: filePath, fileType: "text" }
|
||||
: { operation: "modify", path: filePath, fileType: "text" };
|
||||
const diff: V2DiffContent = {
|
||||
type: "diff",
|
||||
changes: [fileChange],
|
||||
...(hunks?.length
|
||||
? { patch: { format: "git_patch", text: gitPatchText(filePath, change, hunks, "absolute") } }
|
||||
: {}),
|
||||
};
|
||||
return diff as unknown as ToolCallContent;
|
||||
}
|
||||
|
||||
function resolveToolPath(filePath: string, cwd?: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd ?? process.cwd(), filePath);
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
@@ -148,14 +153,6 @@ 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.
|
||||
*
|
||||
@@ -168,14 +165,17 @@ const CUSTOM_ANSWER_META_KEY = "_askUserQuestionCustomAnswer";
|
||||
*
|
||||
* 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[],
|
||||
sessionId: string,
|
||||
toolCallId: string | undefined,
|
||||
airClient = false,
|
||||
): CreateElicitationRequest {
|
||||
const single = questions.length === 1;
|
||||
const properties: Record<string, ElicitationPropertySchema> = {};
|
||||
@@ -208,16 +208,25 @@ export function askUserQuestionsToCreateRequest(
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
|
||||
const customAnswer = { questionId: questionFieldKey(index), isCustomAnswer: true };
|
||||
properties[questionCustomFieldKey(index)] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
description: "Type your own answer instead of choosing an option above (optional).",
|
||||
_meta: {
|
||||
[CUSTOM_ANSWER_META_KEY]: {
|
||||
questionId: questionFieldKey(index),
|
||||
isCustomAnswer: true,
|
||||
},
|
||||
},
|
||||
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).",
|
||||
// Marks the field as the custom answer companion of a select question,
|
||||
// under `_meta.jetbrains.air.customAnswer` and under the root key that
|
||||
// released AIR versions read. Only AIR gets the marker.
|
||||
...(airClient
|
||||
? {
|
||||
_meta: withAirMeta(
|
||||
{ [LEGACY_AIR_CUSTOM_ANSWER_KEY]: customAnswer },
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
customAnswer,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -241,17 +250,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,
|
||||
@@ -270,27 +298,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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -111,6 +111,21 @@ export function executionDiagnostic(message: SDKResultMessage): string | undefin
|
||||
return message.errors.find((error) => error.startsWith("[ede_diagnostic]"));
|
||||
}
|
||||
|
||||
/** Whether `message` is the error-shaped diagnostic Claude emits for the cycle
|
||||
* a recorded ExitPlanMode `interrupt: true` answer stopped. */
|
||||
export function isExitPlanInterruptionResult(
|
||||
message: SDKResultMessage,
|
||||
pending: ExitPlanState["pendingExitPlanModeInterruption"],
|
||||
): boolean {
|
||||
if (!pending?.toolResultSeen || !message.is_error) return false;
|
||||
const diagnostic = executionDiagnostic(message);
|
||||
return (
|
||||
!!diagnostic &&
|
||||
/(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) &&
|
||||
/(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(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 {
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
import {
|
||||
createSdkMcpServer,
|
||||
type HookCallback,
|
||||
type McpSdkServerConfigWithInstance,
|
||||
type SdkMcpToolDefinition,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Query, RewindFilesResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { airExtensionMeta, clientSupportsAirCapability, withAirMeta } from "./air-extension.js";
|
||||
|
||||
export const AGENT_FILE_CHANGE_REPORT_CAPABILITY = "agentFileChangeReport";
|
||||
export const FILE_CHANGE_AUDIT_SERVER_NAME = "claude_agent_acp";
|
||||
export const FILE_CHANGE_AUDIT_TOOL_NAME = "report_changed_files";
|
||||
export const FILE_CHANGE_AUDIT_WIRE_TOOL_NAME = `mcp__${FILE_CHANGE_AUDIT_SERVER_NAME}__${FILE_CHANGE_AUDIT_TOOL_NAME}`;
|
||||
|
||||
const FILE_CHANGE_AUDIT_MARKER = "claude-agent-acp-file-change-audit";
|
||||
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 FileChangeAuditTurnState = {
|
||||
export type FileChangeReportTurnState = {
|
||||
requestId: string;
|
||||
phase: "requested" | "collecting" | "finished";
|
||||
};
|
||||
|
||||
export type FileChangeAuditWorkspace = {
|
||||
cwd: string;
|
||||
additionalDirectories: string[];
|
||||
export type NativeFileChangeReportTurn = {
|
||||
promptUuid: string;
|
||||
fileChangeReport?: FileChangeReportTurnState;
|
||||
};
|
||||
|
||||
export type AgentFileChangeReport = {
|
||||
paths: string[];
|
||||
complete: boolean;
|
||||
uncertainty?: string;
|
||||
export type FileChangeReportWorkspace = {
|
||||
cwd: string;
|
||||
additionalDirectories: string[];
|
||||
};
|
||||
|
||||
export type AgentFileChangeReportResult = {
|
||||
@@ -45,7 +35,6 @@ export type AgentFileChangeReportResult = {
|
||||
paths: string[];
|
||||
declaredComplete: boolean;
|
||||
truncated: boolean;
|
||||
uncertainty?: string;
|
||||
}
|
||||
| {
|
||||
status: "unavailable";
|
||||
@@ -56,40 +45,24 @@ export type AgentFileChangeReportResult = {
|
||||
export type FileChangeReportUnavailableReason =
|
||||
"cancelled" | "timeout" | "invalidOutput" | "notReported" | "providerError";
|
||||
|
||||
type FileChangeAuditSupportOptions = {
|
||||
type NativeFileChangeReporterOptions = {
|
||||
cwd: string;
|
||||
additionalDirectories: string[];
|
||||
getActiveState: () => FileChangeAuditTurnState | undefined;
|
||||
publish: (result: AgentFileChangeReportResult) => Promise<void>;
|
||||
logError: (message: string) => void;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type FileChangeAuditSupport = {
|
||||
mcpServer: McpSdkServerConfigWithInstance;
|
||||
preToolUseHook: HookCallback;
|
||||
stopHook: HookCallback;
|
||||
finishUnavailable: (
|
||||
state: FileChangeAuditTurnState,
|
||||
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,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
const reportInputSchema = {
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"Workspace file paths changed during this turn. Use absolute paths or paths relative to cwd.",
|
||||
),
|
||||
complete: z
|
||||
.boolean()
|
||||
.describe("True only when paths contains every workspace file changed during this turn."),
|
||||
uncertainty: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe("Why the report may be incomplete or uncertain."),
|
||||
): void;
|
||||
};
|
||||
|
||||
export function agentFileChangeReportRequestId(meta: unknown): string | undefined {
|
||||
@@ -121,180 +94,106 @@ export function agentFileChangeReportMeta(
|
||||
return withAirMeta(undefined, AGENT_FILE_CHANGE_REPORT_CAPABILITY, result);
|
||||
}
|
||||
|
||||
export function createFileChangeAuditTurnState(requestId: string): FileChangeAuditTurnState {
|
||||
return {
|
||||
requestId,
|
||||
phase: "requested",
|
||||
};
|
||||
}
|
||||
|
||||
export function isFileChangeAuditReportPhase(state: FileChangeAuditTurnState | undefined): boolean {
|
||||
return state?.phase === "collecting" || state?.phase === "finished";
|
||||
}
|
||||
|
||||
export function isFileChangeAuditTool(toolName: string): boolean {
|
||||
return toolName === FILE_CHANGE_AUDIT_WIRE_TOOL_NAME;
|
||||
}
|
||||
|
||||
export function containsFileChangeAuditMarker(text: string): boolean {
|
||||
return text.includes(`<${FILE_CHANGE_AUDIT_MARKER}>`);
|
||||
}
|
||||
|
||||
export function createFileChangeAuditSupport(
|
||||
options: FileChangeAuditSupportOptions,
|
||||
): FileChangeAuditSupport {
|
||||
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 finishUnavailable = async (
|
||||
state: FileChangeAuditTurnState,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
) => {
|
||||
// Claim the terminal outcome before the first await. The report tool, a
|
||||
// Stop hook, cancellation, and stream teardown can race, but only the
|
||||
// winner may publish for this request id. Publication remains fail-open:
|
||||
// an audit transport failure must never hold the user prompt open.
|
||||
const publish = async (state: FileChangeReportTurnState, result: AgentFileChangeReportResult) => {
|
||||
if (state.phase === "finished") return;
|
||||
state.phase = "finished";
|
||||
try {
|
||||
await options.publish({
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "unavailable",
|
||||
reason,
|
||||
});
|
||||
await options.publish(result);
|
||||
} catch (error) {
|
||||
options.logError(`Failed to publish unavailable report ${state.requestId}: ${error}`);
|
||||
options.logError(`Failed to publish file-change report ${state.requestId}: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const tool: SdkMcpToolDefinition<typeof reportInputSchema> = {
|
||||
name: FILE_CHANGE_AUDIT_TOOL_NAME,
|
||||
description:
|
||||
"Internal audit tool. Call it only when a stop-hook message explicitly requests the file-change audit.",
|
||||
inputSchema: reportInputSchema,
|
||||
_meta: {
|
||||
"anthropic/alwaysLoad": true,
|
||||
"claude/endTurn": true,
|
||||
},
|
||||
handler: async (report) => {
|
||||
const state = options.getActiveState();
|
||||
if (!state || state.phase !== "collecting") {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<${FILE_CHANGE_AUDIT_MARKER}>No file-change audit is active.</${FILE_CHANGE_AUDIT_MARKER}>`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Flip before publishing: a client transport failure must not make the
|
||||
// model retry the report and produce duplicates. Delivery is audit-only
|
||||
// and fail-open; the normal turn must still finish.
|
||||
state.phase = "finished";
|
||||
const normalized = normalizeReportedPaths(report.paths, workspace);
|
||||
const result = fitReportedAgentFileChangeReport({
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "reported",
|
||||
paths: normalized.paths,
|
||||
declaredComplete: report.complete && !normalized.truncated,
|
||||
truncated: normalized.truncated,
|
||||
...(report.uncertainty ? { uncertainty: report.uncertainty } : {}),
|
||||
});
|
||||
try {
|
||||
await options.publish(result);
|
||||
} catch (error) {
|
||||
options.logError(`Failed to publish file-change report ${state.requestId}: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<${FILE_CHANGE_AUDIT_MARKER}>File-change audit recorded.</${FILE_CHANGE_AUDIT_MARKER}>`,
|
||||
},
|
||||
],
|
||||
structuredContent: result,
|
||||
};
|
||||
},
|
||||
const finishUnavailable = async (
|
||||
state: FileChangeReportTurnState,
|
||||
reason: FileChangeReportUnavailableReason,
|
||||
) => {
|
||||
await publish(state, {
|
||||
version: 1,
|
||||
requestId: state.requestId,
|
||||
status: "unavailable",
|
||||
reason,
|
||||
});
|
||||
};
|
||||
|
||||
// canUseTool is not guaranteed to run for tools that the current permission
|
||||
// mode auto-allows (notably bypassPermissions). A PreToolUse hook is the
|
||||
// enforcement boundary that keeps the hidden continuation read-only in every
|
||||
// mode: it can submit the report, but cannot inspect or mutate more files.
|
||||
const preToolUseHook: HookCallback = async (input) => {
|
||||
if (input.hook_event_name !== "PreToolUse") return {};
|
||||
const state = options.getActiveState();
|
||||
const isReportTool = isFileChangeAuditTool(input.tool_name);
|
||||
if (!isReportTool && !isFileChangeAuditReportPhase(state)) return {};
|
||||
|
||||
const allowReport = state?.phase === "collecting" && isReportTool;
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
permissionDecision: allowReport ? "allow" : "deny",
|
||||
...(!allowReport
|
||||
? {
|
||||
permissionDecisionReason:
|
||||
"Only the internal file-change report is allowed during the audit.",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const stopHook: HookCallback = async (input) => {
|
||||
if (input.hook_event_name !== "Stop") return {};
|
||||
const state = options.getActiveState();
|
||||
if (!state || state.phase === "finished") return {};
|
||||
// A Stop can be only a pause while a background generator is still
|
||||
// running. Keep the request pending so a later final Stop can include its
|
||||
// writes instead of declaring a prematurely complete path set.
|
||||
if (state.phase === "requested" && (input.background_tasks?.length ?? 0) > 0) return {};
|
||||
if (state.phase === "collecting" || input.stop_hook_active) {
|
||||
await finishUnavailable(state, "notReported");
|
||||
return {};
|
||||
}
|
||||
|
||||
// Set the phase before returning the continuation so every following SDK
|
||||
// message is hidden even if the next stream starts immediately.
|
||||
const report = async (
|
||||
turn: NativeFileChangeReportTurn | null | undefined,
|
||||
query: Pick<Query, "rewindFiles">,
|
||||
) => {
|
||||
const state = turn?.fileChangeReport;
|
||||
if (!state || state.phase !== "requested") return;
|
||||
state.phase = "collecting";
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "Stop",
|
||||
additionalContext:
|
||||
`<${FILE_CHANGE_AUDIT_MARKER}>` +
|
||||
`Before stopping, call ${FILE_CHANGE_AUDIT_WIRE_TOOL_NAME} exactly once. ` +
|
||||
"Report every workspace file changed during this user turn, including files changed " +
|
||||
"by commands, generators, version-control operations, and child processes. " +
|
||||
"Do not call any other tool and do not emit user-facing prose. " +
|
||||
"Set complete to false and explain uncertainty when you are not sure the list is complete." +
|
||||
`</${FILE_CHANGE_AUDIT_MARKER}>`,
|
||||
},
|
||||
};
|
||||
|
||||
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 {
|
||||
mcpServer: createSdkMcpServer({
|
||||
name: FILE_CHANGE_AUDIT_SERVER_NAME,
|
||||
version: "1.0.0",
|
||||
tools: [tool],
|
||||
alwaysLoad: true,
|
||||
}),
|
||||
preToolUseHook,
|
||||
stopHook,
|
||||
finishUnavailable,
|
||||
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[],
|
||||
): FileChangeAuditWorkspace {
|
||||
): FileChangeReportWorkspace {
|
||||
const normalizedCwd = canonicalizeWorkspaceRoot(path.resolve(cwd));
|
||||
const seen = new Set([pathKey(normalizedCwd)]);
|
||||
const normalizedAdditionalDirectories: string[] = [];
|
||||
@@ -313,7 +212,7 @@ function normalizeWorkspace(
|
||||
|
||||
function normalizeReportedPaths(
|
||||
reportedPaths: string[],
|
||||
workspace: FileChangeAuditWorkspace,
|
||||
workspace: FileChangeReportWorkspace,
|
||||
): { paths: string[]; truncated: boolean } {
|
||||
const roots = [workspace.cwd, ...workspace.additionalDirectories];
|
||||
const seen = new Set<string>();
|
||||
@@ -322,6 +221,7 @@ function normalizeReportedPaths(
|
||||
let truncated = false;
|
||||
for (const reportedPath of reportedPaths) {
|
||||
if (
|
||||
typeof reportedPath !== "string" ||
|
||||
reportedPath.trim().length === 0 ||
|
||||
reportedPath.length > MAX_REPORTED_PATH_LENGTH ||
|
||||
hasControlCharacter(reportedPath)
|
||||
@@ -349,9 +249,7 @@ function normalizeReportedPaths(
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(key)) continue;
|
||||
const pathBytes = Buffer.byteLength(normalized, "utf8");
|
||||
if (
|
||||
result.length >= MAX_REPORTED_PATHS ||
|
||||
@@ -367,7 +265,6 @@ function normalizeReportedPaths(
|
||||
return { paths: result, truncated };
|
||||
}
|
||||
|
||||
/** Keep the complete serialized report within AIR's wire limit. */
|
||||
function fitReportedAgentFileChangeReport(
|
||||
report: Extract<AgentFileChangeReportResult, { status: "reported" }>,
|
||||
): Extract<AgentFileChangeReportResult, { status: "reported" }> {
|
||||
@@ -375,8 +272,6 @@ function fitReportedAgentFileChangeReport(
|
||||
let fitted = report;
|
||||
while (Buffer.byteLength(JSON.stringify(fitted), "utf8") > AGENT_FILE_CHANGE_REPORT_MAX_BYTES) {
|
||||
if (paths.length === 0) {
|
||||
// The fixed fields and bounded uncertainty are far below the cap. Keep
|
||||
// this loud if the wire contract changes without updating this helper.
|
||||
throw new Error("Agent file-change report exceeds the wire limit without paths");
|
||||
}
|
||||
paths.pop();
|
||||
@@ -395,19 +290,10 @@ function isWithinRoot(candidate: string, root: string): boolean {
|
||||
return !path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve filesystem aliases in a workspace root, including macOS' /tmp ->
|
||||
* /private/tmp alias. For a root that does not exist yet, resolve its nearest
|
||||
* existing ancestor and retain the missing suffix.
|
||||
*/
|
||||
function canonicalizeWorkspaceRoot(value: string): string {
|
||||
return canonicalizeFromExistingAncestor(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize the parent but not the leaf itself. A changed path may be
|
||||
* deleted, or it may be a symlink whose node (rather than target) changed.
|
||||
*/
|
||||
function canonicalizeReportedPath(value: string): string {
|
||||
const parent = canonicalizeFromExistingAncestor(path.dirname(value));
|
||||
return path.resolve(parent, path.basename(value));
|
||||
@@ -417,7 +303,6 @@ 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);
|
||||
@@ -425,7 +310,6 @@ function canonicalizeFromExistingAncestor(value: string): string {
|
||||
} catch (error) {
|
||||
if (!isMissingPathError(error)) return original;
|
||||
}
|
||||
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return original;
|
||||
missingSegments.push(path.basename(current));
|
||||
|
||||
@@ -2,7 +2,13 @@ import { ForkSessionRequest, ForkSessionResponse, RequestError } from "@agentcli
|
||||
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;
|
||||
@@ -11,6 +17,8 @@ type ForkSessionMeta = {
|
||||
fork?: {
|
||||
version?: number;
|
||||
messageId?: string;
|
||||
messageFingerprint?: string;
|
||||
messageOccurrence?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -18,6 +26,7 @@ type ForkSessionMeta = {
|
||||
|
||||
type ForkSessionDependencies = {
|
||||
liveMessageIdToUuid?: ReadonlyMap<string, string>;
|
||||
logger?: { log: (...args: unknown[]) => void };
|
||||
messageIdForGrouping: (message: {
|
||||
type?: string;
|
||||
uuid?: string | null;
|
||||
@@ -25,11 +34,29 @@ type ForkSessionDependencies = {
|
||||
}) => string | undefined;
|
||||
};
|
||||
|
||||
function forkPointMessageId(meta: 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();
|
||||
return messageId || undefined;
|
||||
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[] {
|
||||
@@ -38,43 +65,171 @@ function forkPointMessageIdCandidates(messageId: string): string[] {
|
||||
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 messageId = forkPointMessageId(params._meta);
|
||||
if (!messageId) {
|
||||
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(messageId);
|
||||
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?.find((message) => dependencies.messageIdForGrouping(message) === candidateId)
|
||||
?.uuid,
|
||||
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) {
|
||||
if (!messageUuid && !fullHistoryUuid) {
|
||||
throw RequestError.invalidParams(
|
||||
{ messageId },
|
||||
`Fork point message ${messageId} was not found in session ${params.sessionId}`,
|
||||
{ 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,
|
||||
upToMessageId: messageUuid ?? fullHistoryUuid,
|
||||
});
|
||||
timing.phase("sdk-fork");
|
||||
return { sessionId: forked.sessionId };
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
|
||||
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");
|
||||
@@ -144,7 +143,7 @@ export function claudeLoginRequiredError(): RequestError {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* concurrent pair of turns shares one account read, and so
|
||||
* that each degraded-guard warning is logged once.
|
||||
*/
|
||||
export type ClaudeSubscriptionGuardState = {
|
||||
@@ -190,8 +189,6 @@ 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
|
||||
@@ -219,8 +216,7 @@ type GuardOptions = {
|
||||
* 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.
|
||||
* flow. The guard sends no separate access message.
|
||||
*
|
||||
* Fails open when no account can be read at all: session creation already
|
||||
* refused every account without a usable credential, and an unavailable probe
|
||||
@@ -241,8 +237,8 @@ export async function refuseClaudeSubscriptionTurn(options: GuardOptions): Promi
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** Read the account and return the error for the refused turn.
|
||||
* Never throws: an unreadable account fails open. */
|
||||
async function evaluateGuard(options: GuardOptions): Promise<RequestError | undefined> {
|
||||
const account = await readGuardAccount(options);
|
||||
if (!account) {
|
||||
@@ -259,14 +255,9 @@ async function evaluateGuard(options: GuardOptions): Promise<RequestError | unde
|
||||
);
|
||||
}
|
||||
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;
|
||||
@@ -293,19 +284,3 @@ async function readGuardAccount(options: GuardOptions): Promise<AccountInfo | un
|
||||
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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
25
src/index.ts
25
src/index.ts
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
import { claudeCliPath } from "./acp-agent.js";
|
||||
import { applyManagedPolicyEnv } from "./managed-policy.js";
|
||||
import { serveAcp } from "./serve.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -44,13 +45,10 @@ if (process.argv.includes("--cli")) {
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Apply env vars from the managed-policy tier before any SDK call so the
|
||||
// SDK subprocess inherits them. Going through resolveSettings (vs. a raw
|
||||
// read of managed-settings.json) also picks up MDM sources on macOS and
|
||||
// HKLM/HKCU on Windows.
|
||||
const policy = await resolveSettings({ settingSources: [] });
|
||||
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
// SDK subprocess inherits them. Reading the tier is best-effort: a transient
|
||||
// failure leaves the agent running with no policy env instead of aborting
|
||||
// module evaluation here, before any ACP traffic.
|
||||
await applyManagedPolicyEnv();
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
|
||||
@@ -85,10 +83,13 @@ if (process.argv.includes("--cli")) {
|
||||
: undefined;
|
||||
logger?.log("Claude ACP started");
|
||||
|
||||
const { connection, agent } = runAcp(logger);
|
||||
// The draft ACP v2 is opt-in while it is a draft.
|
||||
const acp = serveAcp(logger, {
|
||||
experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1",
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
await acp.dispose().catch((err) => {
|
||||
console.error("Error during cleanup:", err);
|
||||
});
|
||||
process.exit(0);
|
||||
@@ -97,7 +98,7 @@ if (process.argv.includes("--cli")) {
|
||||
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
|
||||
// error). Without this, `process.stdin.resume()` keeps the event loop
|
||||
// alive indefinitely, causing orphan process accumulation in oneshot mode.
|
||||
connection.closed.then(shutdown);
|
||||
acp.closed.then(shutdown);
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
34
src/managed-policy.ts
Normal file
34
src/managed-policy.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/**
|
||||
* Applies the managed-policy tier's env vars to `process.env` before any SDK
|
||||
* call, so the SDK subprocess inherits them. Going through `resolveSettings`
|
||||
* rather than a raw read of `managed-settings.json` also picks up MDM sources on
|
||||
* macOS and `HKLM`/`HKCU` on Windows.
|
||||
*
|
||||
* Reading the tier is best-effort by nature: on a host with no MDM and no
|
||||
* managed policy the result is empty and the agent behaves identically. So a
|
||||
* transient failure to read it — `EINTR`, `EMFILE`, `EAGAIN` — must leave the
|
||||
* agent running with no policy env rather than stop it from starting.
|
||||
*
|
||||
* That guard matters because the caller awaits this at module scope in an ESM
|
||||
* entry point, where a rejection aborts module evaluation and the process exits
|
||||
* before serving any ACP traffic. `SettingsManager.loadAllSettings` wraps the
|
||||
* same SDK call for the same reason.
|
||||
*/
|
||||
export async function applyManagedPolicyEnv(
|
||||
resolve: typeof resolveSettings = resolveSettings,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): Promise<void> {
|
||||
let policy: Awaited<ReturnType<typeof resolveSettings>>;
|
||||
try {
|
||||
policy = await resolve({ settingSources: [] });
|
||||
} catch (error) {
|
||||
// stderr, so the ACP stream on stdout stays clean.
|
||||
console.error("Failed to resolve managed policy settings:", error);
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
388
src/mcp-command.ts
Normal file
388
src/mcp-command.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import type { AvailableCommand } from "@agentclientprotocol/sdk";
|
||||
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { escapeMarkdown } from "./usage-markdown.js";
|
||||
import { raceTimeoutAndAbort } from "./utils.js";
|
||||
|
||||
/** The `/mcp` entry of `available_commands_update`. The adapter shows the
|
||||
* servers as a list, and runs a reconnect, an enable, or a disable through
|
||||
* the SDK control API. */
|
||||
export const MCP_AVAILABLE_COMMAND: AvailableCommand = {
|
||||
name: "mcp",
|
||||
description: "Show the MCP servers and their status, or reconnect, enable, or disable a server",
|
||||
input: { hint: "[reconnect|enable|disable [<server>|all]]" },
|
||||
};
|
||||
|
||||
/** The time that the status read after a `/mcp` command can take. */
|
||||
const MCP_STATUS_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The time that one reconnect, enable, or disable can take. */
|
||||
const MCP_ACTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** A `/mcp` prompt that gets a replacement for its Claude Code text. `all` is
|
||||
* true for `all` and for a reconnect without a server name, as in Claude
|
||||
* Code. `server` is undefined for an enable or a disable without a name. */
|
||||
export type McpCommand =
|
||||
| { action: "status" }
|
||||
| { action: "reconnect" | "enable" | "disable"; all: boolean; server?: string };
|
||||
|
||||
/** Parse a prompt that is exactly `/mcp`, or `/mcp reconnect`, `/mcp enable`,
|
||||
* or `/mcp disable` with an optional server name or `all`. Any other `/mcp`
|
||||
* argument gives null, and the prompt gets no replacement. */
|
||||
export function parseMcpCommand(text: string): McpCommand | null {
|
||||
const words = text.trim().split(/\s+/);
|
||||
if (words[0] !== "/mcp") return null;
|
||||
if (words.length === 1) return { action: "status" };
|
||||
const action = words[1]?.toLowerCase();
|
||||
if (action !== "reconnect" && action !== "enable" && action !== "disable") return null;
|
||||
const server = words.slice(2).join(" ");
|
||||
if (server === "all" || (server === "" && action === "reconnect")) {
|
||||
return { action, all: true };
|
||||
}
|
||||
return server === "" ? { action, all: false } : { action, all: false, server };
|
||||
}
|
||||
|
||||
/** Read the MCP server status for the list after a `/mcp` command. Resolves
|
||||
* to null when the read fails, takes too long, or `signal` aborts. Then the
|
||||
* turn keeps the text of Claude Code. */
|
||||
export async function readMcpServerStatus(
|
||||
query: Pick<Query, "mcpServerStatus">,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpServerStatus[] | null> {
|
||||
if (signal.aborted) return null;
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(
|
||||
query.mcpServerStatus(),
|
||||
MCP_STATUS_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
if (outcome.type === "timeout") {
|
||||
logError("The MCP server status read timed out; keeping the Claude Code text of /mcp");
|
||||
}
|
||||
return outcome.type === "done" ? outcome.value : null;
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
logError(`The MCP server status read failed; keeping the Claude Code text of /mcp: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The known statuses in the order of the groups and of the summary counts.
|
||||
* A disabled server is not a group. It is on one line at the end. */
|
||||
const GROUP_ORDER: McpServerStatus["status"][] = ["failed", "needs-auth", "pending", "connected"];
|
||||
|
||||
const GROUP_TITLES: Partial<Record<McpServerStatus["status"], string>> = {
|
||||
failed: "Failed",
|
||||
"needs-auth": "Needs authentication",
|
||||
pending: "Connecting",
|
||||
connected: "Connected",
|
||||
};
|
||||
|
||||
const SUMMARY_LABELS: Record<McpServerStatus["status"], string> = {
|
||||
connected: "connected",
|
||||
failed: "failed",
|
||||
"needs-auth": "need authentication",
|
||||
pending: "connecting",
|
||||
disabled: "disabled",
|
||||
};
|
||||
|
||||
const KNOWN_STATUSES: string[] = [...GROUP_ORDER, "disabled"];
|
||||
|
||||
const MAX_ERROR_LENGTH = 160;
|
||||
|
||||
/** The longest segment that can be a wrapper. A longer segment is content. */
|
||||
const MAX_WRAPPER_LENGTH = 60;
|
||||
|
||||
/** True for a server that a `/mcp reconnect` without a name must retry. */
|
||||
function needsReconnect(status: McpServerStatus): boolean {
|
||||
return (
|
||||
status.status === "failed" || status.status === "pending" || status.status === "needs-auth"
|
||||
);
|
||||
}
|
||||
|
||||
/** The text as a Markdown code span. The fence is longer than the longest
|
||||
* backtick run in the text, so the span shows the text unchanged. The user
|
||||
* can copy a server name from it into `/mcp reconnect`. */
|
||||
function codeSpan(text: string): string {
|
||||
const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
||||
const fence = "`".repeat(longestRun + 1);
|
||||
// CommonMark strips one space from each end of a span, so a pad keeps the text unchanged.
|
||||
const pad = text.startsWith("`") || text.endsWith("`") || /^ .*[^ ].* $/.test(text) ? " " : "";
|
||||
return `${fence}${pad}${text}${pad}${fence}`;
|
||||
}
|
||||
|
||||
function isErrorCode(segment: string): boolean {
|
||||
return /^-?\d+$/.test(segment);
|
||||
}
|
||||
|
||||
/** True for a segment that only wraps the real error, such as
|
||||
* `MCP startup failed`, `McpError`, `MCP error -32001`, an error code, or a
|
||||
* Rust or TypeScript type path. */
|
||||
function isWrapperSegment(segment: string): boolean {
|
||||
return (
|
||||
isErrorCode(segment) ||
|
||||
segment.includes("::") ||
|
||||
(segment.length <= MAX_WRAPPER_LENGTH &&
|
||||
!/[.!?]/.test(segment) &&
|
||||
/(failed|error|exception)(\s+-?\d+)?$/i.test(segment))
|
||||
);
|
||||
}
|
||||
|
||||
/** Cut the text to `MAX_ERROR_LENGTH` code points, so a surrogate pair stays whole. */
|
||||
function truncate(text: string): string {
|
||||
const chars = Array.from(text);
|
||||
if (chars.length <= MAX_ERROR_LENGTH) return text;
|
||||
const cut = chars.slice(0, MAX_ERROR_LENGTH - 1).join("");
|
||||
return `${cut.trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** Make an MCP error short and readable for one list item. The result has
|
||||
* no leading wrapper segments, at most two sentences, and at most
|
||||
* `MAX_ERROR_LENGTH` code points. A wrapper before an error code stays, so
|
||||
* an error code is never alone. The result is empty for an error with only
|
||||
* whitespace. */
|
||||
export function cleanMcpError(error: string): string {
|
||||
const segments = error.replace(/\s+/g, " ").trim().split(": ");
|
||||
let start = 0;
|
||||
while (
|
||||
start < segments.length - 1 &&
|
||||
isWrapperSegment(segments[start]!) &&
|
||||
!segments.slice(start + 1).every(isErrorCode)
|
||||
) {
|
||||
start++;
|
||||
}
|
||||
const content = segments.slice(start).join(": ");
|
||||
return truncate(
|
||||
content
|
||||
.split(/(?<=[.!?]) /)
|
||||
.slice(0, 2)
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
function toolCount(status: McpServerStatus): string | undefined {
|
||||
if (!status.tools) return undefined;
|
||||
return status.tools.length === 1 ? "1 tool" : `${status.tools.length} tools`;
|
||||
}
|
||||
|
||||
/** The server name as inline code. Claude Code keys the servers by name, so
|
||||
* two servers have the same name only from different scopes. Then the
|
||||
* scope tells them apart. */
|
||||
function serverName(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const origin = status.scope ?? status.source;
|
||||
return duplicates.has(status.name) && origin
|
||||
? `${codeSpan(status.name)} (${escapeMarkdown(origin)})`
|
||||
: codeSpan(status.name);
|
||||
}
|
||||
|
||||
function serverItem(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const error = status.error ? cleanMcpError(status.error) : "";
|
||||
const details = [error === "" ? undefined : escapeMarkdown(error), toolCount(status)]
|
||||
.filter((detail) => detail !== undefined)
|
||||
.join("; ");
|
||||
const name = serverName(status, duplicates);
|
||||
return details === "" ? `- ${name}` : `- ${name}: ${details}`;
|
||||
}
|
||||
|
||||
function summaryLine(statuses: McpServerStatus[]): string {
|
||||
// The counts follow the group order. An unknown status also counts, so the counts add up to the total.
|
||||
const counts = [...GROUP_ORDER, ...unknownStatuses(statuses), "disabled"]
|
||||
.map((state) => ({
|
||||
state,
|
||||
count: statuses.filter((status) => status.status === state).length,
|
||||
}))
|
||||
.filter(({ count }) => count > 0);
|
||||
const parts = counts.map(({ state, count }) =>
|
||||
count === 1 && state === "needs-auth"
|
||||
? "1 needs authentication"
|
||||
: `${count} ${SUMMARY_LABELS[state as McpServerStatus["status"]] ?? escapeMarkdown(state)}`,
|
||||
);
|
||||
return `**MCP servers:** ${statuses.length} (${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
/** The statuses that this adapter does not know, in the SDK order. The SDK
|
||||
* can report a new status before the adapter knows it. */
|
||||
function unknownStatuses(statuses: McpServerStatus[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
statuses
|
||||
.map((status) => status.status as string)
|
||||
.filter((state) => !KNOWN_STATUSES.includes(state)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** The group title of a status that the adapter does not know. It starts with a capital letter, as a known title does. */
|
||||
function unknownTitle(state: string): string {
|
||||
return escapeMarkdown(state.charAt(0).toUpperCase() + state.slice(1));
|
||||
}
|
||||
|
||||
/** Markdown for `/mcp`: a summary line, one group of servers for each
|
||||
* status, and the disabled servers on one line. A list reads better than a
|
||||
* table in a narrow chat. */
|
||||
export function formatMcpStatus(statuses: McpServerStatus[]): string {
|
||||
if (statuses.length === 0) return "No MCP servers are configured.";
|
||||
const blocks: string[] = [];
|
||||
const names = statuses.map((status) => status.name);
|
||||
const duplicates = new Set(names.filter((name, index) => names.indexOf(name) !== index));
|
||||
blocks.push(summaryLine(statuses));
|
||||
const groups: [string, string][] = [
|
||||
...GROUP_ORDER.map((state): [string, string] => [state, GROUP_TITLES[state]!]),
|
||||
...unknownStatuses(statuses).map((state): [string, string] => [state, unknownTitle(state)]),
|
||||
];
|
||||
for (const [state, title] of groups) {
|
||||
const members = statuses.filter((status) => status.status === state);
|
||||
if (members.length === 0) continue;
|
||||
blocks.push(
|
||||
[`**${title}**`, ...members.map((status) => serverItem(status, duplicates))].join("\n"),
|
||||
);
|
||||
}
|
||||
const disabled = statuses.filter((status) => status.status === "disabled");
|
||||
if (disabled.length > 0) {
|
||||
blocks.push(
|
||||
`**Disabled:** ${disabled.map((status) => serverName(status, duplicates)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (statuses.some(needsReconnect)) {
|
||||
blocks.push(
|
||||
"Run `/mcp reconnect <server>` to reconnect one server, or `/mcp reconnect` to reconnect every server that is not connected and not disabled.",
|
||||
);
|
||||
}
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
/** The SDK query methods that `/mcp` uses. */
|
||||
export type McpControlQuery = Pick<
|
||||
Query,
|
||||
"mcpServerStatus" | "reconnectMcpServer" | "toggleMcpServer"
|
||||
>;
|
||||
|
||||
/** The result of {@link runMcpCommand}. `markdown` is null when the turn
|
||||
* keeps the text of Claude Code. `reconnected` holds the servers that the
|
||||
* adapter tried to reconnect. */
|
||||
export type McpCommandOutcome = { markdown: string | null; reconnected: string[] };
|
||||
|
||||
type CallResult = { type: "done" } | { type: "failed"; error: string } | { type: "aborted" };
|
||||
|
||||
/** Wait for one control call. The wait stops when `signal` aborts or the
|
||||
* call takes more than {@link MCP_ACTION_TIMEOUT_MS}. */
|
||||
async function awaitCall(call: () => Promise<void>, signal: AbortSignal): Promise<CallResult> {
|
||||
if (signal.aborted) return { type: "aborted" };
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(call(), MCP_ACTION_TIMEOUT_MS, signal);
|
||||
if (outcome.type === "timeout") return { type: "failed", error: "The request timed out." };
|
||||
return outcome.type === "done" ? { type: "done" } : outcome;
|
||||
} catch (error) {
|
||||
return signal.aborted
|
||||
? { type: "aborted" }
|
||||
: { type: "failed", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_DONE = { reconnect: "Reconnected", enable: "Enabled", disable: "Disabled" };
|
||||
|
||||
/** The Claude Code sentence that refuses an action in SDK mode. */
|
||||
const UNAVAILABLE_SENTENCE = /aren't available in this session/i;
|
||||
|
||||
/** A Claude Code sentence that points to a terminal, which an ACP client
|
||||
* does not have. */
|
||||
function isTerminalSentence(sentence: string): boolean {
|
||||
return /in the terminal/i.test(sentence) || sentence.includes("`/mcp reconnect all` here");
|
||||
}
|
||||
|
||||
function sentences(text: string): string[] {
|
||||
return text
|
||||
.trim()
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.filter((sentence) => sentence !== "");
|
||||
}
|
||||
|
||||
/** The servers that the action changes. A named server is always a target,
|
||||
* so the control API reports its own error. */
|
||||
function actionTargets(
|
||||
command: Extract<McpCommand, { all: boolean }>,
|
||||
statuses: McpServerStatus[],
|
||||
): string[] {
|
||||
if (!command.all) return command.server === undefined ? [] : [command.server];
|
||||
const names = statuses
|
||||
.filter((status) => {
|
||||
if (command.action === "reconnect") return needsReconnect(status);
|
||||
if (command.action === "enable") return status.status === "disabled";
|
||||
return status.status !== "disabled";
|
||||
})
|
||||
.map((status) => status.name);
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
function nothingToDoLine(action: McpCommand["action"]): string {
|
||||
if (action === "enable") return "Every MCP server is already enabled.";
|
||||
if (action === "disable") return "Every MCP server is already disabled.";
|
||||
return "Every MCP server is connected or disabled. There is nothing to reconnect.";
|
||||
}
|
||||
|
||||
/** Run a `/mcp` command after Claude Code sent `originalOutput`, its text for
|
||||
* the command. Claude Code refuses a reconnect, an enable, and a disable in
|
||||
* SDK mode, so the adapter runs them through the SDK control API. The result
|
||||
* of each server goes above the server list. An error of one server does
|
||||
* not stop the others. When the first status read fails or `signal`
|
||||
* aborts, the turn keeps the text of Claude Code. */
|
||||
export async function runMcpCommand(
|
||||
query: McpControlQuery,
|
||||
command: McpCommand,
|
||||
originalOutput: string,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpCommandOutcome> {
|
||||
const reconnected: string[] = [];
|
||||
const keep: McpCommandOutcome = { markdown: null, reconnected };
|
||||
if (command.action === "status") {
|
||||
// The list replaces the Claude Code summary line.
|
||||
const statuses = await readMcpServerStatus(query, signal, logError);
|
||||
return { markdown: statuses ? formatMcpStatus(statuses) : null, reconnected };
|
||||
}
|
||||
const before = await readMcpServerStatus(query, signal, logError);
|
||||
if (!before) return keep;
|
||||
|
||||
const cliSentences = sentences(originalOutput).filter(
|
||||
(sentence) => !isTerminalSentence(sentence),
|
||||
);
|
||||
const cliNamesUnknown = cliSentences.some((sentence) => /no MCP server named/i.test(sentence));
|
||||
const results: string[] = [];
|
||||
if (!command.all && command.server === undefined) {
|
||||
results.push(`Name a server or \`all\`, for example \`/mcp ${command.action} <server>\`.`);
|
||||
}
|
||||
const known = new Set(before.map((status) => status.name));
|
||||
const targets = actionTargets(command, before);
|
||||
if (command.all && targets.length === 0) results.push(nothingToDoLine(command.action));
|
||||
for (const server of targets) {
|
||||
if (!known.has(server)) {
|
||||
// Claude Code checks the name first. Its sentence then stays.
|
||||
if (!cliNamesUnknown) results.push(`There's no MCP server named ${codeSpan(server)}.`);
|
||||
continue;
|
||||
}
|
||||
if (command.action === "reconnect") reconnected.push(server);
|
||||
const call =
|
||||
command.action === "reconnect"
|
||||
? () => query.reconnectMcpServer(server)
|
||||
: () => query.toggleMcpServer(server, command.action === "enable");
|
||||
const result = await awaitCall(call, signal);
|
||||
if (result.type === "aborted") return keep;
|
||||
if (result.type === "done") {
|
||||
results.push(`- ${ACTION_DONE[command.action]} ${codeSpan(server)}`);
|
||||
} else {
|
||||
const error = cleanMcpError(result.error);
|
||||
const failure = `- Couldn't ${command.action} ${codeSpan(server)}`;
|
||||
results.push(error === "" ? failure : `${failure}: ${escapeMarkdown(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
const kept = cliSentences.filter((sentence) => !UNAVAILABLE_SENTENCE.test(sentence));
|
||||
if (kept.length > 0) blocks.push(kept.join(" "));
|
||||
if (results.length > 0) blocks.push(results.join("\n"));
|
||||
const after = await readMcpServerStatus(query, signal, logError);
|
||||
if (signal.aborted) return keep;
|
||||
if (after) blocks.push(formatMcpStatus(after));
|
||||
return { markdown: blocks.join("\n\n"), reconnected };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
|
||||
export type NativeSubagent = {
|
||||
sessionId: string;
|
||||
@@ -6,6 +7,11 @@ export type NativeSubagent = {
|
||||
parentToolUseId?: string;
|
||||
name: string;
|
||||
task: string;
|
||||
/**
|
||||
* The exact prompt of this generation, sent as `prompt` in
|
||||
* `subagent_spawned`. It is absent when the adapter has no prompt.
|
||||
*/
|
||||
prompt?: string;
|
||||
announced?: boolean;
|
||||
terminalState?: SubagentState;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
@@ -41,6 +47,8 @@ type SubagentIdentity = {
|
||||
const MAX_PENDING_PARENTS = 64;
|
||||
const MAX_PENDING_UPDATES = 256;
|
||||
const MAX_PENDING_UPDATES_PER_PARENT = 32;
|
||||
/** The number of child tool calls whose owning child session the runtime remembers. */
|
||||
const MAX_CHILD_TOOL_CALLS = 2048;
|
||||
|
||||
/**
|
||||
* Owns the connection-local native subagent registry and all ACP lifecycle
|
||||
@@ -55,6 +63,12 @@ export class NativeSubagentRuntime {
|
||||
private readonly identityByToolUse = new Map<string, SubagentIdentity>();
|
||||
private readonly controlByToolUse = new Map<string, AcpSessionNotification>();
|
||||
private readonly childByParentToolUse = new Map<string, NativeSubagent>();
|
||||
/**
|
||||
* The child session of each tool call that went to a child session. A later
|
||||
* update of that tool call can lose `parentToolUseId`, for example a progress
|
||||
* beat after the child finished. It still belongs to the child session.
|
||||
*/
|
||||
private readonly childByToolCall = 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[]>();
|
||||
@@ -85,7 +99,7 @@ export class NativeSubagentRuntime {
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const { update } = notification;
|
||||
const claudeMeta = update._meta?.claudeCode as
|
||||
{ parentToolUseId?: string | null; subagent?: true; toolName?: string } | undefined;
|
||||
{ parentToolUseId?: string | null; toolName?: string } | undefined;
|
||||
const isControl = isNativeSubagentControlUpdate(update);
|
||||
|
||||
if (!this.enabled) return notification;
|
||||
@@ -126,11 +140,23 @@ export class NativeSubagentRuntime {
|
||||
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update"
|
||||
? update.toolCallId
|
||||
: undefined;
|
||||
|
||||
// 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 (forcedSessionId) {
|
||||
const forcedChild = this.childOfSession(forcedSessionId);
|
||||
if (toolCallId && forcedChild) this.rememberToolCallOwner(toolCallId, forcedChild);
|
||||
return { ...notification, sessionId: forcedSessionId };
|
||||
}
|
||||
|
||||
const owner = toolCallId ? this.childByToolCall.get(toolCallId) : undefined;
|
||||
if (owner) return this.toChild(owner, notification, toolCallId);
|
||||
|
||||
if (this.enabled && claudeMeta?.parentToolUseId) {
|
||||
const child = this.childByParentToolUse.get(claudeMeta.parentToolUseId);
|
||||
@@ -138,13 +164,7 @@ export class NativeSubagentRuntime {
|
||||
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 this.toChild(child, notification, toolCallId);
|
||||
}
|
||||
|
||||
return notification;
|
||||
@@ -162,40 +182,64 @@ export class NativeSubagentRuntime {
|
||||
const previous = this.children.get(task.taskId);
|
||||
if (previous && previous.terminalState === undefined) return;
|
||||
|
||||
const knownParentSessionId = task.toolUseId
|
||||
? this.parentByToolUse.get(task.toolUseId)
|
||||
: undefined;
|
||||
// A SendMessage resume reuses the finished generation's tool id, whose
|
||||
// control state is already cleaned up.
|
||||
const knownParentSessionId =
|
||||
(task.toolUseId ? this.parentByToolUse.get(task.toolUseId) : undefined) ??
|
||||
(previous && this.resumedParentSessionId(previous));
|
||||
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);
|
||||
}
|
||||
}
|
||||
await this.openGeneration(
|
||||
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,
|
||||
),
|
||||
...promptField(promptText(task.prompt) ?? identity?.prompt),
|
||||
},
|
||||
!!knownParentSessionId || !task.toolUseId,
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a new generation of a finished child when the SDK resumes the same
|
||||
* agent id. The SDK can resume a child without a new `task_started`, so a
|
||||
* running `task_updated` patch or a SendMessage `resumedAgentId` is the
|
||||
* signal. A child that did not finish is not changed. The `prompt` is the
|
||||
* SendMessage text that resumed the child, when the adapter knows it.
|
||||
*/
|
||||
async taskResumed(taskId: string, deliver: Publish, prompt?: string): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const previous = this.children.get(taskId);
|
||||
if (!previous) return;
|
||||
const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
|
||||
if (finishing) await finishing.catch(() => {});
|
||||
if (this.children.get(taskId) !== previous || previous.terminalState === undefined) return;
|
||||
await this.openGeneration(
|
||||
taskId,
|
||||
previous,
|
||||
{
|
||||
parentSessionId: this.resumedParentSessionId(previous),
|
||||
parentToolUseId: previous.parentToolUseId,
|
||||
name: previous.name,
|
||||
task: previous.task,
|
||||
...promptField(promptText(prompt)),
|
||||
},
|
||||
true,
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
|
||||
async finishTask(
|
||||
@@ -258,8 +302,74 @@ export class NativeSubagentRuntime {
|
||||
this.takePending(parentToolUseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an update to the child session. An update of a finished child is
|
||||
* dropped: it never goes to the root session.
|
||||
*/
|
||||
private toChild(
|
||||
child: NativeSubagent,
|
||||
notification: AcpSessionNotification,
|
||||
toolCallId: string | undefined,
|
||||
): AcpSessionNotification | null {
|
||||
if (child.terminalState !== undefined || child.terminalPromise) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (toolCallId) this.rememberToolCallOwner(toolCallId, child);
|
||||
return { ...notification, sessionId: child.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* The route of the work that a child tool call started, for example an async
|
||||
* task. The route sends each update to the child generation that owned the
|
||||
* tool call when the work started. An async task can outlive that child: a
|
||||
* subagent can start a background command and end its turn. So the route
|
||||
* still sends `async_task_spawned`, `async_task_progress` and
|
||||
* `async_task_state_update` to the child generation after it finished. The route drops every other update
|
||||
* after the child finished. `undefined` means that the root session owns the
|
||||
* tool call. `eagerSessionId` is the session where a permission request
|
||||
* created the tool call before the stream routed it.
|
||||
*/
|
||||
routeOfToolCall(
|
||||
toolCallId: string,
|
||||
eagerSessionId?: string,
|
||||
): ((notification: AcpSessionNotification) => AcpSessionNotification | null) | undefined {
|
||||
if (!this.enabled) return undefined;
|
||||
const owner =
|
||||
this.childByToolCall.get(toolCallId) ??
|
||||
(eagerSessionId ? this.childOfSession(eagerSessionId) : undefined);
|
||||
return (
|
||||
owner &&
|
||||
((notification) =>
|
||||
isLateAsyncTaskUpdate(notification)
|
||||
? { ...notification, sessionId: owner.sessionId }
|
||||
: this.toChild(owner, notification, undefined))
|
||||
);
|
||||
}
|
||||
|
||||
private rememberToolCallOwner(toolCallId: string, child: NativeSubagent): void {
|
||||
this.childByToolCall.delete(toolCallId);
|
||||
this.childByToolCall.set(toolCallId, child);
|
||||
if (this.childByToolCall.size > MAX_CHILD_TOOL_CALLS) {
|
||||
const oldest = this.childByToolCall.keys().next().value;
|
||||
if (oldest !== undefined) this.childByToolCall.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** The child generation with the ACP session `sessionId`, if one exists. */
|
||||
private childOfSession(sessionId: string): NativeSubagent | undefined {
|
||||
if (sessionId === this.rootSessionId) return undefined;
|
||||
for (const child of this.children.values()) {
|
||||
if (child.sessionId === sessionId) return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.children.clear();
|
||||
this.childByToolCall.clear();
|
||||
this.taskByToolUse.clear();
|
||||
this.parentByToolUse.clear();
|
||||
this.identityByToolUse.clear();
|
||||
@@ -303,6 +413,52 @@ export class NativeSubagentRuntime {
|
||||
this.parentByToolUse.delete(toolUseId);
|
||||
}
|
||||
|
||||
/** The parent of a resumed generation: the old parent while it is live, else the root. */
|
||||
private resumedParentSessionId(previous: NativeSubagent): string {
|
||||
return this.isLiveSession(previous.parentSessionId)
|
||||
? previous.parentSessionId
|
||||
: this.rootSessionId;
|
||||
}
|
||||
|
||||
private isLiveSession(sessionId: string): boolean {
|
||||
if (sessionId === this.rootSessionId) return true;
|
||||
for (const child of this.children.values()) {
|
||||
if (child.sessionId === sessionId) return child.terminalState === undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new child session for the task and makes it the owner of its
|
||||
* parent tool call. With `announce`, it publishes `subagent_spawned` and
|
||||
* delivers the updates that waited for the child.
|
||||
*/
|
||||
private async openGeneration(
|
||||
taskId: string,
|
||||
previous: NativeSubagent | undefined,
|
||||
fields: Pick<
|
||||
NativeSubagent,
|
||||
"parentSessionId" | "parentToolUseId" | "name" | "task" | "prompt"
|
||||
>,
|
||||
announce: boolean,
|
||||
deliver: Publish,
|
||||
): Promise<void> {
|
||||
const child: NativeSubagent = {
|
||||
sessionId: this.nextChildSessionId(taskId, previous),
|
||||
...fields,
|
||||
};
|
||||
const toolUseId = child.parentToolUseId;
|
||||
this.children.set(taskId, child);
|
||||
if (toolUseId) {
|
||||
this.taskByToolUse.set(toolUseId, taskId);
|
||||
this.childByParentToolUse.set(toolUseId, child);
|
||||
this.controlByToolUse.delete(toolUseId);
|
||||
}
|
||||
if (!announce) return;
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
|
||||
}
|
||||
|
||||
private nextChildSessionId(taskId: string, previous: NativeSubagent | undefined): string {
|
||||
if (!previous) {
|
||||
this.generationByTaskId.set(taskId, 1);
|
||||
@@ -328,6 +484,7 @@ export async function announceNativeSubagent(
|
||||
subagentSessionId: child.sessionId,
|
||||
name: child.name,
|
||||
task: child.task,
|
||||
...promptField(child.prompt),
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
@@ -370,6 +527,41 @@ export async function finishNativeSubagent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent id that a successful SendMessage result resumed. The SDK puts it
|
||||
* in `tool_use_result.resumedAgentId` when a finished agent runs again.
|
||||
*/
|
||||
export function resumedNativeSubagentId(toolUseResult: unknown): string | undefined {
|
||||
if (typeof toolUseResult !== "object" || toolUseResult === null) return undefined;
|
||||
const result = toolUseResult as { success?: unknown; resumedAgentId?: unknown };
|
||||
return result.success === true ? nonBlankString(result.resumedAgentId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SendMessage text that resumed the agent `agentId`. The tool uses of
|
||||
* `resultToolUseIds` come first: they are the SendMessage calls whose result
|
||||
* carried the resume. Otherwise the latest SendMessage call to `agentId` counts.
|
||||
*/
|
||||
export function sendMessageResumePrompt(
|
||||
toolUses: Record<string, { name: string; input: unknown } | undefined>,
|
||||
agentId: string,
|
||||
resultToolUseIds: readonly string[] = [],
|
||||
): string | undefined {
|
||||
for (const toolUseId of resultToolUseIds) {
|
||||
const toolUse = toolUses[toolUseId];
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const text = promptText((toolUse.input as { message?: unknown } | null)?.message);
|
||||
if (text) return text;
|
||||
}
|
||||
if (resultToolUseIds.length > 0) return undefined;
|
||||
for (const toolUse of Object.values(toolUses).reverse()) {
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const input = toolUse.input as { to?: unknown; message?: unknown } | null;
|
||||
if (input?.to === agentId) return promptText(input.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function nativeSubagentState(status: unknown): SubagentState | undefined {
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "failed") return "failed";
|
||||
@@ -387,14 +579,31 @@ export function isNativeSubagentControlUpdate(
|
||||
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);
|
||||
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
|
||||
return (
|
||||
airExtensionMeta(update._meta)?.[AIR_SUBAGENT_KEY] === true ||
|
||||
isNativeSubagentControlTool(claudeMeta?.toolName)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNativeSubagentControlTool(toolName: unknown): boolean {
|
||||
return toolName === "Agent" || toolName === "Task";
|
||||
}
|
||||
|
||||
/**
|
||||
* An async task update that may reach a child generation after it finished.
|
||||
* The spawn is one of them: a held task can get its tool call id, and so its
|
||||
* spawn, after the child finished.
|
||||
*/
|
||||
function isLateAsyncTaskUpdate(notification: AcpSessionNotification): boolean {
|
||||
const kind = notification.update.sessionUpdate;
|
||||
return (
|
||||
kind === "async_task_spawned" ||
|
||||
kind === "async_task_progress" ||
|
||||
kind === "async_task_state_update"
|
||||
);
|
||||
}
|
||||
|
||||
function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boolean {
|
||||
return (
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
@@ -422,6 +631,9 @@ function failedControlFallback(
|
||||
...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
|
||||
@@ -463,8 +675,14 @@ function ordinaryToolMeta(
|
||||
(value) => (value?.claudeCode as Record<string, unknown> | null | undefined) ?? {},
|
||||
),
|
||||
);
|
||||
delete claudeCode.subagent;
|
||||
return { ...merged, claudeCode };
|
||||
const result: Record<string, unknown> = { ...merged, claudeCode };
|
||||
const air = airExtensionMeta(merged);
|
||||
if (air && AIR_SUBAGENT_KEY in air) {
|
||||
const rest = { ...air };
|
||||
delete rest[AIR_SUBAGENT_KEY];
|
||||
result.jetbrains = { ...(merged.jetbrains as Record<string, unknown>), air: rest };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function subagentDisplayName(
|
||||
@@ -493,7 +711,7 @@ function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
const identity: SubagentIdentity = {
|
||||
name: nonBlankString(value.name),
|
||||
description: nonBlankString(value.description),
|
||||
prompt: nonBlankString(value.prompt),
|
||||
prompt: promptText(value.prompt),
|
||||
subagentType: nonBlankString(value.subagent_type),
|
||||
};
|
||||
return Object.values(identity).some(Boolean) ? identity : undefined;
|
||||
@@ -527,6 +745,16 @@ function applySubagentIdentity(
|
||||
if (identity.prompt || identity.description) {
|
||||
child.task = subagentDescription(identity.prompt, identity.description);
|
||||
}
|
||||
child.prompt ??= identity.prompt;
|
||||
}
|
||||
|
||||
/** The prompt text unchanged, or `undefined` when it is not a non-blank string. */
|
||||
function promptText(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function promptField(prompt: string | undefined): { prompt?: string } {
|
||||
return prompt === undefined ? {} : { prompt };
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | undefined {
|
||||
|
||||
7
src/paths.ts
Normal file
7
src/paths.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** The Claude configuration directory: `CLAUDE_CONFIG_DIR`, or `~/.claude`. */
|
||||
export function claudeConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
}
|
||||
@@ -23,9 +23,12 @@ const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
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";
|
||||
@@ -48,10 +51,8 @@ export function resolvePermissionMode(
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
|
||||
logger.error(
|
||||
"Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.",
|
||||
);
|
||||
if (mapped === "bypassPermissions" && !allowBypass) {
|
||||
logger.error("Ignoring permission mode bypassPermissions: not available in this session.");
|
||||
return "default";
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,12 @@ import {
|
||||
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) => permissionOptionOrder(left) - permissionOptionOrder(right),
|
||||
(left, right) => order(left) - order(right),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +75,16 @@ function buildUnsortedClaudePermissionOptions(
|
||||
}
|
||||
}
|
||||
|
||||
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":
|
||||
|
||||
@@ -23,7 +23,12 @@ export interface PermissionOptionContext {
|
||||
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[];
|
||||
/** The mode the session left when it entered plan mode. */
|
||||
prePlanMode?: string;
|
||||
contextUsedPercent?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,47 +60,55 @@ export function buildExitPlanModePermissionOptions(
|
||||
): PermissionOption[] {
|
||||
const modes = new Set(context.availableModes);
|
||||
const options: PermissionOption[] = [];
|
||||
const elevatedMode = modes.has("auto")
|
||||
? "auto"
|
||||
: modes.has("bypassPermissions")
|
||||
? "bypassPermissions"
|
||||
: "acceptEdits";
|
||||
// Claude Code offers bypass only when the user launched with it, so it can
|
||||
// prefer bypass outright. The adapter advertises bypass by default, so Auto
|
||||
// leads unless the session was in bypass before entering plan mode. Both stay
|
||||
// selectable: they have different permission semantics.
|
||||
const elevatedModes = (
|
||||
context.prePlanMode === "bypassPermissions"
|
||||
? (["bypassPermissions", "auto"] as const)
|
||||
: (["auto", "bypassPermissions"] as const)
|
||||
).filter((mode) => modes.has(mode));
|
||||
const preferredMode = elevatedModes[0] ?? "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",
|
||||
});
|
||||
}
|
||||
options.push(
|
||||
preferredMode === "auto"
|
||||
? {
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearAuto,
|
||||
name: `Yes, clear context${usage} and use auto mode`,
|
||||
kind: "allow_always",
|
||||
}
|
||||
: preferredMode === "bypassPermissions"
|
||||
? {
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanClearBypass,
|
||||
name: `Yes, clear context${usage} and bypass permissions`,
|
||||
kind: "allow_always",
|
||||
}
|
||||
: {
|
||||
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 {
|
||||
for (const mode of elevatedModes) {
|
||||
options.push(
|
||||
mode === "auto"
|
||||
? {
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanAuto,
|
||||
name: "Yes, and use auto mode",
|
||||
kind: "allow_always",
|
||||
}
|
||||
: {
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanBypass,
|
||||
name: "Yes, and bypass permissions",
|
||||
kind: "allow_always",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (elevatedModes.length === 0) {
|
||||
options.push({
|
||||
optionId: PERMISSION_OPTION_ID.exitPlanAcceptEdits,
|
||||
name: "Yes, auto-accept edits",
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import type { RequestPermissionRequest, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import { toolInfoFromToolUse } from "../tools.js";
|
||||
import type { RequestPermissionRequest, ToolCallContent } from "@agentclientprotocol/sdk";
|
||||
import { AIR_PERMISSION_KEY, withAirMeta } from "../air-extension.js";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
|
||||
|
||||
/**
|
||||
* A permission request as the agent builds it. For a v2 client it also
|
||||
* carries the heading and the explanation of the prompt, which ACP v2 sends as
|
||||
* the request's `title` and `description`. v1 has no field for them; AIR gets
|
||||
* them in `_meta`.
|
||||
*/
|
||||
export type AcpPermissionRequest = RequestPermissionRequest & {
|
||||
/** The heading of the prompt. It is not the title of the tool call. */
|
||||
title?: string;
|
||||
/** Why the permission is asked. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export interface ClaudePermissionPresentationInput {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
toolUseID: string;
|
||||
cwd?: string;
|
||||
supportsTerminalOutput?: boolean;
|
||||
capabilities?: ClientCapabilities;
|
||||
/** The exact patch of the change to approve, for a negotiated `diffPatch` client or a v2 client. */
|
||||
previewContent?: ToolCallContent[];
|
||||
blockedPath?: string;
|
||||
title?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
decisionReason?: string;
|
||||
defaultToNo?: boolean;
|
||||
}
|
||||
|
||||
function humanText(value: unknown, maxLength: number, singleLine = false): string | undefined {
|
||||
@@ -38,75 +56,82 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission request presentation.
|
||||
*
|
||||
* The request `toolCall` carries `toolCallId`, `title`, and `rawInput`. The
|
||||
* adapter emits the `tool_call` before the request, so the client already
|
||||
* holds the rest. The request adds only what it shows new: the exact preview
|
||||
* patch, and the blocked path when the tool call has no such location.
|
||||
*
|
||||
* The heading and the explanation of the prompt go to AIR in `_meta`, and to a
|
||||
* v2 client as `title` and `description`.
|
||||
*/
|
||||
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,
|
||||
);
|
||||
): Pick<AcpPermissionRequest, "toolCall" | "_meta" | "title" | "description"> {
|
||||
const capabilities = value.capabilities ?? new ClientCapabilities();
|
||||
const renderer = new AcpToolCallRenderer(capabilities);
|
||||
const toolUse = { id: value.toolUseID, name: value.toolName, input: value.input };
|
||||
const facts = renderer.facts(toolUse, 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 shellTitle =
|
||||
value.toolName === "Bash" || value.toolName === "PowerShell"
|
||||
? (compactText(value.input.description) ?? value.toolName)
|
||||
: undefined;
|
||||
const toolCallTitle = shellTitle ?? subjectTitle ?? info.title;
|
||||
const toolCallTitle = subjectTitle ?? facts.title;
|
||||
const permissionTitle = value.toolName === "ExitPlanMode" ? "Ready to code?" : toolCallTitle;
|
||||
const title = humanText(permissionTitle, 4_000, true) ?? "Use tool?";
|
||||
// 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;
|
||||
const blockedPath = humanText(value.blockedPath, 4_096, true);
|
||||
const extraLocations =
|
||||
blockedPath && !(facts.locations ?? []).some((location) => location.path === blockedPath)
|
||||
? [{ path: blockedPath }]
|
||||
: undefined;
|
||||
return {
|
||||
toolCall: {
|
||||
toolCallId: value.toolUseID,
|
||||
name: value.toolName,
|
||||
status: "pending",
|
||||
rawInput: value.input,
|
||||
...info,
|
||||
toolCall: renderer.permissionToolCall(toolUse, {
|
||||
cwd: value.cwd,
|
||||
title: toolCallTitle,
|
||||
content: subjectContent,
|
||||
locations: withBlockedPath(info.locations, value.blockedPath),
|
||||
},
|
||||
...(title
|
||||
previewContent: value.previewContent,
|
||||
extraLocations,
|
||||
// The upstream request shows the input of a network or computer-use
|
||||
// request that has no content of its own.
|
||||
fallbackContent:
|
||||
host || isComputerUse
|
||||
? [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `\`\`\`json\n${JSON.stringify(value.input, null, 2)}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
}),
|
||||
// Only AIR gets the permission presentation.
|
||||
...(title && capabilities.air.client
|
||||
? {
|
||||
_meta: {
|
||||
permission: {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
},
|
||||
},
|
||||
_meta: withAirMeta(undefined, AIR_PERMISSION_KEY, {
|
||||
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 } : {}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
243
src/resumed-session.ts
Normal file
243
src/resumed-session.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { getSessionMessages, type SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { access, open, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { claudeConfigDir } from "./paths.js";
|
||||
import { SessionTiming } from "./session-timing.js";
|
||||
|
||||
/** The size of one backward read of a transcript. */
|
||||
const TAIL_CHUNK_BYTES = 64 * 1024;
|
||||
|
||||
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 = concreteModel((entry.message as { model?: unknown }).model);
|
||||
if (model) return model;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The model name, or undefined for a missing or a synthetic `<…>` model. */
|
||||
function concreteModel(model: unknown): string | undefined {
|
||||
if (typeof model !== "string") return undefined;
|
||||
const name = model.trim();
|
||||
return name.length > 0 && !/^<[^>]+>$/.test(name) ? name : 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 {};
|
||||
}
|
||||
}
|
||||
|
||||
/** What a resume restores from the end of the local transcript. */
|
||||
export type ResumedTail = {
|
||||
model?: string;
|
||||
/** The last permission mode of the main thread. See {@link TailScan}. */
|
||||
permissionMode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the resume model and the permission mode of a session from the end
|
||||
* of its local transcript.
|
||||
*
|
||||
* A resume needs only these two values, not the messages. The transcript file
|
||||
* is read backwards until both are known, so the cost does not grow with the
|
||||
* length of the session. A session without a local transcript file falls back
|
||||
* to {@link readResumedSession} for the model.
|
||||
*
|
||||
* A caller that already has the model passes `withModel: false`. The read then
|
||||
* looks for the permission mode only and has no fallback.
|
||||
*/
|
||||
export async function readResumedTail(
|
||||
sessionId: string,
|
||||
logger?: ResumeLogger,
|
||||
withModel = true,
|
||||
): Promise<ResumedTail> {
|
||||
const timing = new SessionTiming(logger, "models", sessionId);
|
||||
try {
|
||||
const filePath = await findTranscript(sessionId);
|
||||
if (filePath) {
|
||||
const tail = await lastTailRecords(filePath, withModel);
|
||||
timing.phase(
|
||||
"read-transcript-tail",
|
||||
` model=${tail.model ?? "unknown"} permissionMode=${tail.permissionMode ?? "unknown"}`,
|
||||
);
|
||||
return tail;
|
||||
}
|
||||
} catch (error) {
|
||||
timing.phase("read-transcript-tail", " outcome=error");
|
||||
logger?.error(`Failed to read the transcript tail of resumed session ${sessionId}:`, error);
|
||||
}
|
||||
return withModel ? { model: (await readResumedSession(sessionId, logger)).model } : {};
|
||||
}
|
||||
|
||||
/** The local transcript of a session in any project directory, as the SDK looks it up. */
|
||||
export async function findTranscript(sessionId: string): Promise<string | undefined> {
|
||||
const projects = path.join(claudeConfigDir(), "projects");
|
||||
let directories: string[];
|
||||
try {
|
||||
directories = await readdir(projects);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
for (const directory of directories) {
|
||||
const candidate = path.join(projects, directory, `${sessionId}.jsonl`);
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Not in this project directory.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The model of the last real assistant record and the last permission mode
|
||||
* of the main thread in a JSONL transcript. */
|
||||
async function lastTailRecords(filePath: string, withModel: boolean): Promise<ResumedTail> {
|
||||
const handle = await open(filePath, "r");
|
||||
const scan = new TailScan(withModel);
|
||||
try {
|
||||
let end = (await handle.stat()).size;
|
||||
// The bytes of the line whose start is not read yet, in file order.
|
||||
let pending: Buffer[] = [];
|
||||
while (end > 0) {
|
||||
const start = Math.max(0, end - TAIL_CHUNK_BYTES);
|
||||
let chunk = Buffer.alloc(end - start);
|
||||
await handle.read(chunk, 0, chunk.length, start);
|
||||
for (let at = chunk.lastIndexOf(0x0a); at >= 0; at = chunk.lastIndexOf(0x0a)) {
|
||||
if (scan.visit(Buffer.concat([chunk.subarray(at + 1), ...pending]))) return scan.tail;
|
||||
pending = [];
|
||||
chunk = chunk.subarray(0, at);
|
||||
}
|
||||
pending.unshift(chunk);
|
||||
end = start;
|
||||
}
|
||||
scan.visit(Buffer.concat(pending));
|
||||
return scan.tail;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
type TranscriptRecord = {
|
||||
type?: unknown;
|
||||
isSidechain?: unknown;
|
||||
message?: { model?: unknown };
|
||||
permissionMode?: unknown;
|
||||
origin?: { kind?: unknown };
|
||||
attachment?: { type?: unknown };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the lines of a transcript from the end and keeps what a resume needs.
|
||||
*
|
||||
* Claude Code records the permission mode on each prompt record of type `user`.
|
||||
* The `permission-mode` records of the CLI metadata block are not used: they
|
||||
* can disagree with the prompts, and the SDK does not write them.
|
||||
* The SDK writes no record when it leaves plan mode through ExitPlanMode.
|
||||
* It writes a `plan_mode_exit` attachment only, without the new mode. Thus a
|
||||
* `plan` mode before a later plan exit is not the current mode, and the scan
|
||||
* gives no mode.
|
||||
*
|
||||
* The mode search stops at the first mode record or at the last human prompt.
|
||||
* Each human prompt of a current Claude Code has the mode, so a prompt without
|
||||
* it means that the transcript does not record modes. A compaction does not
|
||||
* stop the search: the mode of the prompt before it is still current.
|
||||
*/
|
||||
class TailScan {
|
||||
readonly tail: ResumedTail = {};
|
||||
private modeDone = false;
|
||||
private planExited = false;
|
||||
|
||||
constructor(private readonly withModel: boolean) {}
|
||||
|
||||
/** Visit the previous line. Return true when the scan has all it needs. */
|
||||
visit(line: Buffer): boolean {
|
||||
let parsed: TranscriptRecord | null | undefined;
|
||||
// Most lines have no marker of interest. The check skips their JSON parse.
|
||||
const record = (marker: string): TranscriptRecord | undefined => {
|
||||
if (line.indexOf(marker) < 0) return undefined;
|
||||
if (parsed === undefined) parsed = parseMainThreadRecord(line);
|
||||
return parsed ?? undefined;
|
||||
};
|
||||
if (this.withModel && this.tail.model === undefined) {
|
||||
const entry = record('"assistant"');
|
||||
if (entry?.type === "assistant") this.tail.model = concreteModel(entry.message?.model);
|
||||
}
|
||||
if (!this.modeDone) this.visitForMode(record);
|
||||
return (!this.withModel || this.tail.model !== undefined) && this.modeDone;
|
||||
}
|
||||
|
||||
private visitForMode(record: (marker: string) => TranscriptRecord | undefined): void {
|
||||
const modeEntry = record('"permissionMode"');
|
||||
if (
|
||||
modeEntry?.type === "user" &&
|
||||
typeof modeEntry.permissionMode === "string" &&
|
||||
modeEntry.permissionMode.trim() !== ""
|
||||
) {
|
||||
const mode = modeEntry.permissionMode.trim();
|
||||
this.modeDone = true;
|
||||
if (!(this.planExited && mode === "plan")) this.tail.permissionMode = mode;
|
||||
return;
|
||||
}
|
||||
if (record('"plan_mode_exit"')?.attachment?.type === "plan_mode_exit") {
|
||||
this.planExited = true;
|
||||
return;
|
||||
}
|
||||
const human = record('"human"');
|
||||
if (human?.type === "user" && human.origin?.kind === "human") this.modeDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The parsed record of one line, or null for a subagent record or a bad line. */
|
||||
function parseMainThreadRecord(line: Buffer): TranscriptRecord | null {
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line.toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const record = entry as TranscriptRecord;
|
||||
return record.isSidechain === true ? null : record;
|
||||
}
|
||||
51
src/serve.ts
Normal file
51
src/serve.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* Serves ACP on stdio.
|
||||
*
|
||||
* With `experimentalV2`, the connection goes through {@link acpProtocolRouter}.
|
||||
* Without it, the adapter serves ACP v1 alone and never routes by protocol
|
||||
* version, exactly as before.
|
||||
*/
|
||||
export function serveAcp(
|
||||
logger: Logger | undefined,
|
||||
options: { experimentalV2: boolean },
|
||||
): { closed: Promise<void>; dispose(): Promise<void> } {
|
||||
const output = nodeToWebWritable(process.stdout);
|
||||
const input = nodeToWebReadable(process.stdin);
|
||||
// The router opens the connection of the negotiated version only when it
|
||||
// reads `initialize`, so a client that leaves before then leaves no agent.
|
||||
let agent: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = options.experimentalV2
|
||||
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
|
||||
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
|
||||
return {
|
||||
closed,
|
||||
dispose: async () => {
|
||||
await agent?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a connection to the protocol version that the client negotiates in
|
||||
* `initialize`: the experimental draft ACP v2 for a client that requests
|
||||
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
|
||||
* the connection once the router has opened it.
|
||||
*/
|
||||
export function acpProtocolRouter(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentProtocolRouter {
|
||||
return v2
|
||||
.agentProtocolRouter()
|
||||
.withV1(v1AgentApp(logger, onAgent))
|
||||
.withV2(v2AgentApp(logger, onAgent));
|
||||
}
|
||||
130
src/session-effort.ts
Normal file
130
src/session-effort.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
|
||||
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
|
||||
// ultracode on at any level, so carry a settings-requested ultracode along
|
||||
// with every effort apply.
|
||||
export function effortFlagSettings(
|
||||
value: string | undefined,
|
||||
settings: Settings,
|
||||
): { effortLevel: EffortLevel | null; ultracode?: true } {
|
||||
const effortLevel = toSdkEffortLevel(value);
|
||||
return settings.ultracode === true ? { effortLevel, ultracode: true } : { 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),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function airSessionFailureCapabilityMeta(...additionalCapabilities: strin
|
||||
}
|
||||
|
||||
export type ClaudeFailureKind =
|
||||
| "access_denied"
|
||||
| "advisory"
|
||||
| "auth_required"
|
||||
| "bad_request"
|
||||
@@ -93,6 +94,15 @@ const AIR_FAILURE_POLICY: Record<
|
||||
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: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude could not access the account or provider credentials.",
|
||||
},
|
||||
advisory: {
|
||||
category: "unknown",
|
||||
actions: [],
|
||||
@@ -448,6 +458,15 @@ export function providerFailureCategory(
|
||||
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":
|
||||
|
||||
@@ -12,13 +12,27 @@ import type {
|
||||
PermissionResult,
|
||||
Query,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ALLOW_BYPASS } from "./permissions/modes.js";
|
||||
import { AIR_KIND_KEY, airOnlyMeta } from "./air-extension.js";
|
||||
import { resolvePermissionMode } from "./permissions/modes.js";
|
||||
import {
|
||||
noticeOrTranscriptUpdate,
|
||||
noticeTranscriptText,
|
||||
sentenceCase,
|
||||
type SessionNotice,
|
||||
} from "./session-notices.js";
|
||||
|
||||
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.";
|
||||
/** Reads on after the bold label in the transcript line; capitalized when it
|
||||
* stands alone as a notice description. */
|
||||
const AUTO_MODE_FALLBACK_REASON =
|
||||
"the selected model does not support Auto mode; using Accept edits instead.";
|
||||
const AUTO_MODE_FALLBACK_NOTICE: SessionNotice = {
|
||||
severity: "warning",
|
||||
title: "Auto mode unavailable",
|
||||
description: sentenceCase(AUTO_MODE_FALLBACK_REASON),
|
||||
};
|
||||
|
||||
export type SessionMode = {
|
||||
query: Pick<Query, "setPermissionMode">;
|
||||
@@ -30,6 +44,8 @@ export type SessionMode = {
|
||||
autoModeFallbackWarningShown?: boolean;
|
||||
/** Initial mode fallback is reported after session/new, on the first prompt. */
|
||||
autoModeFallbackWarningPending?: boolean;
|
||||
/** The mode the session left when it entered plan mode, if it is in plan. */
|
||||
prePlanMode?: string;
|
||||
};
|
||||
|
||||
export type SessionModeManagerOptions<S extends SessionMode> = {
|
||||
@@ -37,7 +53,13 @@ export type SessionModeManagerOptions<S extends SessionMode> = {
|
||||
sessionEndedMessage: string;
|
||||
updateConfigOption(sessionId: string, configId: string, value: string): Promise<void>;
|
||||
sessionUpdate(params: SessionNotification): Promise<void>;
|
||||
/** Whether the client can present `notice` updates; the fallback warning
|
||||
* is a transcript line otherwise. Read per call: capabilities are only
|
||||
* known after `initialize`. */
|
||||
supportsNotices?(): boolean;
|
||||
logError(...args: unknown[]): void;
|
||||
/** Whether the client is AIR. Only AIR gets the mode kind, under `_meta.jetbrains.air`. */
|
||||
airClient?(): boolean;
|
||||
};
|
||||
|
||||
type ModeConfigSession = SessionMode & {
|
||||
@@ -49,8 +71,41 @@ type InitializeSessionModeParams = {
|
||||
requestedMode: PermissionMode;
|
||||
currentModelInfo?: ModelInfo;
|
||||
currentModelId: string;
|
||||
/** When false, bypassPermissions is omitted from the mode catalog. */
|
||||
allowBypass: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission mode a new Claude query starts in.
|
||||
*
|
||||
* An explicit mode wins. A resume or a load continues in the mode of the last
|
||||
* user record of the transcript. If that mode is unknown or not available now,
|
||||
* for example bypass, the session starts in `defaultMode` of the settings.
|
||||
*/
|
||||
export function resolveInitialPermissionMode(
|
||||
sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown },
|
||||
logger: { error: (...args: unknown[]) => void },
|
||||
allowBypass: boolean,
|
||||
): PermissionMode {
|
||||
if (sources.explicit !== undefined) {
|
||||
return resolvePermissionMode(sources.explicit, logger, allowBypass);
|
||||
}
|
||||
if (sources.resumed !== undefined) {
|
||||
let rejected = false;
|
||||
const mode = resolvePermissionMode(
|
||||
sources.resumed,
|
||||
{ error: () => (rejected = true) },
|
||||
allowBypass,
|
||||
);
|
||||
if (!rejected) return mode;
|
||||
logger.error(
|
||||
`Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` +
|
||||
`not available in this session.`,
|
||||
);
|
||||
}
|
||||
return resolvePermissionMode(sources.defaultMode, logger, allowBypass);
|
||||
}
|
||||
|
||||
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
|
||||
export class SessionModeManager<S extends SessionMode> {
|
||||
constructor(private readonly options: SessionModeManagerOptions<S>) {}
|
||||
@@ -60,11 +115,12 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
requestedMode,
|
||||
currentModelInfo,
|
||||
currentModelId,
|
||||
allowBypass,
|
||||
}: InitializeSessionModeParams): Promise<{
|
||||
modes: SessionModeState;
|
||||
autoModeFallbackWarningPending: boolean;
|
||||
}> {
|
||||
const availableModes = this.buildAvailableModes();
|
||||
const availableModes = this.buildAvailableModes(allowBypass);
|
||||
let effectiveMode = requestedMode;
|
||||
let autoModeFallbackWarningPending = false;
|
||||
|
||||
@@ -113,6 +169,12 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
|
||||
syncConfig(session: ModeConfigSession, mode: string): void {
|
||||
const previousMode = session.modes.currentModeId;
|
||||
if (mode !== "plan") {
|
||||
session.prePlanMode = undefined;
|
||||
} else if (previousMode !== "plan") {
|
||||
session.prePlanMode = previousMode;
|
||||
}
|
||||
session.modes = { ...session.modes, currentModeId: mode };
|
||||
session.configOptions = session.configOptions.map((option) =>
|
||||
option.id === MODE_CONFIG_ID && typeof option.currentValue === "string"
|
||||
@@ -229,10 +291,14 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
try {
|
||||
await this.options.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: AUTO_MODE_FALLBACK_NOTICE },
|
||||
},
|
||||
update: noticeOrTranscriptUpdate(
|
||||
AUTO_MODE_FALLBACK_NOTICE,
|
||||
this.options.supportsNotices?.() ?? false,
|
||||
noticeTranscriptText({
|
||||
...AUTO_MODE_FALLBACK_NOTICE,
|
||||
description: AUTO_MODE_FALLBACK_REASON,
|
||||
}),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
// The fallback has already been applied; a failed advisory must not turn
|
||||
@@ -281,39 +347,45 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
}
|
||||
|
||||
private buildAvailableModes(): SessionModeState["availableModes"] {
|
||||
private buildAvailableModes(allowBypass: boolean): SessionModeState["availableModes"] {
|
||||
// Only AIR gets the mode kind.
|
||||
const air = this.options.airClient?.() ?? false;
|
||||
const kind = (value: string) => {
|
||||
const meta = airOnlyMeta(air, AIR_KIND_KEY, value);
|
||||
return meta ? { _meta: meta } : {};
|
||||
};
|
||||
const modes: SessionModeState["availableModes"] = [
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
_meta: { kind: "standard" },
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
_meta: { kind: "standard" },
|
||||
...kind("standard"),
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
_meta: { kind: "plan" },
|
||||
...kind("plan"),
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
_meta: { kind: "auto_review" },
|
||||
...kind("auto_review"),
|
||||
},
|
||||
];
|
||||
if (ALLOW_BYPASS) {
|
||||
if (allowBypass) {
|
||||
modes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
_meta: { kind: "full_access" },
|
||||
...kind("full_access"),
|
||||
});
|
||||
}
|
||||
return modes;
|
||||
|
||||
509
src/session-model.ts
Normal file
509
src/session-model.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
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);
|
||||
}
|
||||
|
||||
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
|
||||
|
||||
/** A model id without the spellings `deniedModels` ignores: provider
|
||||
* prefixes, dates, version suffixes, `-fast` and context hints. */
|
||||
function normalizeDeniedModelId(value: string): string {
|
||||
let id = stripContextHints(value.trim().toLowerCase());
|
||||
const providerPrefix = id.lastIndexOf("anthropic.");
|
||||
if (providerPrefix !== -1) id = id.slice(providerPrefix + "anthropic.".length);
|
||||
return id
|
||||
.replace(/@.*$/, "")
|
||||
.replace(/-v\d+(?::\d+)?$/, "")
|
||||
.replace(/-fast$/, "")
|
||||
.replace(/-\d{8}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether managed `deniedModels` blocks a model id, following the CLI's rules
|
||||
* closely enough for the picker: a family alias blocks the whole family, and a
|
||||
* model id blocks that version in every spelling plus the later minor versions
|
||||
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). The CLI stays
|
||||
* the enforcer; this only keeps the picker from offering a refused row.
|
||||
*/
|
||||
export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean {
|
||||
const id = normalizeDeniedModelId(modelId);
|
||||
if (!id) return false;
|
||||
return deniedModels.some((entry) => {
|
||||
const denied = normalizeDeniedModelId(entry);
|
||||
if (!denied) return false;
|
||||
if (MODEL_FAMILY_ALIASES.has(denied)) return id === denied || id.includes(`-${denied}`);
|
||||
return id === denied || id.startsWith(`${denied}-`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `[]`.
|
||||
* - Managed `deniedModels` wins over the allowlist, so a denied entry is
|
||||
* dropped even though the CLI would otherwise leave it unmatched.
|
||||
*/
|
||||
export function applyAvailableModelsAllowlist(
|
||||
sdkModels: ModelInfo[],
|
||||
allowlist: string[],
|
||||
settingsModelOverrides?: Record<string, string>,
|
||||
deniedModels: readonly 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 (
|
||||
[trimmed, effective, sdkMatch?.resolvedModel].some(
|
||||
(id) => id != null && isDeniedModel(id, deniedModels),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
96
src/session-notices.ts
Normal file
96
src/session-notices.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
|
||||
/** Fire-and-forget advisory text for the user: live events rather than
|
||||
* conversation history. See the ACP Session Notices RFD
|
||||
* (https://agentclientprotocol.com/rfds/session-notices). */
|
||||
export type SessionNotice = {
|
||||
severity: "info" | "warning" | "error";
|
||||
/** Plain text that stands alone. */
|
||||
title: string;
|
||||
/** Plain-text detail or guidance. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/** Longest free-form line that may serve as a notice title on its own. Past
|
||||
* this the text moves to `description` under a generic title. */
|
||||
export const MAX_NOTICE_TITLE_LENGTH = 256;
|
||||
|
||||
/** Whether the Client advertised `clientCapabilities.session.notices`. Agents
|
||||
* MUST NOT send `notice` updates otherwise; the alternative is a bold-label
|
||||
* transcript line, which was the only way to flag these before. */
|
||||
export function clientSupportsNotices(capabilities?: ClientCapabilities | null): boolean {
|
||||
const notices = capabilities?.session?.notices;
|
||||
return typeof notices === "object" && notices !== null && !Array.isArray(notices);
|
||||
}
|
||||
|
||||
export function noticeUpdate(notice: SessionNotice): SessionNotification["update"] {
|
||||
return {
|
||||
sessionUpdate: "notice",
|
||||
severity: notice.severity,
|
||||
title: notice.title,
|
||||
...(notice.description ? { description: notice.description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The transcript rendering of a notice for clients without the capability. */
|
||||
export function noticeTranscriptText(notice: SessionNotice): string {
|
||||
return notice.description ? `**${notice.title}:** ${notice.description}` : `**${notice.title}**`;
|
||||
}
|
||||
|
||||
/** The update to send for `notice`: a `notice` when the client can present
|
||||
* one, else an agent message carrying `transcriptText` (by default the
|
||||
* bold-label rendering of the notice) and `transcriptMeta`, so clients
|
||||
* without the capability can still tell the line apart from Claude's reply. */
|
||||
export function noticeOrTranscriptUpdate(
|
||||
notice: SessionNotice,
|
||||
supportsNotices: boolean,
|
||||
transcriptText: string = noticeTranscriptText(notice),
|
||||
transcriptMeta?: Record<string, unknown>,
|
||||
): SessionNotification["update"] {
|
||||
if (supportsNotices) return noticeUpdate(notice);
|
||||
return {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: transcriptText },
|
||||
...(transcriptMeta ? { _meta: transcriptMeta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fragments that read on after a bold label stand alone as a notice
|
||||
* description, so start them with a capital. */
|
||||
export function sentenceCase(text: string): string {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
/** Collapses whitespace so a text shown as a notice can be recognized when
|
||||
* the SDK repeats it elsewhere (a hook-blocked turn's result, for one). */
|
||||
export function normalizeNoticeText(text: string): string {
|
||||
return text.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Notice fields are plain text. Free-form SDK/hook output may carry light
|
||||
* markdown that a transcript would render; drop the inline markers rather
|
||||
* than show literal asterisks and backticks in a toast. */
|
||||
export function noticePlainText(text: string): string {
|
||||
return text
|
||||
.replace(/^[ \t]*#{1,6}[ \t]+/gm, "")
|
||||
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2")
|
||||
.replace(/(?<![\w`])`([^`\n]+)`(?![\w`])/g, "$1");
|
||||
}
|
||||
|
||||
/** Shapes free-form text into a notice title plus optional description: the
|
||||
* first line stands as the title when it is short enough, otherwise the whole
|
||||
* text becomes the description under `fallbackTitle`. */
|
||||
export function splitNoticeText(
|
||||
text: string,
|
||||
fallbackTitle: string,
|
||||
): Pick<SessionNotice, "title" | "description"> {
|
||||
const trimmed = noticePlainText(text).trim();
|
||||
if (!trimmed) return { title: fallbackTitle };
|
||||
const lineBreak = trimmed.search(/\r?\n/);
|
||||
const firstLine = lineBreak === -1 ? trimmed : trimmed.slice(0, lineBreak).trimEnd();
|
||||
const rest = lineBreak === -1 ? "" : trimmed.slice(lineBreak).trim();
|
||||
if (firstLine.length > MAX_NOTICE_TITLE_LENGTH) {
|
||||
return { title: fallbackTitle, description: trimmed };
|
||||
}
|
||||
return rest ? { title: firstLine, description: rest } : { title: firstLine };
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
resolveSettings,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
import { claudeConfigDir } from "./paths.js";
|
||||
|
||||
/**
|
||||
* Permission rule format examples:
|
||||
@@ -49,6 +49,7 @@ export interface SettingsManagerOptions {
|
||||
export class SettingsManager {
|
||||
private cwd: string;
|
||||
private effective: Settings = {};
|
||||
private managedDeniedModels: string[] = [];
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
@@ -91,7 +92,7 @@ export class SettingsManager {
|
||||
*/
|
||||
private getWatchedPaths(): string[] {
|
||||
return [
|
||||
path.join(CLAUDE_CONFIG_DIR, "settings.json"),
|
||||
path.join(claudeConfigDir(), "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.local.json"),
|
||||
getManagedSettingsPath(),
|
||||
@@ -106,9 +107,14 @@ export class SettingsManager {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: this.cwd });
|
||||
this.effective = filterEscalatingDefaultMode(resolved);
|
||||
// The CLI reads `deniedModels` from managed settings only.
|
||||
this.managedDeniedModels = resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.flatMap((entry) => entry.settings.deniedModels ?? []);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
this.managedDeniedModels = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +177,14 @@ export class SettingsManager {
|
||||
return this.effective;
|
||||
}
|
||||
|
||||
/**
|
||||
* `deniedModels` entries from the managed tier, the only tier the CLI
|
||||
* honors them from.
|
||||
*/
|
||||
getManagedDeniedModels(): string[] {
|
||||
return this.managedDeniedModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current working directory
|
||||
*/
|
||||
|
||||
54
src/subagent-history.ts
Normal file
54
src/subagent-history.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { findTranscript } from "./resumed-session.js";
|
||||
|
||||
/** Where the subagent transcripts of a session are. */
|
||||
export interface SubagentHistory {
|
||||
/** The subagent id of each Agent or Task tool use that launched a subagent. */
|
||||
ids: Map<string, string>;
|
||||
/**
|
||||
* The project directory of the session, as the SDK takes it in `dir`. With
|
||||
* it, the SDK reads a subagent transcript without a search of every project.
|
||||
*/
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the subagent transcripts of a session.
|
||||
*
|
||||
* Claude keeps the history of a subagent in its own transcript,
|
||||
* `<session>/subagents/agent-<id>.jsonl`, next to the session transcript.
|
||||
* The `agent-<id>.meta.json` file beside it names the launching tool use.
|
||||
* The ids are empty for a session without a local transcript.
|
||||
*/
|
||||
export async function subagentHistory(sessionId: string): Promise<SubagentHistory> {
|
||||
const ids = new Map<string, string>();
|
||||
const transcript = await findTranscript(sessionId);
|
||||
if (!transcript) return { ids };
|
||||
const directory = path.join(path.dirname(transcript), sessionId, "subagents");
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(directory);
|
||||
} catch {
|
||||
return { ids };
|
||||
}
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const match = /^agent-(.+)\.meta\.json$/.exec(name);
|
||||
if (!match) return;
|
||||
try {
|
||||
const meta: unknown = JSON.parse(await readFile(path.join(directory, name), "utf8"));
|
||||
const toolUseId = (meta as { toolUseId?: unknown } | null)?.toolUseId;
|
||||
if (typeof toolUseId === "string") ids.set(toolUseId, match[1]!);
|
||||
} catch {
|
||||
// A damaged meta file leaves its subagent without history.
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (ids.size === 0) return { ids };
|
||||
// The folder name encodes the project directory, and the encoding loses
|
||||
// characters. The session info reads it back from the transcript.
|
||||
const dir = (await getSessionInfo(sessionId).catch(() => undefined))?.cwd;
|
||||
return dir ? { ids, dir } : { ids };
|
||||
}
|
||||
@@ -97,6 +97,163 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
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("keeps a settings-requested ultracode on when applying effort", async () => {
|
||||
// Since CLI 2.1.284 an effortLevel apply without an ultracode key turns
|
||||
// ultracode off; the CLI's own effort control keeps it on.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ ultracode: true }),
|
||||
);
|
||||
const applyFlagSettings = vi.fn();
|
||||
querySpy.mockReturnValue(
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
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 },
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "medium", ultracode: true });
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: response.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "high", ultracode: true });
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
@@ -150,6 +307,79 @@ 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.each(["user", "project"] as const)(
|
||||
"honors permissions.disableBypassPermissionsMode from %s settings",
|
||||
async (tier) => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(path.join(projectDir, ".claude"), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: "bypassPermissions",
|
||||
...(tier === "user" ? { disableBypassPermissionsMode: "disable" } : {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (tier === "project") {
|
||||
await fs.promises.writeFile(
|
||||
path.join(projectDir, ".claude", "settings.json"),
|
||||
JSON.stringify({ permissions: { disableBypassPermissionsMode: "disable" } }),
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -169,6 +399,142 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
describe("on resume", () => {
|
||||
const sessionId = "resumed-mode-session";
|
||||
|
||||
async function setup(records: unknown[]) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "plan" } }),
|
||||
);
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
const transcriptDir = path.join(tempDir, "projects", "-project");
|
||||
await fs.promises.mkdir(transcriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(transcriptDir, `${sessionId}.jsonl`),
|
||||
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
return { agent, projectDir, getCapturedOptions };
|
||||
}
|
||||
|
||||
async function resume(records: unknown[], meta: object = {}) {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup(records);
|
||||
const response = await (agent as any).createSession(
|
||||
{ cwd: projectDir, mcpServers: [], _meta: { disableBuiltInTools: true, ...meta } },
|
||||
{ resume: sessionId },
|
||||
);
|
||||
return { permissionMode: getCapturedOptions().permissionMode, response };
|
||||
}
|
||||
|
||||
let previousUuid: string | null = null;
|
||||
const user = (permissionMode?: string) => {
|
||||
const uuid = crypto.randomUUID();
|
||||
const record = {
|
||||
type: "user",
|
||||
uuid,
|
||||
parentUuid: previousUuid,
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
isSidechain: false,
|
||||
origin: { kind: "human" },
|
||||
message: { role: "user", content: "hello" },
|
||||
...(permissionMode ? { permissionMode } : {}),
|
||||
};
|
||||
previousUuid = uuid;
|
||||
return record;
|
||||
};
|
||||
|
||||
it("continues in the last permission mode of the transcript", async () => {
|
||||
const { permissionMode, response } = await resume([user("plan"), user("acceptEdits")]);
|
||||
|
||||
expect(permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when the transcript has no mode", async () => {
|
||||
const { permissionMode, response } = await resume([user()]);
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when bypass is not available now", async () => {
|
||||
const { permissionMode, response } = await resume([user("bypassPermissions")], {
|
||||
claudeCode: { options: { allowDangerouslySkipPermissions: false } },
|
||||
});
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("continues in the last permission mode on session/load", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([
|
||||
user("plan"),
|
||||
user("acceptEdits"),
|
||||
]);
|
||||
|
||||
const response = await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes?.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("keeps the live mode when it recreates the session", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]);
|
||||
const params = { sessionId, cwd: projectDir, _meta: { disableBuiltInTools: true } };
|
||||
await agent.resumeSession({ ...params, mcpServers: [] });
|
||||
await agent.setSessionMode({ sessionId, modeId: "default" });
|
||||
|
||||
// Other MCP servers change the fingerprint, so the agent recreates the query.
|
||||
const response = await agent.resumeSession({
|
||||
...params,
|
||||
mcpServers: [{ name: "other", command: "true", args: [], env: [] }],
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes?.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("keeps the live mode when a sign-out respawn starts a fresh conversation", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]);
|
||||
await agent.resumeSession({
|
||||
sessionId,
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
await agent.setSessionMode({ sessionId, modeId: "default" });
|
||||
const { RequestError } = await import("@agentclientprotocol/sdk");
|
||||
const createSession = (agent as any).createSession.bind(agent);
|
||||
const creations: object[] = [];
|
||||
(agent as any).createSession = async (params: object, options: { resume?: string }) => {
|
||||
creations.push(options);
|
||||
// The CLI never wrote the conversation, so the resume fails.
|
||||
if (options.resume) throw RequestError.resourceNotFound(sessionId);
|
||||
return createSession(params, options);
|
||||
};
|
||||
|
||||
await (agent as any).recreateSignedOutQuery(sessionId, (agent as any).sessions[sessionId]);
|
||||
|
||||
expect(creations).toEqual([
|
||||
{ resume: sessionId, permissionMode: "default" },
|
||||
{ reuseSessionId: sessionId, permissionMode: "default" },
|
||||
]);
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'default' when permissions.defaultMode is invalid", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
@@ -303,30 +669,27 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
|
||||
// A client that is not AIR gets no mode kind.
|
||||
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];
|
||||
@@ -335,7 +698,6 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
_meta: { kind: "full_access" },
|
||||
});
|
||||
}
|
||||
expect(modeIds).not.toContain("dontAsk");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
98
src/tests/acp-scenarios-compare.test.ts
Normal file
98
src/tests/acp-scenarios-compare.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareWithBaseline } from "./acp-scenarios/compare.js";
|
||||
import type { Recorded } from "./acp-scenarios/harness.js";
|
||||
|
||||
function update(fields: Record<string, unknown>): Recorded {
|
||||
return { kind: "sessionUpdate", payload: { sessionId: "s", update: fields } };
|
||||
}
|
||||
|
||||
const toolCall = update({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "t",
|
||||
title: "Read",
|
||||
status: "pending",
|
||||
_meta: { claudeCode: { toolName: "Read" } },
|
||||
});
|
||||
|
||||
const result = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "t",
|
||||
title: "Read",
|
||||
status: "completed",
|
||||
rawOutput: "text",
|
||||
_meta: { claudeCode: { toolName: "Read" } },
|
||||
};
|
||||
|
||||
const baseline = [toolCall, update(result)];
|
||||
|
||||
/** A copy of `value` without `key`. */
|
||||
function omit(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const copy = { ...value };
|
||||
delete copy[key];
|
||||
return copy;
|
||||
}
|
||||
|
||||
describe("compareWithBaseline", () => {
|
||||
it("accepts the baseline itself", () => {
|
||||
expect(compareWithBaseline(baseline, baseline)).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts an update without a field that did not change", () => {
|
||||
expect(compareWithBaseline(baseline, [toolCall, update(omit(result, "title"))])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a dropped _meta key", () => {
|
||||
expect(compareWithBaseline(baseline, [toolCall, update(omit(result, "_meta"))])).toContainEqual(
|
||||
expect.stringMatching(/^origin\/main sent .*"toolName":"Read"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a changed rawOutput", () => {
|
||||
const changed = update({ ...result, rawOutput: "other" });
|
||||
expect(compareWithBaseline(baseline, [toolCall, changed])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"rawOutput":"other"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an extra update", () => {
|
||||
const extra = update({ sessionUpdate: "tool_call_update", toolCallId: "t", status: "failed" });
|
||||
expect(compareWithBaseline(baseline, [...baseline, extra])).toEqual([
|
||||
expect.stringMatching(/^origin\/main did not send /u),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("available_commands_update", () => {
|
||||
const commands = (...names: string[]) =>
|
||||
update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: names.map((name) => ({ name, description: name })),
|
||||
});
|
||||
|
||||
it("accepts the mcp command of the adapter", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("compact", "mcp")])).toEqual([]);
|
||||
expect(compareWithBaseline([commands()], [commands("mcp")])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports any other extra command", () => {
|
||||
expect(compareWithBaseline([commands()], [commands("mcp", "other")])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"name":"other"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a dropped command", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("mcp")])).toContainEqual(
|
||||
expect.stringMatching(/^origin\/main sent .*"name":"compact"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a changed mcp command that origin/main listed", () => {
|
||||
const changed = update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "mcp", description: "other" }],
|
||||
});
|
||||
expect(compareWithBaseline([commands("mcp")], [changed])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"description":"other"/u),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/tests/acp-scenarios-v2.test.ts
Normal file
171
src/tests/acp-scenarios-v2.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2
|
||||
* surface: through the protocol router, with the SDK's v2 client.
|
||||
*
|
||||
* - Status: each scenario passes, every prompt turn ending with a stop reason,
|
||||
* unless {@link V2_STOPS} names why it stops. Every scenario passes today;
|
||||
* a scenario that the v2 surface cannot run yet goes in the table.
|
||||
* - Schema: every message the agent sends, also in a scenario that stops, is
|
||||
* valid against the draft v2 schema of the SDK.
|
||||
* - Tool calls: the first report of each tool call has a title. v2 has no
|
||||
* `tool_call`, so a report of a tool call that the client has not seen
|
||||
* creates it, where v1 drops it.
|
||||
* - Golden files: `acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
|
||||
* passing scenario. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u`
|
||||
* to update them.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import {
|
||||
resetIds,
|
||||
runScenarioV2,
|
||||
type ScenarioRunV2,
|
||||
type WireRecorded,
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import { canonical } from "./acp-scenarios/compare.js";
|
||||
import { validateV2Message } from "./acp-scenarios/schema.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// The recordings replace only the ids that the run generated, so the harness
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
const id = actual.randomUUID(...args);
|
||||
harness.noteGeneratedId(id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PASSES = "passes";
|
||||
|
||||
/**
|
||||
* The scenarios that stop on the v2 surface, each with the reason, which names
|
||||
* the step of `docs/acp-v2.md` that removes it. Every other scenario passes.
|
||||
*/
|
||||
const V2_STOPS: Record<string, string> = {};
|
||||
|
||||
const statusOf = (scenario: string) => V2_STOPS[scenario] ?? PASSES;
|
||||
|
||||
const runs = new Map<string, ScenarioRunV2>();
|
||||
let configDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// A run must not depend on the machine: no remote login, no Claude CLI, no
|
||||
// user settings, and the bypass mode also for root.
|
||||
for (const name of ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER"]) {
|
||||
vi.stubEnv(name, "");
|
||||
}
|
||||
vi.stubEnv("CLAUDE_CODE_REMOTE", "");
|
||||
vi.stubEnv("ANTHROPIC_MODEL", "");
|
||||
vi.stubEnv("IS_SANDBOX", "1");
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { acpProtocolRouter } = await import("../serve.js");
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
const quiet = { log: () => {}, error: () => {} };
|
||||
runs.set(
|
||||
scenario.name,
|
||||
await runScenarioV2(
|
||||
(stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream),
|
||||
scenario,
|
||||
),
|
||||
);
|
||||
await Promise.all(agents.map((agent) => agent.dispose()));
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(scenario: string): ScenarioRunV2 {
|
||||
const recorded = runs.get(scenario);
|
||||
if (!recorded) throw new Error(`no v2 run of ${scenario}`);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function toJsonLines(recorded: WireRecorded[]): string {
|
||||
return recorded.map((record) => `${canonical(record)}\n`).join("");
|
||||
}
|
||||
|
||||
describe("ACP v2 scenarios", () => {
|
||||
it("pass, or stop as the table says", () => {
|
||||
const table = (status: (scenario: string) => string) =>
|
||||
Object.fromEntries(SCENARIOS.map((scenario) => [scenario.name, status(scenario.name)]));
|
||||
expect(table((scenario) => run(scenario).status)).toEqual(table(statusOf));
|
||||
});
|
||||
|
||||
describe("schema check", () => {
|
||||
const chunk = (update: Record<string, unknown>): WireRecorded => ({
|
||||
kind: "notification",
|
||||
method: "session/update",
|
||||
payload: { sessionId: "s", update: { sessionUpdate: "agent_message_chunk", ...update } },
|
||||
});
|
||||
|
||||
it("rejects a message that breaks the v2 schema", () => {
|
||||
const content = { type: "text", text: "hi" };
|
||||
expect(validateV2Message(chunk({ messageId: "m", content }))).toEqual([]);
|
||||
// v2 requires the id of the message a chunk belongs to.
|
||||
expect(validateV2Message(chunk({ content }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
expect(run(scenario).raw.flatMap((message) => validateV2Message(message))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool calls are reported with a title first", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
const reported = new Set<string>();
|
||||
const untitled: string[] = [];
|
||||
for (const message of run(scenario).raw) {
|
||||
if (message.kind !== "notification" || message.method !== "session/update") continue;
|
||||
const { sessionId, update } = message.payload as {
|
||||
sessionId: string;
|
||||
update: { sessionUpdate: string; toolCallId?: string; title?: unknown };
|
||||
};
|
||||
if (update.sessionUpdate !== "tool_call_update") continue;
|
||||
const key = `${sessionId} ${update.toolCallId}`;
|
||||
if (!reported.has(key) && typeof update.title !== "string") untitled.push(key);
|
||||
reported.add(key);
|
||||
}
|
||||
expect(untitled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2 golden files", () => {
|
||||
const passing = SCENARIOS.map((scenario) => scenario.name).filter(
|
||||
(scenario) => statusOf(scenario) === PASSES,
|
||||
);
|
||||
it.each(passing)("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
994
src/tests/acp-scenarios.test.ts
Normal file
994
src/tests/acp-scenarios.test.ts
Normal file
@@ -0,0 +1,994 @@
|
||||
/**
|
||||
* The outbound ACP traffic of every scenario in `acp-scenarios/scenarios.ts`,
|
||||
* for three client profiles: a plain ACP client, Zed, and JetBrains AIR.
|
||||
*
|
||||
* - Recordings are JSON Lines: one outbound message on each line, with sorted
|
||||
* keys. `normalize` in `harness.ts` replaces only exact run-specific values:
|
||||
* the generated ids, the paths, and the argv and version of this process.
|
||||
* - AIR golden files: `acp-scenarios/__snapshots__/air/<scenario>.jsonl`.
|
||||
* Run `npx vitest run src/tests/acp-scenarios.test.ts -u` to update them.
|
||||
* - Schema: every message is valid against the ACP schema of the SDK.
|
||||
* - Plain and Zed: the same information as origin/main
|
||||
* (`acp-scenarios/origin-main/<profile>/<scenario>.jsonl`). `compare.ts`
|
||||
* allows only the documented differences. These profiles have no golden
|
||||
* files of their own. `origin-main/zed/` holds a scenario only when its
|
||||
* recording differs from the plain client.
|
||||
* - Zed: the Zed conventions and the upstream `_meta` keys.
|
||||
* - AIR: the AIR extensions of `docs/air-extensions.md`, each fact once.
|
||||
*
|
||||
* To record the origin/main baseline again, copy `src/tests/acp-scenarios/`
|
||||
* and this file into a checkout of origin/main, and run this file there with
|
||||
* `ACP_SCENARIO_BASELINE_DIR=<dir>`. Then copy `<dir>/plain` and `<dir>/zed`
|
||||
* to `acp-scenarios/origin-main/`.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
AIR_CAPABILITY_NAMES,
|
||||
assistantTurn,
|
||||
normalize,
|
||||
PROFILES,
|
||||
type Profile,
|
||||
type Recorded,
|
||||
resetIds,
|
||||
result,
|
||||
runScenario,
|
||||
type Scenario,
|
||||
type ScenarioRun,
|
||||
streamMessage,
|
||||
toolCall,
|
||||
toolResult,
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import {
|
||||
AIR_ONLY_CLAUDE_CODE_KEYS,
|
||||
AIR_ONLY_META_KEYS,
|
||||
canonical,
|
||||
compareWithBaseline,
|
||||
} from "./acp-scenarios/compare.js";
|
||||
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
|
||||
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// The recordings replace only the ids that the run generated, so the harness
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
const id = actual.randomUUID(...args);
|
||||
harness.noteGeneratedId(id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const baselineDir = process.env.ACP_SCENARIO_BASELINE_DIR;
|
||||
const profiles = Object.values(PROFILES);
|
||||
const runs = new Map<string, ScenarioRun>();
|
||||
const key = (profile: Profile["name"], scenario: string) => `${profile}/${scenario}`;
|
||||
let configDir: string;
|
||||
let Agent: Parameters<typeof runScenario>[0];
|
||||
|
||||
beforeAll(async () => {
|
||||
// A run must not depend on the machine: no remote login, no Claude CLI, no
|
||||
// user settings, and the bypass mode also for root.
|
||||
for (const name of ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER"]) {
|
||||
vi.stubEnv(name, "");
|
||||
}
|
||||
vi.stubEnv("CLAUDE_CODE_REMOTE", "");
|
||||
vi.stubEnv("ANTHROPIC_MODEL", "");
|
||||
vi.stubEnv("IS_SANDBOX", "1");
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
Agent = ClaudeAcpAgent;
|
||||
for (const profile of profiles) {
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
runs.set(
|
||||
key(profile.name, scenario.name),
|
||||
await runScenario(ClaudeAcpAgent, profile, scenario),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A recording as JSON Lines: one message on each line, with sorted keys. */
|
||||
function toJsonLines(recorded: Recorded[]): string {
|
||||
return recorded.map((record) => `${canonical(record)}\n`).join("");
|
||||
}
|
||||
|
||||
function fromJsonLines(text: string): Recorded[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as Recorded);
|
||||
}
|
||||
|
||||
function run(profile: Profile["name"], scenario: string): ScenarioRun {
|
||||
const recorded = runs.get(key(profile, scenario));
|
||||
if (!recorded) throw new Error(`no run of ${scenario} for ${profile}`);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/** Runs one more scenario for AIR without some AIR capabilities. */
|
||||
async function runAir(scenario: Scenario, without: string[] = []): Promise<Recorded[]> {
|
||||
resetIds();
|
||||
const profile: Profile = {
|
||||
name: "air",
|
||||
capabilities: {
|
||||
...PROFILES.air.capabilities,
|
||||
_meta: {
|
||||
...PROFILES.air.capabilities._meta,
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: AIR_CAPABILITY_NAMES.filter((name) => !without.includes(name)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return (await runScenario(Agent, profile, scenario)).raw;
|
||||
}
|
||||
|
||||
function updates(recorded: Recorded[]): Record<string, any>[] {
|
||||
return recorded
|
||||
.filter((record) => record.kind === "sessionUpdate")
|
||||
.map((record) => (record.payload as { update: Record<string, any> }).update);
|
||||
}
|
||||
|
||||
function toolCallReports(recorded: Recorded[], toolCallId?: string): Record<string, any>[] {
|
||||
return updates(recorded).filter(
|
||||
(update) =>
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
(toolCallId === undefined || update.toolCallId === toolCallId),
|
||||
);
|
||||
}
|
||||
|
||||
function permissionRequests(recorded: Recorded[]): Record<string, any>[] {
|
||||
return recorded
|
||||
.filter((record) => record.kind === "requestPermission")
|
||||
.map((record) => record.payload as Record<string, any>);
|
||||
}
|
||||
|
||||
/** Every `_meta` object in a message, with the path to it. */
|
||||
function metaObjects(value: unknown, at = "$"): { at: string; meta: Record<string, unknown> }[] {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
if (Array.isArray(value)) return value.flatMap((item, i) => metaObjects(item, `${at}[${i}]`));
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([k, item]) => [
|
||||
...(k === "_meta" && item && typeof item === "object" && !Array.isArray(item)
|
||||
? [{ at: `${at}._meta`, meta: item as Record<string, unknown> }]
|
||||
: []),
|
||||
...metaObjects(item, `${at}.${k}`),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The origin/main recording of a scenario. Zed has a file only where it differs from the plain client. */
|
||||
function readBaseline(profile: "plain" | "zed", scenario: string): string {
|
||||
const file = (name: string) =>
|
||||
path.join(here, "acp-scenarios", "origin-main", name, `${scenario}.jsonl`);
|
||||
return fs.readFileSync(
|
||||
profile === "zed" && !fs.existsSync(file("zed")) ? file("plain") : file(profile),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe.runIf(baselineDir)("the origin/main baseline", () => {
|
||||
it("writes the recordings of the plain and Zed profiles", () => {
|
||||
for (const profile of ["plain", "zed"] as const) {
|
||||
fs.mkdirSync(path.join(baselineDir!, profile), { recursive: true });
|
||||
for (const scenario of SCENARIOS) {
|
||||
const text = toJsonLines(run(profile, scenario.name).normalized);
|
||||
if (profile === "zed" && text === toJsonLines(run("plain", scenario.name).normalized)) {
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(path.join(baselineDir!, profile, `${scenario.name}.jsonl`), text);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
describe("normalize", () => {
|
||||
const cwd = "/tmp/acp-scenario-x";
|
||||
const session = "aaaaaaaa-0000-4000-8000-000000000001";
|
||||
const first = "bbbbbbbb-0000-4000-8000-000000000002";
|
||||
const second = "cccccccc-0000-4000-8000-000000000003";
|
||||
const scripted = "00000000-0000-4000-8000-00000000c001";
|
||||
const version = JSON.parse(
|
||||
fs.readFileSync(path.join(here, "..", "..", "package.json"), "utf8"),
|
||||
).version;
|
||||
const argv = process.argv.slice(1);
|
||||
|
||||
it("keeps a UUID that the run did not generate", () => {
|
||||
expect(normalize({ toolCallId: scripted }, cwd, session, new Set([first]))).toEqual({
|
||||
toolCallId: scripted,
|
||||
});
|
||||
});
|
||||
|
||||
it("names the generated ids in the order of their first appearance", () => {
|
||||
const generated = new Set([first, second]);
|
||||
const reports = (ids: string[]) =>
|
||||
normalize(
|
||||
ids.map((id) => ({ sessionId: session, toolCallId: id })),
|
||||
cwd,
|
||||
session,
|
||||
generated,
|
||||
);
|
||||
expect(reports([first, first])).toEqual([
|
||||
{ sessionId: "<session>", toolCallId: "<id-1>" },
|
||||
{ sessionId: "<session>", toolCallId: "<id-1>" },
|
||||
]);
|
||||
// A report that names another generated id, or the session id, still differs.
|
||||
expect(reports([first, second])).not.toEqual(reports([first, first]));
|
||||
expect(reports([first, session])).not.toEqual(reports([first, first]));
|
||||
});
|
||||
|
||||
it("replaces only the exact argv prefix of this process", () => {
|
||||
expect(argv.length).toBeGreaterThan(0);
|
||||
expect(normalize({ args: [...argv, "--cli", "login"] }, cwd, session)).toEqual({
|
||||
args: ["<argv>", "--cli", "login"],
|
||||
});
|
||||
// An extra argument stays in the recording, before or after the prefix.
|
||||
expect(normalize({ args: [...argv, "--inspect", "--cli"] }, cwd, session)).toEqual({
|
||||
args: ["<argv>", "--inspect", "--cli"],
|
||||
});
|
||||
const before = ["--inspect", ...argv, "--cli"];
|
||||
const { args } = normalize({ args: before }, cwd, session) as { args: string[] };
|
||||
expect(args).toHaveLength(before.length);
|
||||
expect(args).not.toContain("<argv>");
|
||||
expect(normalize({ args: ["other.js", "--cli"] }, cwd, session)).toEqual({
|
||||
args: ["other.js", "--cli"],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces only the version of this adapter and the path of this Node binary", () => {
|
||||
expect(normalize({ version, command: process.execPath }, cwd, session)).toEqual({
|
||||
version: "<version>",
|
||||
command: "<executable>",
|
||||
});
|
||||
expect(normalize({ version: "9.9.9", command: "/usr/bin/node" }, cwd, session)).toEqual({
|
||||
version: "9.9.9",
|
||||
command: "/usr/bin/node",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps durations and other numbers", () => {
|
||||
expect(normalize({ durationMs: 7, totalDurationMs: 10 }, cwd, session)).toEqual({
|
||||
durationMs: 7,
|
||||
totalDurationMs: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema check", () => {
|
||||
const none = new Set<string>();
|
||||
const elicitation = (url: string): Recorded => ({
|
||||
kind: "createElicitation",
|
||||
payload: {
|
||||
sessionId: "s",
|
||||
message: "Sign in",
|
||||
mode: "url",
|
||||
elicitationId: "e",
|
||||
url,
|
||||
},
|
||||
});
|
||||
const location = (line: number): Recorded => ({
|
||||
kind: "sessionUpdate",
|
||||
payload: {
|
||||
sessionId: "s",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "t",
|
||||
locations: [{ path: "/a.ts", line }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("rejects a value that breaks a standard format", () => {
|
||||
expect(validateRecorded(elicitation("https://example.com/login"), none)).toEqual([]);
|
||||
expect(validateRecorded(elicitation("not a uri"), none)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a value that breaks an ACP integer format", () => {
|
||||
expect(validateRecorded(location(2 ** 32 - 1), none)).toEqual([]);
|
||||
expect(validateRecorded(location(2 ** 32), none)).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("air golden files", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run("air", scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "air", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(profiles.map((profile) => profile.name))("%s ACP schema", (profile) => {
|
||||
const extensions = profile === "air" ? EXTENSION_SESSION_UPDATES : new Set<string>();
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends only valid messages",
|
||||
(scenario) => {
|
||||
const errors = run(profile, scenario).raw.flatMap((record) =>
|
||||
validateRecorded(record, extensions),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(profiles.map((profile) => profile.name))("%s session ids", (profile) => {
|
||||
// Only AIR negotiates native subagent sessions. There a child session
|
||||
// gets updates after its parent announced it with `subagent_spawned`.
|
||||
const nativeSubagents = profile === "air";
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends every session/update to the ACP session",
|
||||
(scenario) => {
|
||||
const { sessionId, raw } = run(profile, scenario);
|
||||
const sessions = new Set([sessionId]);
|
||||
const wrong: string[] = [];
|
||||
for (const record of raw) {
|
||||
if (record.kind !== "sessionUpdate") continue;
|
||||
const payload = record.payload as { sessionId: string; update: Record<string, any> };
|
||||
if (!sessions.has(payload.sessionId)) {
|
||||
wrong.push(`${payload.sessionId}: ${payload.update.sessionUpdate}`);
|
||||
continue;
|
||||
}
|
||||
if (nativeSubagents && payload.update.sessionUpdate === "subagent_spawned") {
|
||||
sessions.add(payload.update.subagentSessionId);
|
||||
}
|
||||
}
|
||||
expect(wrong).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(["plain", "zed"] as const)("%s client", (profile) => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s carries the same information as origin/main",
|
||||
(scenario) => {
|
||||
const baseline = fromJsonLines(readBaseline(profile, scenario));
|
||||
expect(compareWithBaseline(baseline, run(profile, scenario).normalized)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends no key that exists only for AIR",
|
||||
(scenario) => {
|
||||
const found = metaObjects(run(profile, scenario).raw).flatMap(({ at, meta }) => [
|
||||
...Object.keys(meta)
|
||||
.filter((k) => AIR_ONLY_META_KEYS.has(k))
|
||||
.map((k) => `${at}.${k}`),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {})
|
||||
.filter((k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k))
|
||||
.map((k) => `${at}.claudeCode.${k}`),
|
||||
]);
|
||||
expect(found).toEqual([]);
|
||||
expect(
|
||||
updates(run(profile, scenario).raw).filter((update) =>
|
||||
EXTENSION_SESSION_UPDATES.has(update.sessionUpdate),
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("plain client", () => {
|
||||
it("gets the Bash output as a code block and the description as content", () => {
|
||||
const reports = toolCallReports(run("plain", "bash-foreground").raw, "toolu_bash");
|
||||
expect(reports[0]).not.toHaveProperty("_meta.terminal_info");
|
||||
expect(reports.some((r) => JSON.stringify(r.content ?? []).includes("List files"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reports.at(-2)).toMatchObject({
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "```console\na.ts\nb.ts\n```" } },
|
||||
],
|
||||
rawOutput: "a.ts\nb.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets the streamed partial input as rawInput, and the Write text in rawInput", () => {
|
||||
const reports = toolCallReports(run("plain", "write-new").raw, "toolu_write");
|
||||
expect(reports[0]).toMatchObject({ sessionUpdate: "tool_call", rawInput: {} });
|
||||
expect(reports.some((r) => r.rawInput?.file_path && !("content" in r.rawInput))).toBe(true);
|
||||
expect(reports.some((r) => r.rawInput?.content === "export const x = 1;\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("gets the whole tool call again in a permission request", () => {
|
||||
const [request] = permissionRequests(run("plain", "edit-with-permission").raw);
|
||||
expect(request.toolCall).toMatchObject({
|
||||
toolCallId: "toolu_edit",
|
||||
name: "Edit",
|
||||
status: "pending",
|
||||
kind: "edit",
|
||||
title: "Edit src/app.ts",
|
||||
rawInput: {
|
||||
old_string: "const value = 1;",
|
||||
new_string: "const value = 2;",
|
||||
},
|
||||
content: [{ type: "diff", oldText: "const value = 1;", newText: "const value = 2;" }],
|
||||
});
|
||||
expect(request).not.toHaveProperty("_meta");
|
||||
});
|
||||
|
||||
it("gets the question of a single AskUserQuestion as the title", () => {
|
||||
const reports = toolCallReports(run("plain", "ask-user-question").raw, "toolu_ask");
|
||||
expect(reports.map((r) => r.title)).toContain("Which database?");
|
||||
});
|
||||
|
||||
it("gets the upstream NotebookEdit rendering and the result text", () => {
|
||||
const reports = toolCallReports(run("plain", "notebook-edit").raw, "toolu_nb");
|
||||
expect(reports[0]).toMatchObject({ title: "NotebookEdit", kind: "other", content: [] });
|
||||
expect(reports.at(-1)).toMatchObject({
|
||||
status: "completed",
|
||||
rawOutput: "Updated cell cell-1 with print('hi')",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets every Task* plan, also a repeated one", () => {
|
||||
const plans = updates(run("plain", "task-create-update").raw).filter(
|
||||
(update) => update.sessionUpdate === "plan",
|
||||
);
|
||||
expect(plans.map((plan) => plan.entries[0]?.status)).toEqual([
|
||||
"pending",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zed", () => {
|
||||
const zed = (scenario: string) => run("zed", scenario).raw;
|
||||
|
||||
it("keeps terminal_info, terminal_output, and terminal_exit", () => {
|
||||
const reports = toolCallReports(zed("bash-foreground"), "toolu_bash");
|
||||
expect(reports[0]).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
content: [{ type: "terminal", terminalId: "toolu_bash" }],
|
||||
_meta: { terminal_info: { terminal_id: "toolu_bash" } },
|
||||
});
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_meta: { terminal_output: { terminal_id: "toolu_bash", data: "a.ts\nb.ts" } },
|
||||
}),
|
||||
);
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
_meta: expect.objectContaining({
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
for (const report of reports) {
|
||||
expect(report._meta ?? {}).not.toHaveProperty("terminal_output_delta");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the upstream claudeCode keys and the full PostToolUse toolResponse", () => {
|
||||
const reports = toolCallReports(zed("bash-foreground"), "toolu_bash");
|
||||
expect(reports[0]._meta.claudeCode).toEqual({ toolName: "Bash" });
|
||||
expect(reports.at(-1)?._meta.claudeCode).toEqual({
|
||||
toolName: "Bash",
|
||||
toolResponse: { stdout: "a.ts\nb.ts", stderr: "", interrupted: false, isImage: false },
|
||||
});
|
||||
const child = toolCallReports(zed("subagent-task-legacy"), "toolu_sub_read");
|
||||
expect(child[0]._meta.claudeCode).toEqual({
|
||||
toolName: "Read",
|
||||
parentToolUseId: "toolu_task",
|
||||
});
|
||||
// ACP does not merge `_meta` keys, so each update names the parent again.
|
||||
for (const report of child) {
|
||||
expect(report._meta.claudeCode).toMatchObject({
|
||||
toolName: "Read",
|
||||
parentToolUseId: "toolu_task",
|
||||
});
|
||||
}
|
||||
const [childRequest] = permissionRequests(zed("subagent-task-legacy"));
|
||||
expect(childRequest.toolCall._meta).toEqual({
|
||||
claudeCode: { toolName: "Bash", parentToolUseId: "toolu_task" },
|
||||
});
|
||||
const [mcpRequest] = permissionRequests(zed("mcp-tool"));
|
||||
expect(mcpRequest.toolCall._meta).toEqual({
|
||||
claudeCode: {
|
||||
toolName: "mcp__docs__search",
|
||||
mcpServer: { name: "docs", source: "project" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the full permission denial toolResponse", () => {
|
||||
const denial = toolCallReports(zed("permission-denied"), "toolu_denied").find(
|
||||
(report) => report.status === "failed",
|
||||
);
|
||||
expect(denial?._meta.claudeCode.toolResponse).toEqual({
|
||||
decisionReasonType: "rule",
|
||||
decisionReason: "Denied by rule Bash(rm:*)",
|
||||
message: "Permission to use Bash has been denied.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the _claude/* keys", () => {
|
||||
const [elicitation] = zed("ask-user-question")
|
||||
.filter((record) => record.kind === "createElicitation")
|
||||
.map((record) => record.payload as Record<string, any>);
|
||||
const options = elicitation.requestedSchema.properties.question_0.oneOf;
|
||||
expect(options[0]._meta).toHaveProperty(["_claude/askUserQuestionOption"]);
|
||||
const usage = updates(zed("rate-limit-and-origin")).filter(
|
||||
(update) => update.sessionUpdate === "usage_update",
|
||||
);
|
||||
expect(usage.some((update) => update._meta?.["_claude/rateLimit"])).toBe(true);
|
||||
expect(usage.some((update) => update._meta?.["_claude/origin"])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps promptQueueing and steering", () => {
|
||||
const initialize = zed("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
expect(initialize.agentCapabilities._meta.claudeCode).toEqual({ promptQueueing: true });
|
||||
expect(initialize._meta).toEqual({ steering: { supported: true } });
|
||||
});
|
||||
|
||||
it("sends terminal-auth commands that rerun this adapter with --cli", () => {
|
||||
// The raw traffic, because the recordings normalize the machine paths.
|
||||
const initialize = zed("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
const argv = process.argv.slice(1);
|
||||
expect(initialize.authMethods).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "claude-ai-login",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
_meta: {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...argv, "--cli", "auth", "login", "--claudeai"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "console-login",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
_meta: {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...argv, "--cli", "auth", "login", "--console"],
|
||||
label: "Anthropic Console Login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AIR", () => {
|
||||
const air = (scenario: string) => run("air", scenario).raw;
|
||||
const airMeta = (meta: Record<string, any> | undefined) => meta?.jetbrains?.air;
|
||||
|
||||
it("gets the AIR capabilities and the goal capability under jetbrains.air", () => {
|
||||
const initialize = air("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
expect(initialize._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: expect.arrayContaining(
|
||||
AIR_CAPABILITY_NAMES.filter((name) => name !== "rawInputRendering"),
|
||||
),
|
||||
goal: expect.objectContaining({ version: 1 }),
|
||||
},
|
||||
},
|
||||
steering: { supported: true },
|
||||
});
|
||||
const session = air("session-setup").find((record) => record.kind === "newSession")!
|
||||
.payload as Record<string, any>;
|
||||
for (const mode of session.modes.availableModes) {
|
||||
expect(mode._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, kind: expect.any(String) } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("gets commandTitle, subagent, and skill under jetbrains.air, each once", async () => {
|
||||
const bash = toolCallReports(air("bash-foreground"), "toolu_bash");
|
||||
expect(bash.filter((r) => airMeta(r._meta)?.commandTitle === "List files")).toHaveLength(1);
|
||||
// A native subagent session replaces the tool call of the subagent, so
|
||||
// the marker shows without native subagent sessions.
|
||||
const [nested] = toolCallReports(
|
||||
await runAir(
|
||||
{
|
||||
name: "nested-agent",
|
||||
turns: [
|
||||
async function* () {
|
||||
yield* assistantTurn("msg_nested", [
|
||||
{ type: "tool_use", id: "toolu_nested", name: "Agent", input: { prompt: "Go" } },
|
||||
]);
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
},
|
||||
["nativeSubagentSessions"],
|
||||
),
|
||||
"toolu_nested",
|
||||
);
|
||||
expect(airMeta(nested?._meta)?.subagent).toBe(true);
|
||||
const skill = toolCallReports(air("skill"), "toolu_skill");
|
||||
expect(skill.filter((r) => airMeta(r._meta)?.skill)).toEqual([
|
||||
expect.objectContaining({
|
||||
_meta: expect.objectContaining({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
skill: { name: "commits", path: expect.stringMatching(/SKILL\.md$/) },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("gets the permission, customAnswer, goal, and contextCompaction keys", () => {
|
||||
const [request] = permissionRequests(air("bash-foreground"));
|
||||
expect(request._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, permission: { version: 1, title: "ls -la" } } },
|
||||
});
|
||||
const elicitation = air("ask-user-question").find(
|
||||
(record) => record.kind === "createElicitation",
|
||||
)!.payload as Record<string, any>;
|
||||
expect(elicitation.requestedSchema.properties.question_0_custom._meta).toEqual({
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { version: 1, customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
});
|
||||
const goal = updates(air("goal")).find((u) => u.sessionUpdate === "session_info_update");
|
||||
expect(goal?._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
goal: expect.objectContaining({ objective: "Ship the feature", status: "active" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const compaction = toolCallReports(air("compaction-legacy"));
|
||||
expect(compaction.at(-1)?._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
contextCompaction: expect.objectContaining({ version: 1, preTokens: 1000 }),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(compaction.at(-1)).not.toHaveProperty("rawOutput");
|
||||
});
|
||||
|
||||
it("sends no upstream copy of an AIR key", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const { at, meta } of metaObjects(air(scenario.name))) {
|
||||
const duplicates = [
|
||||
// Released AIR versions read only the legacy custom answer key, so AIR gets both.
|
||||
...Object.keys(meta).filter(
|
||||
(k) =>
|
||||
AIR_ONLY_META_KEYS.has(k) &&
|
||||
k !== "jetbrains" &&
|
||||
k !== LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {}).filter(
|
||||
(k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k),
|
||||
),
|
||||
];
|
||||
expect(duplicates, `${scenario.name} ${at}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("sends each tool fact in one field", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const report of toolCallReports(air(scenario.name))) {
|
||||
const where = `${scenario.name} ${report.toolCallId}`;
|
||||
// Output is never copied into rawOutput when content carries it.
|
||||
if (report.rawOutput !== undefined) {
|
||||
expect(report.content ?? [], where).toEqual([]);
|
||||
}
|
||||
// Terminal output goes to deltas, never to snapshots.
|
||||
expect(report._meta ?? {}, where).not.toHaveProperty("terminal_output");
|
||||
}
|
||||
}
|
||||
const write = toolCallReports(air("write-new"), "toolu_write");
|
||||
for (const report of write) expect(report.rawInput ?? {}).not.toHaveProperty("content");
|
||||
expect(write[0]).not.toHaveProperty("rawInput");
|
||||
});
|
||||
|
||||
describe("ExitPlanMode with a plan file", () => {
|
||||
const plan = "# Plan\n1. Do it";
|
||||
/**
|
||||
* The CLI streams the input that the model wrote, which has no plan.
|
||||
* The complete message and canUseTool get the text and the path of the
|
||||
* plan file. The structured result names the file again.
|
||||
*/
|
||||
const planFileScenario = (options: { file: boolean; reject?: boolean }): Scenario => ({
|
||||
name: "exit-plan-file",
|
||||
...(options.file ? { files: { "plans/plan.md": plan } } : {}),
|
||||
...(options.reject ? { permission: "reject_once" } : {}),
|
||||
turns: [
|
||||
async function* (ctx) {
|
||||
const planFilePath = path.join(ctx.cwd, "plans", "plan.md");
|
||||
const input = { plan, planFilePath };
|
||||
yield* streamMessage("msg_toolu_plan", [
|
||||
{ type: "tool_use", id: "toolu_plan", name: "ExitPlanMode", input: {} },
|
||||
]);
|
||||
yield* toolCall(
|
||||
ctx,
|
||||
{ id: "toolu_plan", name: "ExitPlanMode", input },
|
||||
options.reject
|
||||
? {
|
||||
ask: true,
|
||||
isError: true,
|
||||
content: "```\nThe user doesn't want to proceed with this tool use.\n```",
|
||||
}
|
||||
: {
|
||||
ask: true,
|
||||
content: `User has approved your plan.\n\n## Approved Plan:\n${plan}`,
|
||||
structured: { plan, isAgent: false, filePath: planFilePath },
|
||||
},
|
||||
);
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
});
|
||||
it("sends the path and no plan text in every report", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true }));
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
const [request] = permissionRequests(recorded);
|
||||
const withInput = [...reports, request.toolCall].filter((r) => "rawInput" in r);
|
||||
expect(withInput.length).toBeGreaterThan(0);
|
||||
for (const report of withInput) {
|
||||
expect(report.rawInput).toEqual({ planFilePath: expect.stringMatching(/plan\.md$/) });
|
||||
expect(path.isAbsolute(report.rawInput.planFilePath)).toBe(true);
|
||||
}
|
||||
expect(reports[0]).toMatchObject({ sessionUpdate: "tool_call" });
|
||||
expect(reports[0]).not.toHaveProperty("rawInput");
|
||||
expect(reports.at(-1)).toMatchObject({ status: "completed", title: "Exited Plan Mode" });
|
||||
expect(JSON.stringify([reports, request])).not.toContain("Do it");
|
||||
});
|
||||
|
||||
it("sends the path with a rejection", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true, reject: true }));
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ planFilePath: expect.any(String) });
|
||||
expect(reports.at(-1)).toMatchObject({ status: "failed" });
|
||||
expect(JSON.stringify([reports, request])).not.toContain("Do it");
|
||||
});
|
||||
|
||||
it("sends the plan text when the plan file does not exist", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: false }));
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
});
|
||||
|
||||
it("sends the plan text to an AIR client without planFile", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true }), ["planFile"]);
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
});
|
||||
|
||||
it("sends the whole input to a client that is not AIR", async () => {
|
||||
for (const profile of [PROFILES.plain, PROFILES.zed]) {
|
||||
resetIds();
|
||||
const recorded = (await runScenario(Agent, profile, planFileScenario({ file: true })))
|
||||
.raw;
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
expect(request.toolCall.content).toEqual([
|
||||
{ type: "content", content: { type: "text", text: plan } },
|
||||
]);
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
expect(reports.some((r) => r.rawInput?.plan === plan)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("drops every update of a tool call of a finished child session", () => {
|
||||
const recorded = air("subagent-late-child-update");
|
||||
const finished = recorded.findIndex(
|
||||
(record) =>
|
||||
record.kind === "sessionUpdate" &&
|
||||
(record.payload as { update: Record<string, any> }).update.sessionUpdate ===
|
||||
"subagent_state_update",
|
||||
);
|
||||
expect(finished).toBeGreaterThan(0);
|
||||
const late = recorded
|
||||
.slice(finished)
|
||||
.filter((record) => record.kind === "sessionUpdate")
|
||||
.map((record) => record.payload as { sessionId: string; update: Record<string, any> })
|
||||
.filter(({ update }) => update.toolCallId === "toolu_late_read");
|
||||
expect(late).toEqual([]);
|
||||
for (const record of recorded.filter((r) => r.kind === "sessionUpdate")) {
|
||||
const { sessionId, update } = record.payload as {
|
||||
sessionId: string;
|
||||
update: Record<string, any>;
|
||||
};
|
||||
if (update.toolCallId === "toolu_late_read") expect(sessionId).toBe("agent_late");
|
||||
}
|
||||
});
|
||||
|
||||
it("gets the Write patch, but no file text in the PostToolUse toolResponse", async () => {
|
||||
const scenario = writtenFileScenario();
|
||||
const hookReport = (recorded: Recorded[]) =>
|
||||
toolCallReports(recorded, "toolu_write").find(
|
||||
(report) =>
|
||||
report._meta?.claudeCode?.toolResponse !== undefined || report.content?.[0]?._meta,
|
||||
);
|
||||
const airReport = hookReport(await runAir(scenario));
|
||||
expect(airReport?.content?.[0]?._meta?.jetbrains?.air?.diffPatch?.text).toContain(
|
||||
"-export const x = 0;\n+export const x = 1;\n",
|
||||
);
|
||||
expect(airReport?._meta?.claudeCode?.toolResponse).toBeUndefined();
|
||||
expect(JSON.stringify(airReport)).not.toContain("originalFile");
|
||||
// A client that is not AIR keeps the full toolResponse of origin/main.
|
||||
resetIds();
|
||||
const plain = (await runScenario(Agent, PROFILES.plain, scenario)).raw;
|
||||
expect(hookReport(plain)?._meta?.claudeCode?.toolResponse).toMatchObject({
|
||||
type: "update",
|
||||
content: "export const x = 1;\n",
|
||||
originalFile: "export const x = 0;\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends no creation patch over a file and no Edit patch before the preview", async () => {
|
||||
const patches = (reports: Record<string, any>[]) =>
|
||||
reports.flatMap((report) =>
|
||||
(report.content ?? [])
|
||||
.map((block: any) => block._meta?.jetbrains?.air?.diffPatch?.text)
|
||||
.filter((text: unknown) => text !== undefined),
|
||||
);
|
||||
// Streamed: the Write of an existing file gets only the update patch of
|
||||
// its hook, and the Edit gets no patch before its approval.
|
||||
const writePatches = patches(toolCallReports(air("write-existing"), "toolu_write"));
|
||||
expect(writePatches).toHaveLength(1);
|
||||
expect(writePatches[0]).not.toContain("new file mode");
|
||||
expect(writePatches[0]).toContain("-export const x = 0;\n+export const x = 1;");
|
||||
const edit = air("edit-with-permission");
|
||||
const approval = edit.findIndex((record) => record.kind === "requestPermission");
|
||||
expect(approval).toBeGreaterThan(0);
|
||||
expect(patches(toolCallReports(edit.slice(0, approval), "toolu_edit"))).toEqual([]);
|
||||
// Replayed: session/load renders the same tool uses from the transcript.
|
||||
const replay = await runAir(replayedEditsScenario());
|
||||
expect(patches(toolCallReports(replay))).toEqual([]);
|
||||
// A replay does not read old.ts: the disk shows a later state than the history.
|
||||
expect(toolCallReports(replay, "toolu_r_write")[0].content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: expect.stringMatching(/old\.ts$/),
|
||||
oldText: null,
|
||||
newText: "export const x = 1;\n",
|
||||
},
|
||||
]);
|
||||
expect(toolCallReports(replay, "toolu_r_edit")[0].content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: expect.stringMatching(/app\.ts$/),
|
||||
oldText: "const value = 1;",
|
||||
newText: "const value = 2;",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the Bash output as terminal deltas", () => {
|
||||
const reports = toolCallReports(air("bash-foreground"), "toolu_bash");
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_meta: { terminal_output_delta: { terminal_id: "toolu_bash", data: "a.ts\nb.ts" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** A Write that changes the file on disk before its PostToolUse hook runs. */
|
||||
function writtenFileScenario(): Scenario {
|
||||
return {
|
||||
name: "write-on-disk",
|
||||
files: { "old.ts": "export const x = 0;\n" },
|
||||
turns: [
|
||||
async function* (ctx) {
|
||||
const file = path.join(ctx.cwd, "old.ts");
|
||||
const input = { file_path: file, content: "export const x = 1;\n" };
|
||||
yield* assistantTurn("msg_write", [
|
||||
{ type: "tool_use", id: "toolu_write", name: "Write", input },
|
||||
]);
|
||||
fs.writeFileSync(file, input.content);
|
||||
yield toolResult("toolu_write", `The file ${file} has been updated successfully.`);
|
||||
await ctx.postToolUse("toolu_write", "Write", input, {
|
||||
type: "update",
|
||||
filePath: file,
|
||||
content: input.content,
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 1,
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
lines: ["-export const x = 0;", "+export const x = 1;"],
|
||||
},
|
||||
],
|
||||
originalFile: "export const x = 0;\n",
|
||||
});
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A session/load of a Write over an existing file and of an Edit. */
|
||||
function replayedEditsScenario(): Scenario {
|
||||
return {
|
||||
name: "replayed-edits",
|
||||
files: { "old.ts": "export const x = 0;\n", "app.ts": "line 1\nconst value = 1;\n" },
|
||||
transcript: ({ cwd, sessionId }) => [
|
||||
{
|
||||
type: "user",
|
||||
uuid: "00000000-0000-4000-8000-00000000f001",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: { role: "user", content: "Change both files" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "00000000-0000-4000-8000-00000000f002",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
id: "msg_r_edits",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_r_write",
|
||||
name: "Write",
|
||||
input: { file_path: path.join(cwd, "old.ts"), content: "export const x = 1;\n" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_r_edit",
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: path.join(cwd, "app.ts"),
|
||||
old_string: "const value = 1;",
|
||||
new_string: "const value = 2;",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
turns: [],
|
||||
};
|
||||
}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"_askUserQuestionCustomAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_1","canStop":true,"description":"Build assets","name":"Build assets","outputFilePath":"/tmp/tasks/workflow_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
5
src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl
generated
Normal file
5
src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","canStop":true,"description":"Watch the logs","name":"Watch the logs","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow","toolCallId":"toolu_workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"first line","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"second line","usage":{"durationMs":6,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
3
src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl
generated
Normal file
3
src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_2","canStop":true,"description":"Build assets","name":"Build assets","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bg"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Wait","version":1}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","canStop":true,"description":"sleep 100","name":"sleep 100","outputFilePath":"/tmp/tasks/bash_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"shell","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"asyncTasks":{"backgrounded":true}}},"terminal_output_delta":{"data":"Command running in background with ID: bash_1. Output is being written to: /tmp/tasks/bash_1.output. You will be notified when it completes.","terminal_id":"toolu_bg"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_bg"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","sessionUpdate":"async_task_progress","summary":"still running","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Fail","version":1}}},"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"Exit code 1\nboom","terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"List files","version":1}}},"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"ls -la","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"command":"ls -la","description":"List files"},"title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"a.ts\nb.ts","terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Do","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"ne.","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"error":"Context too small","version":1}}}},"content":[{"content":{"text":"Compaction failed: Context too small","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1}}}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"newText":"value = 2","oldText":"value = 1","path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit src/app.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/src/app.ts b<cwd>/src/app.ts\n--- a<cwd>/src/app.ts\n+++ b<cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\nThe user doesn't want to proceed with this tool use. The tool use was rejected.\n```","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"newText":"const value = 2;","oldText":"const value = 1;","path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit src/app.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/src/app.ts b<cwd>/src/app.ts\n--- a<cwd>/src/app.ts\n+++ b<cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"newText":"line 1\nconst value = 2;\nline 3","oldText":"line 1\nconst value = 1;\nline 3","path":"<cwd>/src/app.ts","type":"diff"}],"locations":[{"line":1,"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Ready to code?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"currentModeId":"default","sessionUpdate":"current_mode_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","title":"Exited Plan Mode","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Ready to code?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The user doesn't want to proceed with this tool use.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
3
src/tests/acp-scenarios/__snapshots__/air/goal.jsonl
generated
Normal file
3
src/tests/acp-scenarios/__snapshots__/air/goal.jsonl
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"goal":{"controlMethod":"_session/goal","objective":"Ship the feature","status":"active"},"version":1}}},"sessionUpdate":"session_info_update"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"grep \"value\"","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"grep \"value\" src","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"-n":true,"output_mode":"content","path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep -n \"value\" src","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Glob"}},"content":[],"kind":"search","locations":[],"name":"Glob","sessionUpdate":"tool_call","status":"pending","title":"Find","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"pattern":"**/*.ts"},"sessionUpdate":"tool_call_update","title":"Find `**/*.ts`","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"src/app.ts\nsrc/b.ts","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"},"is_mcp_tool_call":true},"content":[],"kind":"other","name":"mcp__docs__search","sessionUpdate":"tool_call","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"mcp__docs__search","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"rawInput":{"q":"acp"},"title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Found 2 documents.","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"select"}}},"kind":"read","locations":[{"path":"/memory/a.md"},{"path":"/memory/b.md"}],"sessionUpdate":"tool_call","status":"completed","title":"Recalled 2 memories","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"synthesize"}}},"content":[{"content":{"text":"The user prefers tabs.","type":"text"},"type":"content"}],"kind":"read","sessionUpdate":"tool_call","status":"completed","title":"Recalled synthesized memory","toolCallId":"00000000-0000-4000-8000-000000000002"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"SandboxNetworkAccess"}},"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Reason: Sandbox blocks network","title":"example.com","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"host":"example.com"},"title":"example.com","toolCallId":"toolu_net"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"content":[],"kind":"edit","locations":[],"name":"NotebookEdit","sessionUpdate":"tool_call","status":"pending","title":"Edit notebook","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/n.ipynb"}],"sessionUpdate":"tool_call_update","title":"Edit cell in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"Edit cell cell-1 in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit cell cell-1 in n.ipynb","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"title":"Edit cell cell-1 in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_denied"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"rm -rf /"},"sessionUpdate":"tool_call_update","title":"rm -rf /","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"decisionReasonType":"rule","message":"Permission to use Bash has been denied."}}},"content":[{"content":{"text":"Permission denied: Denied by rule Bash(rm:*)","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"Permission to use Bash has been denied.","terminal_id":"toolu_denied"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_denied"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
8
src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Work","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"ing.","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6","_claude/rateLimit":{"resetsAt":1700000000,"status":"allowed_warning","utilization":0.9}},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6","_claude/origin":{"kind":"human"}},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
8
src/tests/acp-scenarios/__snapshots__/air/read.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/air/read.jsonl
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/a.ts"}],"rawInput":{"file_path":"<cwd>/a.ts"},"sessionUpdate":"tool_call_update","title":"Read a.ts","toolCallId":"toolu_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
15
src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl
generated
Normal file
15
src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"jetbrains":{"air":{"commandTitle":"List","version":1}},"terminal_info":{"terminal_id":"toolu_r_bash"}},"content":[{"terminalId":"toolu_r_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls","description":"List"},"sessionUpdate":"tool_call","status":"pending","title":"ls","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[{"newText":"b","oldText":"a","path":"<cwd>/src/app.ts","type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call","status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_r_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Explore","sessionUpdate":"subagent_spawned","subagentSessionId":"<session>:replay-subagent:toolu_r_task","task":"Look around"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"app.ts","terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_r_task"}},"content":{"text":"Subagent text.","type":"text"},"messageId":"msg_r_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"<session>:replay-subagent:toolu_r_task"}}}
|
||||
{"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
3
src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl
generated
Normal file
3
src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"newSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
8
src/tests/acp-scenarios/__snapshots__/air/skill.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/air/skill.jsonl
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Skill"}},"content":[],"kind":"other","name":"Skill","sessionUpdate":"tool_call","status":"pending","title":"Load skill","toolCallId":"toolu_skill"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"skill":{"name":"commits","path":"<cwd>/.claude/skills/commits/SKILL.md"},"version":1}}},"rawInput":{"skill":"commits"},"sessionUpdate":"tool_call_update","title":"Load skill: commits","toolCallId":"toolu_skill"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"Launching skill: commits","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_skill"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent","toolResponse":{"isAsync":true,"status":"async_launched"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Background review","prompt":"Review the diff","sessionUpdate":"subagent_spawned","subagentSessionId":"a2","task":"Review the diff"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"a2"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Check the logs","prompt":"Check logs","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_late","task":"Check logs"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_late"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
19
src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl
generated
Normal file
19
src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Fix the build","prompt":"Make the build pass","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_n","task":"Make the build pass"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","title":"grep \"TODO\"","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"rawInput":{"path":"/src","pattern":"TODO"},"sessionUpdate":"tool_call_update","title":"grep \"TODO\" /src","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_native_bash"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Build","version":1}}},"rawInput":{"command":"make","description":"Build"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"make","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_n","toolCall":{"rawInput":{"command":"make","description":"Build"},"title":"make","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"terminal_output_delta":{"data":"make: *** Error 2","terminal_id":"toolu_native_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_native_bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"failed","subagentSessionId":"agent_n"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Plan the work","prompt":"Plan it","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_outer","task":"Plan it"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"capabilities":{},"name":"Read the spec","prompt":"Read spec.md","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_inner","task":"Read spec.md"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_inner"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":"Plan:","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":" do X.","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_outer"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
27
src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl
generated
Normal file
27
src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Explore code","prompt":"Find the parser","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_1","task":"Find the parser"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"locations":[{"line":1,"path":"/x.ts"}],"rawInput":{"file_path":"/x.ts"},"sessionUpdate":"tool_call_update","title":"Read /x.ts","toolCallId":"toolu_sub_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_sub_bash"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"tool_call_update","title":"rg parser","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Search","version":1}}},"rawInput":{"command":"rg parser","description":"Search"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"rg parser","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","toolCall":{"rawInput":{"command":"rg parser","description":"Search"},"title":"rg parser","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"terminal_output_delta":{"data":"x.ts","terminal_id":"toolu_sub_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_sub_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"locations":[{"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"content":[{"newText":"b","oldText":"a","path":"<cwd>/x.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/x.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit x.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","toolCall":{"rawInput":{"file_path":"<cwd>/x.ts"},"title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"rawOutput":"The file has been updated.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Foun","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"d it.","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task","toolResponse":{"status":"completed"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_1"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Summarize","prompt":"Summarize the repo","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_t","task":"Summarize the repo"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"two packages.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript","toolName":"Glob"}},"content":[],"kind":"search","locations":[],"name":"Glob","sessionUpdate":"tool_call","status":"pending","title":"Find","toolCallId":"toolu_transcript_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"rawInput":{"pattern":"*"},"sessionUpdate":"tool_call_update","title":"Find `*`","toolCallId":"toolu_transcript_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"content":[{"content":{"text":"a\nb","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_transcript_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_t"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
16
src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl
generated
Normal file
16
src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Write tests","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Write tests","priority":"medium","status":"in_progress"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Write tests","priority":"medium","status":"completed"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"All ","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"done.","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
12
src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl
generated
Normal file
12
src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskOutput"}},"content":[],"kind":"other","name":"TaskOutput","sessionUpdate":"tool_call","status":"pending","title":"TaskOutput","toolCallId":"toolu_out"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"block":true,"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_out"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"<retrieval_status>success</retrieval_status>\n<output>done</output>","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_out"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskStop"}},"content":[],"kind":"other","name":"TaskStop","sessionUpdate":"tool_call","status":"pending","title":"TaskStop","toolCallId":"toolu_stop"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_stop"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Successfully stopped task: bash_1","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_stop"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Let me thin","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"k about it.","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Here is t","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"he answer.","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
6
src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl
generated
Normal file
6
src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Writing tests","priority":"medium","status":"in_progress"},{"content":"Ship","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
12
src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl
generated
Normal file
12
src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_slow"}},"content":[{"terminalId":"toolu_slow","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"make"},"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":1}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":2}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":3}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"built","terminal_id":"toolu_slow"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_slow"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/web-fetch-and-search.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/web-fetch-and-search.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"content":[],"kind":"fetch","name":"WebFetch","sessionUpdate":"tool_call","status":"pending","title":"Fetch","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"Fetch https://example.com","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Fetch https://example.com","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"allow_always","name":"Yes, and don't ask again for example.com","optionId":"allow-with-updates"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"prompt":"Summarize the page","url":"https://example.com"},"title":"Fetch https://example.com","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"The page is an example.","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_fetch"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"WebSearch"}},"content":[],"kind":"fetch","name":"WebSearch","sessionUpdate":"tool_call","status":"pending","title":"Web search","toolCallId":"toolu_search"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"query":"acp protocol"},"sessionUpdate":"tool_call_update","title":"Search \"acp protocol\"","toolCallId":"toolu_search"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"ACP (https://agentclientprotocol.com)\nA summary.","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_search"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/write-existing.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/write-existing.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","sessionUpdate":"tool_call","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/old.ts"}],"sessionUpdate":"tool_call_update","title":"Write old.ts","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/old.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/old.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/old.ts b<cwd>/old.ts\n--- a<cwd>/old.ts\n+++ b<cwd>/old.ts\n@@ -1 +1 @@\n-export const x = 0;\n+export const x = 1;\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/old.ts","type":"diff"}],"locations":[{"line":1,"path":"<cwd>/old.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/write-new.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/write-new.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","sessionUpdate":"tool_call","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/new.ts"}],"sessionUpdate":"tool_call_update","title":"Write new.ts","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"file_path":"<cwd>/new.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_write"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Write new.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/new.ts b<cwd>/new.ts\nnew file mode 100644\n--- /dev/null\n+++ b<cwd>/new.ts\n@@ -0,0 +1 @@\n+export const x = 1;\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/new.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/new.ts"},"title":"Write new.ts","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"File created successfully at: <cwd>/new.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_write"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
16
src/tests/acp-scenarios/__snapshots__/v2/ask-user-question.jsonl
generated
Normal file
16
src/tests/acp-scenarios/__snapshots__/v2/ask-user-question.jsonl
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[{"content":{"text":"Which database?","type":"text"},"type":"content"}],"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","title":"Which database?","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"requires_action"}}}
|
||||
{"kind":"request","method":"elicitation/create","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"rawOutput":"User has answered your questions: \"Which database?\"=\"Postgres\".","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-ends-before-tool-id.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-ends-before-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-held-until-tool-id.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-held-until-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-released-at-turn-end.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/v2/async-task-released-at-turn-end.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
19
src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl
generated
Normal file
19
src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"sleep 100","sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100"},"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100","description":"Wait"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"Q29tbWFuZCBydW5uaW5nIGluIGJhY2tncm91bmQgd2l0aCBJRDogYmFzaF8xLiBPdXRwdXQgaXMgYmVpbmcgd3JpdHRlbiB0bzogL3RtcC90YXNrcy9iYXNoXzEub3V0cHV0LiBZb3Ugd2lsbCBiZSBub3RpZmllZCB3aGVuIGl0IGNvbXBsZXRlcy4="},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
18
src/tests/acp-scenarios/__snapshots__/v2/bash-error.jsonl
generated
Normal file
18
src/tests/acp-scenarios/__snapshots__/v2/bash-error.jsonl
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"configOptions":[{"category":"mode","configId":"mode","currentValue":"default","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"command":"false","sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"false"},"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"RXhpdCBjb2RlIDEKYm9vbQ=="},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user