mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-04 00:12:14 +00:00
Compare commits
62 Commits
v0.76.0
...
v0.85.2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||
47
.github/workflows/publish.yml
vendored
47
.github/workflows/publish.yml
vendored
@@ -67,6 +67,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
version: ${{ steps.publish.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
with:
|
||||
@@ -78,7 +80,12 @@ 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.
|
||||
@@ -207,7 +214,45 @@ jobs:
|
||||
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.76.0"
|
||||
".": "0.85.1"
|
||||
}
|
||||
|
||||
151
CHANGELOG.md
151
CHANGELOG.md
@@ -1,5 +1,156 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
13
README.md
13
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,10 +17,12 @@ 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)
|
||||
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/recommended-config-values-extension.md)
|
||||
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/permission-extension.md)
|
||||
- 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/).
|
||||
|
||||
@@ -40,7 +43,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
|
||||
|
||||
|
||||
@@ -55,14 +55,18 @@ 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. 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.
|
||||
`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 and
|
||||
the registry update run only after the version has been published to npm.
|
||||
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
|
||||
|
||||
1270
docs/air-extensions.md
Normal file
1270
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,118 +0,0 @@
|
||||
# Recommended config values extension
|
||||
|
||||
This document defines the experimental AIR `recommendedValue` session configuration extension
|
||||
implemented by `claude-agent-acp`. It lets a client show concrete model and effort choices without
|
||||
an ambiguous `default` row while preserving legacy config options for every client that does not
|
||||
opt in.
|
||||
|
||||
Permission modes and all other session config options are outside this extension and remain
|
||||
unchanged.
|
||||
|
||||
## Capability negotiation
|
||||
|
||||
A client opts in through the common AIR capability list in `initialize`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientCapabilities": {
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"capabilities": ["recommendedValue"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The adapter enables the extension only when the AIR version is a finite integer greater than or
|
||||
equal to `1` and `capabilities` contains the singular `recommendedValue` capability. Otherwise it
|
||||
preserves the existing `default` rows, current values, and metadata exactly. The adapter advertises
|
||||
the same capability in its top-level initialize-response `_meta.jetbrains.air.capabilities` list.
|
||||
|
||||
## Config option metadata
|
||||
|
||||
For each selector transformed by the extension, the adapter writes the concrete recommendation
|
||||
through the common AIR metadata envelope at `_meta.jetbrains.air.recommendedValue`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "model",
|
||||
"type": "select",
|
||||
"currentValue": "sonnet",
|
||||
"options": [
|
||||
{ "value": "opus", "name": "Claude Opus" },
|
||||
{ "value": "sonnet", "name": "Claude Sonnet" },
|
||||
{ "value": "haiku", "name": "Claude Haiku" }
|
||||
],
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"recommendedValue": "sonnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`recommendedValue` always names one of that selector's advertised option values. It is independent
|
||||
from `currentValue`: a user's explicit selection remains current even when the SDK recommends a
|
||||
different value.
|
||||
|
||||
## Model behavior
|
||||
|
||||
The SDK's `default` model entry may carry a `resolvedModel` identifying the model currently
|
||||
recommended by Claude. The adapter matches that identifier exactly to a selectable named model
|
||||
(accepting equivalent `-1m` and `[1m]` suffix spellings),
|
||||
removes the `default` row, and advertises the named value in
|
||||
`_meta.jetbrains.air.recommendedValue`. When the session itself is still using the SDK default, the
|
||||
same concrete value is presented as `currentValue`.
|
||||
|
||||
If the SDK recommendation cannot be mapped to an advertised named model, the adapter retains the
|
||||
legacy `default` row for that selector and omits `recommendedValue`. This prevents a client from
|
||||
receiving a recommendation or current value it cannot select.
|
||||
|
||||
Terse SDK labels that match their underlying model family include the concrete version derived
|
||||
from model metadata: for example, `Sonnet` becomes `Sonnet 5`, `Fable` becomes `Fable 5.1`, and
|
||||
`Claude Haiku` becomes `Claude Haiku 4.5`.
|
||||
Context suffixes such as `Opus (1M context)` are omitted from the label because the context remains
|
||||
in the description. Custom labels are preserved, and normalization falls back to the original
|
||||
labels if two selectable entries would otherwise collide. This presentation also applies to
|
||||
legacy clients.
|
||||
|
||||
## Effort behavior
|
||||
|
||||
When the current model supports effort selection, the adapter removes the `default` effort row and
|
||||
advertises `medium` as the recommendation. An existing explicit or settings-derived effort remains
|
||||
the `currentValue`; an absent or legacy `default` current effort is presented as `medium`.
|
||||
|
||||
The adapter applies the displayed effort to the SDK on session creation and model switches, so
|
||||
the concrete selection reflects the effort actually used. Explicit SDK `options.effort` and ACP
|
||||
picker choices remain pinned across model switches while supported. Otherwise each switch
|
||||
re-reads the new model's settings before falling back to the recommendation. Switching to a
|
||||
model without effort support clears the flag override. Legacy clients continue to let the SDK
|
||||
resolve automatic effort.
|
||||
|
||||
If effort synchronization fails after the model has already switched, the adapter still reports
|
||||
the new model but never presents the unapplied effort as current. It retains the last successfully
|
||||
applied value when that value is selectable for the new model; otherwise it temporarily omits the
|
||||
effort selector until a later successful switch can rebuild truthful state.
|
||||
|
||||
If a future model exposes effort choices without `medium`, the first SDK-advertised effort level is
|
||||
used so both `recommendedValue` and `currentValue` remain valid option values.
|
||||
|
||||
## SDK upgrade check
|
||||
|
||||
The ordinary test suite pins the SDK version whose Opus label was verified. When updating the
|
||||
SDK, first run the live contract test in an authenticated environment:
|
||||
|
||||
```sh
|
||||
RUN_INTEGRATION_TESTS=true npx vitest run src/tests/model-presentation.test.ts
|
||||
```
|
||||
|
||||
Review any changes to the available Opus entries and their labels before updating the version
|
||||
guard and, if necessary, the normalization. The live test only initializes the SDK; it sends no
|
||||
model prompt.
|
||||
@@ -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
|
||||
```
|
||||
767
package-lock.json
generated
767
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.76.0",
|
||||
"version": "0.85.1",
|
||||
"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.286",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.122.0",
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@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.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.0",
|
||||
"@typescript-eslint/parser": "8.71.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.11.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": "5.0.0"
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
3462
src/acp-agent.ts
3462
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;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,30 @@ 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";
|
||||
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";
|
||||
@@ -41,6 +65,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,6 +37,8 @@ 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 };
|
||||
@@ -58,6 +75,8 @@ type TaskPatch = {
|
||||
|
||||
export type AsyncTaskNotification = TaskIdentity & {
|
||||
status?: unknown;
|
||||
toolCallId?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
summary?: unknown;
|
||||
outputFilePath?: unknown;
|
||||
output_file?: unknown;
|
||||
@@ -65,6 +84,8 @@ export type AsyncTaskNotification = TaskIdentity & {
|
||||
|
||||
type TaskProgress = TaskIdentity & {
|
||||
description?: unknown;
|
||||
toolCallId?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
summary?: unknown;
|
||||
lastToolName?: unknown;
|
||||
last_tool_name?: unknown;
|
||||
@@ -96,6 +117,14 @@ export class AsyncTaskRuntime {
|
||||
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. */
|
||||
private readonly options: {
|
||||
notices?: boolean;
|
||||
routeOf?: (toolCallId: string) => Route | undefined;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
async taskStarted(message: AsyncTaskStarted): Promise<void> {
|
||||
@@ -116,7 +145,8 @@ export class AsyncTaskRuntime {
|
||||
isBackgroundTask(
|
||||
field(message, "isBackgrounded", "is_backgrounded"),
|
||||
field(message, "taskType", "task_type"),
|
||||
)
|
||||
) ||
|
||||
task.held
|
||||
) {
|
||||
await this.announce(task);
|
||||
}
|
||||
@@ -183,7 +213,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 +229,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 +245,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 +293,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 &&
|
||||
@@ -306,7 +347,12 @@ 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
|
||||
@@ -319,7 +365,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 +400,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,13 +414,25 @@ 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -390,12 +449,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;
|
||||
@@ -424,6 +486,20 @@ export class AsyncTaskRuntime {
|
||||
if (toolCallId) task.toolCallId = toolCallId;
|
||||
}
|
||||
|
||||
/** 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"));
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const description = nonBlankString(patch.description);
|
||||
if (description) {
|
||||
@@ -451,23 +527,44 @@ 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.
|
||||
*/
|
||||
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 } : {}),
|
||||
},
|
||||
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);
|
||||
}
|
||||
@@ -495,6 +592,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 +616,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,32 +631,64 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PermissionMode,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
import { DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
|
||||
export type ClearContextReset = {
|
||||
toolUseId: string;
|
||||
@@ -37,7 +37,6 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
|
||||
accumulatedModelUsage?: Record<string, Usage>;
|
||||
models: { currentModelId: string };
|
||||
configOptions: SessionConfigOption[];
|
||||
currentAgent: string;
|
||||
fastModeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
@@ -89,7 +88,6 @@ function restartParams<Session extends ClearContextSession>(session: Session): N
|
||||
...(session.models.currentModelId !== DEFAULT_MODEL_ID
|
||||
? { model: session.models.currentModelId }
|
||||
: {}),
|
||||
...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}),
|
||||
...(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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
586
src/diff.ts
Normal file
586
src/diff.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
): 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 optionalContent(filePatch(filePath, oldText, newString)?.content);
|
||||
}
|
||||
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 optionalContent(filePatch(filePath, oldText, newText)?.content);
|
||||
}
|
||||
|
||||
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[] = [
|
||||
{ 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 optionalContent(filePatch(filePath, oldText, content)?.content) ?? 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,
|
||||
): 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(response.filePath, oldText, newText);
|
||||
if (!patch) return undefined;
|
||||
return {
|
||||
content: [patch.content],
|
||||
// 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 text of one git patch for `filePath`.
|
||||
*
|
||||
* The headers follow `git diff`: the path loses its leading slash, gets the
|
||||
* `a/` and `b/` prefixes, and is quoted when git would quote it. A created
|
||||
* file gets its mode line and a `/dev/null` side.
|
||||
*/
|
||||
export function gitPatchText(filePath: string, change: FileChange, hunks: PatchHunk[]): string {
|
||||
return [
|
||||
...gitPatchHeader(filePath, change),
|
||||
...hunks.flatMap((hunk) => [hunkHeader(hunk), ...hunk.lines]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function gitPatchHeader(filePath: string, change: FileChange): string[] {
|
||||
// git drops one leading slash of an absolute path; a Windows path gets
|
||||
// forward slashes so the header names the same file on every platform.
|
||||
const name = filePath.replaceAll("\\", "/").replace(/^\/+/u, "");
|
||||
const oldName = quoteGitPath("a/", name);
|
||||
const newName = quoteGitPath("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 git patch of a file change and its hunks. Returns undefined when
|
||||
* nothing changed or the diff runs out of its time budget.
|
||||
*/
|
||||
function filePatch(
|
||||
filePath: string,
|
||||
oldText: string | null,
|
||||
newText: string,
|
||||
): { content: ToolCallContent; hunks: PatchHunk[] } | undefined {
|
||||
const hunks = diffHunks(oldText, newText);
|
||||
if (!hunks || hunks.length === 0) return undefined;
|
||||
const change = oldText === null ? "create" : "update";
|
||||
return { content: patchContent(filePath, gitPatchText(filePath, change, hunks)), 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")
|
||||
);
|
||||
}
|
||||
|
||||
function optionalContent(content: ToolCallContent | undefined): ToolCallContent[] | undefined {
|
||||
return content ? [content] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 patchContent(filePath: string, text: string): ToolCallContent {
|
||||
return {
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText: null,
|
||||
newText: "",
|
||||
_meta: withAirMeta(undefined, AIR_DIFF_PATCH_CAPABILITY, {
|
||||
version: 1,
|
||||
format: "git_patch",
|
||||
text,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,65 @@ 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, and drops the update after that 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) => 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 +404,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 +475,7 @@ export async function announceNativeSubagent(
|
||||
subagentSessionId: child.sessionId,
|
||||
name: child.name,
|
||||
task: child.task,
|
||||
...promptField(child.prompt),
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
@@ -370,6 +518,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,8 +570,11 @@ 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 {
|
||||
@@ -422,6 +608,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 +652,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 +688,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 +722,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,22 @@
|
||||
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";
|
||||
|
||||
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. */
|
||||
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,74 +43,77 @@ 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.
|
||||
*/
|
||||
export function buildClaudePermissionPresentation(
|
||||
value: ClaudePermissionPresentationInput,
|
||||
): Pick<RequestPermissionRequest, "toolCall" | "_meta"> {
|
||||
const info = toolInfoFromToolUse(
|
||||
{ id: value.toolUseID, name: value.toolName, input: value.input },
|
||||
value.supportsTerminalOutput ?? false,
|
||||
value.cwd,
|
||||
);
|
||||
const 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 } : {}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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;
|
||||
@@ -27,14 +33,19 @@ export function resumedModelFromTranscript(messages: SessionMessage[]): string |
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const model = (entry.message as { model?: unknown }).model;
|
||||
if (typeof model === "string" && model.trim().length > 0 && !/^<[^>]+>$/.test(model.trim())) {
|
||||
return model.trim();
|
||||
}
|
||||
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(
|
||||
@@ -56,3 +67,92 @@ export async function readResumedSession(
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resume model of a session from the end of its local transcript.
|
||||
*
|
||||
* A resume needs only the model, not the messages. The transcript file is
|
||||
* read backwards until the last real assistant record of the main thread, so
|
||||
* the cost does not grow with the length of the session. A session without a
|
||||
* local transcript file falls back to {@link readResumedSession}.
|
||||
*/
|
||||
export async function readResumedModel(
|
||||
sessionId: string,
|
||||
logger?: ResumeLogger,
|
||||
): Promise<string | undefined> {
|
||||
const timing = new SessionTiming(logger, "models", sessionId);
|
||||
try {
|
||||
const filePath = await findTranscript(sessionId);
|
||||
if (filePath) {
|
||||
const model = await lastAssistantModel(filePath);
|
||||
timing.phase("read-transcript-tail", ` model=${model ?? "unknown"}`);
|
||||
return model;
|
||||
}
|
||||
} catch (error) {
|
||||
timing.phase("read-transcript-tail", " outcome=error");
|
||||
logger?.error(`Failed to read the transcript tail of resumed session ${sessionId}:`, error);
|
||||
}
|
||||
return (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 of the main thread in a JSONL transcript. */
|
||||
async function lastAssistantModel(filePath: string): Promise<string | undefined> {
|
||||
const handle = await open(filePath, "r");
|
||||
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)) {
|
||||
const model = assistantModelOfLine(Buffer.concat([chunk.subarray(at + 1), ...pending]));
|
||||
if (model) return model;
|
||||
pending = [];
|
||||
chunk = chunk.subarray(0, at);
|
||||
}
|
||||
pending.unshift(chunk);
|
||||
end = start;
|
||||
}
|
||||
return assistantModelOfLine(Buffer.concat(pending));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assistantModelOfLine(line: Buffer): string | undefined {
|
||||
// Most lines are not assistant records. The check skips their JSON parse.
|
||||
if (line.indexOf('"assistant"') < 0) return undefined;
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line.toString("utf8"));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") return undefined;
|
||||
const record = entry as { type?: unknown; isSidechain?: unknown; message?: { model?: unknown } };
|
||||
if (record.type !== "assistant" || record.isSidechain === true) return undefined;
|
||||
return concreteModel(record.message?.model);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/** Shared sentinels and ids for session preferences managed by the adapter. */
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
|
||||
@@ -12,6 +12,18 @@ 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()
|
||||
|
||||
@@ -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,26 @@ 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 {
|
||||
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 +43,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 +52,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,6 +70,8 @@ type InitializeSessionModeParams = {
|
||||
requestedMode: PermissionMode;
|
||||
currentModelInfo?: ModelInfo;
|
||||
currentModelId: string;
|
||||
/** When false, bypassPermissions is omitted from the mode catalog. */
|
||||
allowBypass: boolean;
|
||||
};
|
||||
|
||||
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
|
||||
@@ -60,11 +83,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 +137,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 +259,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 +315,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;
|
||||
|
||||
@@ -260,6 +260,39 @@ function resolveSettingsModel(
|
||||
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`).
|
||||
@@ -275,11 +308,14 @@ function resolveSettingsModel(
|
||||
* - `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
|
||||
@@ -310,6 +346,13 @@ export function applyAvailableModelsAllowlist(
|
||||
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 {
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -154,6 +154,51 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
);
|
||||
|
||||
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"),
|
||||
@@ -262,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 });
|
||||
@@ -415,30 +533,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];
|
||||
@@ -447,7 +562,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
63
src/tests/acp-scenarios-compare.test.ts
Normal file
63
src/tests/acp-scenarios-compare.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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),
|
||||
]);
|
||||
});
|
||||
});
|
||||
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":[],"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":[],"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":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"monitor_1","canStop":true,"description":"Watch the logs","name":"Watch the logs","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"monitor","toolCallId":"toolu_monitor"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"monitor_1","sessionUpdate":"async_task_progress","summary":"first line","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"monitor_1","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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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":[],"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}}}
|
||||
260
src/tests/acp-scenarios/compare.ts
Normal file
260
src/tests/acp-scenarios/compare.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Compares the outbound ACP traffic of a client that is not AIR with the
|
||||
* traffic that origin/main sent for the same scenario.
|
||||
*
|
||||
* The rule: a client that is not AIR gets the same information in the same
|
||||
* fields as on origin/main. {@link compareWithBaseline} allows only these
|
||||
* differences, and returns every other difference as a violation:
|
||||
*
|
||||
* - A key that exists only for AIR is gone (see {@link AIR_ONLY_META_KEYS}).
|
||||
* A `session_info_update` that carried only such a key is not sent.
|
||||
* - A `tool_call_update` leaves out a top-level field whose value did not
|
||||
* change since the previous report of the same tool call. An update with
|
||||
* nothing new is not sent. ACP does not merge `_meta` keys, so every
|
||||
* `_meta` key that origin/main sent must still be there.
|
||||
* - A `compaction_update` leaves out a summary whose text is the same as the
|
||||
* `compaction_summary_chunk` text that went out before it.
|
||||
* - A subagent message or thought is not sent again in full when the chunks
|
||||
* of the same message that went out before it hold the same text.
|
||||
*/
|
||||
import type { Recorded } from "./harness.js";
|
||||
|
||||
/** The top-level `_meta` keys that exist only for AIR. */
|
||||
export const AIR_ONLY_META_KEYS = new Set([
|
||||
"jetbrains",
|
||||
"goal",
|
||||
"contextCompaction",
|
||||
"kind",
|
||||
"permission",
|
||||
"_askUserQuestionCustomAnswer",
|
||||
]);
|
||||
|
||||
/** The `_meta.claudeCode` keys that exist only for AIR. */
|
||||
export const AIR_ONLY_CLAUDE_CODE_KEYS = new Set(["title", "subagent", "skill", "skillPath"]);
|
||||
|
||||
/** The tool call fields that an update replaces as a whole. */
|
||||
const REPLACED_FIELDS = [
|
||||
"status",
|
||||
"title",
|
||||
"kind",
|
||||
"content",
|
||||
"locations",
|
||||
"rawInput",
|
||||
"rawOutput",
|
||||
"name",
|
||||
];
|
||||
|
||||
/** `_meta` keys whose data a client appends. They are never "unchanged". */
|
||||
const APPENDED_META_KEYS = new Set([
|
||||
"terminal_output",
|
||||
"terminal_output_delta",
|
||||
"terminal_exit",
|
||||
"mcp_output_delta",
|
||||
]);
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
/** JSON with sorted object keys, so that key order does not matter. */
|
||||
export function canonical(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, item) =>
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
/** Removes the keys that exist only for AIR, recursively. */
|
||||
export function withoutAirOnlyKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(withoutAirOnlyKeys);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
const result: Json = {};
|
||||
for (const [key, item] of Object.entries(value as Json)) {
|
||||
if (key === "_meta" && item && typeof item === "object" && !Array.isArray(item)) {
|
||||
const meta: Json = {};
|
||||
for (const [metaKey, metaValue] of Object.entries(item as Json)) {
|
||||
if (AIR_ONLY_META_KEYS.has(metaKey)) continue;
|
||||
if (metaKey === "claudeCode" && metaValue && typeof metaValue === "object") {
|
||||
const claudeCode = Object.fromEntries(
|
||||
Object.entries(metaValue as Json).filter(([k]) => !AIR_ONLY_CLAUDE_CODE_KEYS.has(k)),
|
||||
);
|
||||
if (Object.keys(claudeCode).length > 0) meta[metaKey] = withoutAirOnlyKeys(claudeCode);
|
||||
continue;
|
||||
}
|
||||
meta[metaKey] = withoutAirOnlyKeys(metaValue);
|
||||
}
|
||||
if (Object.keys(meta).length > 0) result[key] = meta;
|
||||
continue;
|
||||
}
|
||||
result[key] = withoutAirOnlyKeys(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The replaced fields and the merged `_meta` keys of a tool call report, as JSON. */
|
||||
function flatten(update: Json): Map<string, string> {
|
||||
const flat = new Map<string, string>();
|
||||
for (const field of REPLACED_FIELDS) {
|
||||
if (update[field] !== undefined) flat.set(field, canonical(update[field]));
|
||||
}
|
||||
const meta = (update._meta ?? {}) as Json;
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (key === "claudeCode" && value && typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value as Json)) {
|
||||
flat.set(`_meta.claudeCode.${k}`, canonical(v));
|
||||
}
|
||||
} else {
|
||||
flat.set(`_meta.${key}`, canonical(value));
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
/** A copy of `value` without `key`. */
|
||||
function without(value: Json, key: string): Json {
|
||||
const copy = { ...value };
|
||||
delete copy[key];
|
||||
return copy;
|
||||
}
|
||||
|
||||
function updateOf(record: Recorded): Json | undefined {
|
||||
if (record.kind !== "sessionUpdate") return undefined;
|
||||
return (record.payload as { update: Json }).update;
|
||||
}
|
||||
|
||||
/** The key and the text of a subagent message or thought chunk. */
|
||||
function subagentChunk(update: Json | undefined): { key: string; text: string } | undefined {
|
||||
if (
|
||||
update?.sessionUpdate !== "agent_message_chunk" &&
|
||||
update?.sessionUpdate !== "agent_thought_chunk"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const parent = ((update._meta as Json | undefined)?.claudeCode as Json | undefined)
|
||||
?.parentToolUseId;
|
||||
const text = (update.content as Json | undefined)?.text;
|
||||
if (typeof parent !== "string" || typeof update.messageId !== "string") return undefined;
|
||||
if (typeof text !== "string") return undefined;
|
||||
return { key: `${update.sessionUpdate} ${parent} ${update.messageId}`, text };
|
||||
}
|
||||
|
||||
function isAppended(key: string): boolean {
|
||||
return APPENDED_META_KEYS.has(key.slice("_meta.".length));
|
||||
}
|
||||
|
||||
/** Whether a client must get the key on every report, because ACP does not merge it. */
|
||||
function isMeta(key: string): boolean {
|
||||
return key.startsWith("_meta.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the differences between the baseline and the current traffic that
|
||||
* the compatibility rule does not allow. An empty list means compatible.
|
||||
*/
|
||||
export function compareWithBaseline(baseline: Recorded[], current: Recorded[]): string[] {
|
||||
const expected = baseline
|
||||
.map((record) => withoutAirOnlyKeys(record) as Recorded)
|
||||
.filter((record) => {
|
||||
const update = updateOf(record);
|
||||
return !(update?.sessionUpdate === "session_info_update" && Object.keys(update).length === 1);
|
||||
});
|
||||
const violations: string[] = [];
|
||||
const state = new Map<string, Map<string, string>>();
|
||||
const summaryChunks = new Map<string, string>();
|
||||
const subagentText = new Map<string, string>();
|
||||
let next = 0;
|
||||
|
||||
const remember = (update: Json | undefined) => {
|
||||
if (!update) return;
|
||||
const chunk = subagentChunk(update);
|
||||
if (chunk && subagentText.get(chunk.key) !== chunk.text) {
|
||||
subagentText.set(chunk.key, (subagentText.get(chunk.key) ?? "") + chunk.text);
|
||||
}
|
||||
if (update.sessionUpdate === "tool_call") {
|
||||
state.set(update.toolCallId as string, flatten(update));
|
||||
} else if (update.sessionUpdate === "tool_call_update") {
|
||||
const held = state.get(update.toolCallId as string) ?? new Map<string, string>();
|
||||
state.set(update.toolCallId as string, held);
|
||||
for (const [key, value] of flatten(update)) if (!isAppended(key)) held.set(key, value);
|
||||
} else if (update.sessionUpdate === "compaction_summary_chunk") {
|
||||
const id = update.compactionId as string;
|
||||
const text = ((update.content as Json | undefined)?.text as string | undefined) ?? "";
|
||||
summaryChunks.set(id, (summaryChunks.get(id) ?? "") + text);
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether `actual` carries the information of `wanted` under the rule. */
|
||||
const matches = (wanted: Recorded, actual: Recorded | undefined): boolean => {
|
||||
if (!actual || actual.kind !== wanted.kind) return false;
|
||||
if (canonical(actual) === canonical(wanted)) return true;
|
||||
const want = updateOf(wanted);
|
||||
const got = updateOf(actual);
|
||||
if (!want || !got || want.sessionUpdate !== got.sessionUpdate) return false;
|
||||
if (
|
||||
canonical(without(wanted.payload as Json, "update")) !==
|
||||
canonical(without(actual.payload as Json, "update"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (want.sessionUpdate === "tool_call_update" && want.toolCallId === got.toolCallId) {
|
||||
const held = state.get(want.toolCallId as string) ?? new Map<string, string>();
|
||||
const w = flatten(want);
|
||||
const g = flatten(got);
|
||||
for (const [key, value] of g) if (w.get(key) !== value) return false;
|
||||
for (const [key, value] of w) {
|
||||
if (g.has(key)) continue;
|
||||
if (isMeta(key) || held.get(key) !== value) return false;
|
||||
}
|
||||
const other = (u: Json) =>
|
||||
canonical(
|
||||
Object.fromEntries(
|
||||
Object.entries(u)
|
||||
.filter(([k]) => k !== "_meta" && !REPLACED_FIELDS.includes(k))
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
),
|
||||
);
|
||||
return other(want) === other(got);
|
||||
}
|
||||
if (want.sessionUpdate === "compaction_update" && want.summary && !got.summary) {
|
||||
const text = (want.summary as { text?: string }[]).map((part) => part.text ?? "").join("");
|
||||
return (
|
||||
summaryChunks.get(want.compactionId as string) === text &&
|
||||
canonical(without(want, "summary")) === canonical(got)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Whether a baseline update repeats only what the client holds. */
|
||||
const redundant = (wanted: Recorded): boolean => {
|
||||
const want = updateOf(wanted);
|
||||
const chunk = subagentChunk(want);
|
||||
if (chunk) return subagentText.get(chunk.key) === chunk.text;
|
||||
if (want?.sessionUpdate !== "tool_call_update") return false;
|
||||
const held = state.get(want.toolCallId as string);
|
||||
if (!held) return false;
|
||||
for (const [key, value] of flatten(want)) {
|
||||
if (isMeta(key) || held.get(key) !== value) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const wanted of expected) {
|
||||
if (matches(wanted, current[next])) {
|
||||
remember(updateOf(wanted));
|
||||
next++;
|
||||
continue;
|
||||
}
|
||||
if (redundant(wanted)) {
|
||||
remember(updateOf(wanted));
|
||||
continue;
|
||||
}
|
||||
violations.push(
|
||||
`origin/main sent ${canonical(wanted)}, but the adapter sent ${canonical(current[next] ?? null)}`,
|
||||
);
|
||||
remember(updateOf(wanted));
|
||||
}
|
||||
for (const extra of current.slice(next)) {
|
||||
violations.push(`origin/main did not send ${canonical(extra)}`);
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
753
src/tests/acp-scenarios/harness.ts
Normal file
753
src/tests/acp-scenarios/harness.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
/**
|
||||
* A scenario harness for the outbound ACP traffic of the adapter.
|
||||
*
|
||||
* A scenario drives a real `ClaudeAcpAgent` through `initialize`, `session/new`
|
||||
* (or `session/load`), and `session/prompt`. A scripted SDK query replaces the
|
||||
* Claude Code process: it yields SDK messages and stream events, and it calls
|
||||
* the `canUseTool` callback and the hooks that the agent registered, in the
|
||||
* order that Claude Code uses. The harness records every message that the
|
||||
* agent sends to the client.
|
||||
*
|
||||
* The harness is vitest-free. The test file mocks `query` and
|
||||
* `getSessionMessages` of the SDK with {@link mockedQuery} and
|
||||
* {@link mockedSessionMessages}.
|
||||
*/
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
CreateElicitationRequest,
|
||||
RequestPermissionRequest,
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** One message that the agent sent to the client. */
|
||||
export interface Recorded {
|
||||
kind:
|
||||
| "initialize"
|
||||
| "newSession"
|
||||
| "loadSession"
|
||||
| "sessionUpdate"
|
||||
| "requestPermission"
|
||||
| "createElicitation"
|
||||
| "completeElicitation"
|
||||
| "extNotification"
|
||||
| "promptResponse";
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/** A client profile: the capabilities that the client declares. */
|
||||
export interface Profile {
|
||||
name: "plain" | "zed" | "air";
|
||||
capabilities: ClientCapabilities;
|
||||
}
|
||||
|
||||
/** Every AIR capability name that the adapter knows. */
|
||||
export const AIR_CAPABILITY_NAMES = [
|
||||
"nativeSubagentSessions",
|
||||
"asyncTasks",
|
||||
"sessionFailure",
|
||||
"recommendedValue",
|
||||
"diffPatch",
|
||||
"rawInputRendering",
|
||||
"planFile",
|
||||
"agentFileChangeReport",
|
||||
];
|
||||
|
||||
const baseCapabilities: ClientCapabilities = {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
terminal: true,
|
||||
elicitation: { form: {} },
|
||||
};
|
||||
|
||||
export const PROFILES: Record<Profile["name"], Profile> = {
|
||||
plain: { name: "plain", capabilities: { ...baseCapabilities } },
|
||||
zed: {
|
||||
name: "zed",
|
||||
capabilities: {
|
||||
...baseCapabilities,
|
||||
auth: { terminal: true },
|
||||
_meta: { terminal_output: true, "terminal-auth": true },
|
||||
},
|
||||
},
|
||||
air: {
|
||||
name: "air",
|
||||
capabilities: {
|
||||
...baseCapabilities,
|
||||
_meta: {
|
||||
terminal_output: true,
|
||||
terminal_output_delta: true,
|
||||
jetbrains: { air: { version: 1, capabilities: [...AIR_CAPABILITY_NAMES] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** The SDK options that the agent passed to `query`. */
|
||||
type QueryOptions = {
|
||||
sessionId?: string;
|
||||
resume?: string;
|
||||
canUseTool?: (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
extra: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
hooks?: Record<string, { hooks: ((input: any, id: string, opts: any) => Promise<unknown>)[] }[]>;
|
||||
};
|
||||
|
||||
/** What a scenario script reads and calls. */
|
||||
export interface ScriptContext {
|
||||
cwd: string;
|
||||
/** The session id that the agent passed to the SDK. */
|
||||
sessionId: string;
|
||||
options: QueryOptions;
|
||||
/** Calls `canUseTool` like Claude Code does before it runs a tool. */
|
||||
canUseTool(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
toolUseID: string,
|
||||
extra?: Record<string, unknown>,
|
||||
): Promise<unknown>;
|
||||
/** Calls every hook callback that the agent registered for the event. */
|
||||
hook(event: string, input: Record<string, unknown>, toolUseID?: string): Promise<void>;
|
||||
/** Calls the PostToolUse hooks of a tool use. */
|
||||
postToolUse(
|
||||
toolUseID: string,
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
response: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/** A scenario: the SDK side of one or more prompt turns. */
|
||||
export interface Scenario {
|
||||
name: string;
|
||||
/** Capabilities that the scenario adds to the profile, for example compaction. */
|
||||
capabilities?: Partial<ClientCapabilities>;
|
||||
/** Files to create in the working directory before the session starts. */
|
||||
files?: Record<string, string>;
|
||||
/** The permission option kind that the client selects. Default: allow_once. */
|
||||
permission?: "allow_once" | "allow_always" | "reject_once";
|
||||
/** A transcript to replay with `session/load` instead of a new session. */
|
||||
transcript?: (ctx: { cwd: string; sessionId: string }) => Record<string, unknown>[];
|
||||
/** The prompt text of each turn. Default: `prompt <n>`. */
|
||||
prompts?: string[];
|
||||
/** The SDK messages of each turn after the echo of the prompt. */
|
||||
turns: ((ctx: ScriptContext) => AsyncGenerator<Record<string, unknown>>)[];
|
||||
/** Also record the initialize response and the session/new or session/load response. */
|
||||
recordSessionResponse?: boolean;
|
||||
}
|
||||
|
||||
/** The fixed session id of every `session/load` scenario, so that the recordings compare. */
|
||||
export const SESSION_ID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
/**
|
||||
* The session id of the active SDK query. Claude Code writes the session id
|
||||
* that the agent passed in `options.sessionId` (or `options.resume`) into
|
||||
* every SDK message, so the mock does the same.
|
||||
*/
|
||||
let sdkSessionId: string | undefined;
|
||||
|
||||
/** The session id that the SDK messages of the active query carry. */
|
||||
export function activeSdkSessionId(): string {
|
||||
if (!sdkSessionId) throw new Error("no active SDK query");
|
||||
return sdkSessionId;
|
||||
}
|
||||
|
||||
let activeScript:
|
||||
| {
|
||||
scenario: Scenario;
|
||||
cwd: string;
|
||||
transcript: Record<string, unknown>[];
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/** The `query` of the SDK mock: a scripted query of the active scenario. */
|
||||
export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOptions }) {
|
||||
if (!activeScript) throw new Error("no active scenario");
|
||||
const script = activeScript;
|
||||
const options = args.options;
|
||||
// Claude Code uses `sessionId` for a new or forked session and `resume` for
|
||||
// a continued one. A query without either gets a random id, which no
|
||||
// scenario expects.
|
||||
const sessionId = options.sessionId ?? options.resume;
|
||||
if (!sessionId) throw new Error("the agent passed no session id to the SDK");
|
||||
sdkSessionId = sessionId;
|
||||
const ctx: ScriptContext = {
|
||||
cwd: script.cwd,
|
||||
sessionId,
|
||||
options,
|
||||
canUseTool: (toolName, input, toolUseID, extra = {}) =>
|
||||
options.canUseTool!(toolName, input, {
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID,
|
||||
...extra,
|
||||
}),
|
||||
async hook(event, input, toolUseID) {
|
||||
for (const matcher of options.hooks?.[event] ?? []) {
|
||||
for (const callback of matcher.hooks) {
|
||||
await callback(
|
||||
{ hook_event_name: event, session_id: sessionId, cwd: script.cwd, ...input },
|
||||
toolUseID ?? "",
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
postToolUse(toolUseID, toolName, input, response) {
|
||||
return ctx.hook(
|
||||
"PostToolUse",
|
||||
{ tool_name: toolName, tool_input: input, tool_response: response, tool_use_id: toolUseID },
|
||||
toolUseID,
|
||||
);
|
||||
},
|
||||
};
|
||||
async function* run() {
|
||||
const iterator = args.prompt[Symbol.asyncIterator]();
|
||||
for (const turn of script.scenario.turns) {
|
||||
const next = await iterator.next();
|
||||
if (next.done) return;
|
||||
const user = next.value;
|
||||
yield {
|
||||
type: "user",
|
||||
message: user.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: user.uuid,
|
||||
session_id: sessionId,
|
||||
isReplay: true,
|
||||
};
|
||||
yield* turn(ctx);
|
||||
}
|
||||
// Stay open like a live Claude Code process.
|
||||
await iterator.next();
|
||||
}
|
||||
const generator = run();
|
||||
return Object.assign(generator, {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
commands: [],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
supportedAgents: async () => [],
|
||||
mcpServerStatus: async () => [],
|
||||
getContextUsage: async () => ({ totalTokens: 0, rawMaxTokens: 200000 }),
|
||||
close: () => {},
|
||||
interrupt: async () => undefined,
|
||||
stopTask: async () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** The `getSessionMessages` of the SDK mock: the transcript of the active scenario. */
|
||||
export async function mockedSessionMessages(): Promise<Record<string, unknown>[]> {
|
||||
return activeScript?.transcript ?? [];
|
||||
}
|
||||
|
||||
type AgentClass = new (client: any, logger?: any) => any;
|
||||
|
||||
/** The outbound messages of one scenario run. */
|
||||
export interface ScenarioRun {
|
||||
/** The ACP session id of the run. */
|
||||
sessionId: string;
|
||||
/** The messages as the agent sent them. */
|
||||
raw: Recorded[];
|
||||
/** The messages with the run-specific values replaced (see {@link normalize}). */
|
||||
normalized: Recorded[];
|
||||
}
|
||||
|
||||
/** Runs one scenario for one profile and records every outbound message. */
|
||||
export async function runScenario(
|
||||
Agent: AgentClass,
|
||||
profile: Profile,
|
||||
scenario: Scenario,
|
||||
): Promise<ScenarioRun> {
|
||||
const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-")));
|
||||
for (const [file, text] of Object.entries(scenario.files ?? {})) {
|
||||
const target = path.join(cwd, file);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, text);
|
||||
}
|
||||
const recorded: Recorded[] = [];
|
||||
const record = (kind: Recorded["kind"], payload: unknown) =>
|
||||
recorded.push({ kind, payload: structuredClone(payload) });
|
||||
const client = {
|
||||
async sessionUpdate(notification: SessionNotification) {
|
||||
record("sessionUpdate", notification);
|
||||
},
|
||||
async requestPermission(request: RequestPermissionRequest) {
|
||||
record("requestPermission", request);
|
||||
const wanted = scenario.permission ?? "allow_once";
|
||||
const option =
|
||||
request.options.find((o) => o.kind === wanted) ??
|
||||
request.options.find((o) => o.kind.startsWith(wanted.split("_")[0])) ??
|
||||
request.options[0];
|
||||
return { outcome: { outcome: "selected", optionId: option.optionId } };
|
||||
},
|
||||
async createElicitation(request: CreateElicitationRequest) {
|
||||
record("createElicitation", request);
|
||||
const schema = (request as any).requestedSchema as
|
||||
{ properties?: Record<string, any> } | undefined;
|
||||
const content: Record<string, unknown> = {};
|
||||
for (const [key, property] of Object.entries(schema?.properties ?? {})) {
|
||||
const values = property.enum ?? property.oneOf?.map((o: any) => o.const);
|
||||
if (Array.isArray(values) && values.length > 0) content[key] = values[0];
|
||||
}
|
||||
return { action: "accept", content };
|
||||
},
|
||||
async completeElicitation(params: unknown) {
|
||||
record("completeElicitation", params);
|
||||
},
|
||||
async extNotification(method: string, params: unknown) {
|
||||
record("extNotification", { method, params });
|
||||
},
|
||||
async readTextFile() {
|
||||
return { content: "" };
|
||||
},
|
||||
async writeTextFile() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
// The `_meta` of the scenario adds to the `_meta` of the profile, so AIR stays AIR.
|
||||
const capabilities: ClientCapabilities = {
|
||||
...profile.capabilities,
|
||||
...scenario.capabilities,
|
||||
...(profile.capabilities._meta || scenario.capabilities?._meta
|
||||
? { _meta: { ...profile.capabilities._meta, ...scenario.capabilities?._meta } }
|
||||
: {}),
|
||||
};
|
||||
generatedIds.clear();
|
||||
activeScript = {
|
||||
scenario,
|
||||
cwd,
|
||||
transcript: scenario.transcript?.({ cwd, sessionId: SESSION_ID }) ?? [],
|
||||
};
|
||||
const logger = { log: () => {}, error: () => {}, warn: () => {}, debug: () => {} };
|
||||
const agent = new Agent(client, logger);
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
const initialized = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: capabilities,
|
||||
});
|
||||
if (scenario.recordSessionResponse) record("initialize", initialized);
|
||||
const opened = scenario.transcript
|
||||
? await agent.loadSession({ sessionId: SESSION_ID, cwd, mcpServers: [] })
|
||||
: await agent.newSession({ cwd, mcpServers: [] });
|
||||
if (scenario.recordSessionResponse) {
|
||||
record(scenario.transcript ? "loadSession" : "newSession", opened);
|
||||
}
|
||||
const acpSessionId: string = scenario.transcript ? SESSION_ID : opened.sessionId;
|
||||
sessionId = acpSessionId;
|
||||
await settle();
|
||||
for (let turn = 0; turn < scenario.turns.length; turn++) {
|
||||
const response = await agent.prompt({
|
||||
sessionId: acpSessionId,
|
||||
prompt: [{ type: "text", text: scenario.prompts?.[turn] ?? `prompt ${turn + 1}` }],
|
||||
});
|
||||
record("promptResponse", response);
|
||||
await settle();
|
||||
}
|
||||
const raw = [...recorded];
|
||||
return {
|
||||
sessionId: acpSessionId,
|
||||
raw,
|
||||
normalized: normalize(raw, cwd, acpSessionId, generatedIds) as Recorded[],
|
||||
};
|
||||
} finally {
|
||||
activeScript = undefined;
|
||||
sdkSessionId = undefined;
|
||||
try {
|
||||
if (sessionId) await agent.unstable_closeSession?.({ sessionId });
|
||||
} catch {
|
||||
// The session is already gone.
|
||||
}
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
|
||||
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
||||
|
||||
/** The version of this adapter in `package.json`, which `agentInfo` reports. */
|
||||
const PACKAGE_VERSION: string = JSON.parse(
|
||||
fs.readFileSync(new URL("../../../package.json", import.meta.url), "utf8"),
|
||||
).version;
|
||||
|
||||
/** The ids that `randomUUID` of `node:crypto` generated during the active run. */
|
||||
const generatedIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* Records an id that `randomUUID` generated. The test file wraps `randomUUID`
|
||||
* of `node:crypto` with this function, so that {@link normalize} replaces only
|
||||
* the ids that the run generated.
|
||||
*/
|
||||
export function noteGeneratedId(id: string): void {
|
||||
generatedIds.add(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the values that change from run to run. Each rule replaces only an
|
||||
* exact known value, so a recording shows every other value:
|
||||
*
|
||||
* - the temporary working directory of the run and `os.homedir()`;
|
||||
* - `process.execPath` in a `command` key;
|
||||
* - the exact prefix `process.argv.slice(1)` of an `args` array;
|
||||
* - the version of this adapter in a `version` key;
|
||||
* - the session id, and the ids in `generated` (the ids that `randomUUID`
|
||||
* generated during the run). Each id gets a name in the order of its first
|
||||
* appearance, so a message that names a wrong id still differs.
|
||||
*
|
||||
* A UUID that the scenario scripted (an SDK message uuid, a tool use id) is
|
||||
* the same in each run and stays in the recording.
|
||||
*/
|
||||
export function normalize(
|
||||
value: unknown,
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
generated: ReadonlySet<string> = new Set(),
|
||||
): unknown {
|
||||
const ids = new Map<string, string>([[sessionId, "<session>"]]);
|
||||
const home = os.homedir();
|
||||
const argv = process.argv.slice(1);
|
||||
const visit = (node: unknown, key?: string): unknown => {
|
||||
if (typeof node === "string") {
|
||||
// A terminal-auth command reruns this Node binary, whose path depends on
|
||||
// the machine. Any other command stays as it is, so a recording shows it.
|
||||
if (key === "command" && node === process.execPath) return "<executable>";
|
||||
if (key === "version" && node === PACKAGE_VERSION) return "<version>";
|
||||
const text = node.split(cwd).join("<cwd>").split(home).join("<home>");
|
||||
return text.replace(UUID, (id) => {
|
||||
if (!ids.has(id)) {
|
||||
if (!generated.has(id)) return id;
|
||||
ids.set(id, `<id-${ids.size}>`);
|
||||
}
|
||||
return ids.get(id)!;
|
||||
});
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
// A terminal-auth command reruns this process with its own argv, which
|
||||
// depends on the test runner. Only that exact prefix is replaced.
|
||||
if (key === "args" && startsWith(node, argv)) {
|
||||
return ["<argv>", ...node.slice(argv.length).map((item) => visit(item))];
|
||||
}
|
||||
return node.map((item) => visit(item));
|
||||
}
|
||||
if (node && typeof node === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(node as Record<string, unknown>).map(([k, v]) => [k, visit(v, k)]),
|
||||
);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return visit(value);
|
||||
}
|
||||
|
||||
function startsWith(array: unknown[], prefix: string[]): boolean {
|
||||
return prefix.length > 0 && prefix.every((item, i) => array[i] === item);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK message builders.
|
||||
|
||||
let counter = 0;
|
||||
function uuid(): string {
|
||||
counter++;
|
||||
const hex = counter.toString(16).padStart(12, "0");
|
||||
return `00000000-0000-4000-8000-${hex}`;
|
||||
}
|
||||
|
||||
export function resetIds() {
|
||||
counter = 0;
|
||||
}
|
||||
|
||||
type Parent = string | null;
|
||||
|
||||
function streamEvent(event: Record<string, unknown>, parent: Parent) {
|
||||
return {
|
||||
type: "stream_event",
|
||||
event,
|
||||
parent_tool_use_id: parent,
|
||||
uuid: uuid(),
|
||||
session_id: activeSdkSessionId(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A streamed assistant message: message_start, the blocks, message_stop. */
|
||||
export function* streamMessage(
|
||||
messageId: string,
|
||||
blocks: (
|
||||
| { type: "text"; text: string }
|
||||
| { type: "thinking"; thinking: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
|
||||
)[],
|
||||
parent: Parent = null,
|
||||
) {
|
||||
yield streamEvent(
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
},
|
||||
parent,
|
||||
);
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
if (block.type === "text") {
|
||||
yield streamEvent(
|
||||
{ type: "content_block_start", index, content_block: { type: "text", text: "" } },
|
||||
parent,
|
||||
);
|
||||
for (const piece of halves(block.text)) {
|
||||
yield streamEvent(
|
||||
{ type: "content_block_delta", index, delta: { type: "text_delta", text: piece } },
|
||||
parent,
|
||||
);
|
||||
}
|
||||
} else if (block.type === "thinking") {
|
||||
yield streamEvent(
|
||||
{
|
||||
type: "content_block_start",
|
||||
index,
|
||||
content_block: { type: "thinking", thinking: "", signature: "" },
|
||||
},
|
||||
parent,
|
||||
);
|
||||
for (const piece of halves(block.thinking)) {
|
||||
yield streamEvent(
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index,
|
||||
delta: { type: "thinking_delta", thinking: piece },
|
||||
},
|
||||
parent,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
yield streamEvent(
|
||||
{
|
||||
type: "content_block_start",
|
||||
index,
|
||||
content_block: { type: "tool_use", id: block.id, name: block.name, input: {} },
|
||||
},
|
||||
parent,
|
||||
);
|
||||
// The input streams in pieces that end after each top-level field, so
|
||||
// the agent sees the partial input grow field by field.
|
||||
const json = JSON.stringify(block.input);
|
||||
for (const piece of jsonPieces(json)) {
|
||||
yield streamEvent(
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index,
|
||||
delta: { type: "input_json_delta", partial_json: piece },
|
||||
},
|
||||
parent,
|
||||
);
|
||||
}
|
||||
}
|
||||
yield streamEvent({ type: "content_block_stop", index }, parent);
|
||||
}
|
||||
yield streamEvent(
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "tool_use", stop_sequence: null },
|
||||
usage: { output_tokens: 2 },
|
||||
},
|
||||
parent,
|
||||
);
|
||||
yield streamEvent({ type: "message_stop" }, parent);
|
||||
}
|
||||
|
||||
function halves(text: string): string[] {
|
||||
if (text.length < 2) return [text];
|
||||
const middle = Math.floor(text.length / 2);
|
||||
return [text.slice(0, middle), text.slice(middle)];
|
||||
}
|
||||
|
||||
/** Splits a JSON object after each top-level comma. */
|
||||
function jsonPieces(json: string): string[] {
|
||||
const pieces: string[] = [];
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let start = 0;
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
const c = json[i];
|
||||
if (inString) {
|
||||
if (c === "\\") i++;
|
||||
else if (c === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') inString = true;
|
||||
else if (c === "{" || c === "[") depth++;
|
||||
else if (c === "}" || c === "]") depth--;
|
||||
else if (c === "," && depth === 1) {
|
||||
pieces.push(json.slice(start, i + 1));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
pieces.push(json.slice(start));
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/** The consolidated assistant message. */
|
||||
export function assistant(
|
||||
messageId: string,
|
||||
content: Record<string, unknown>[],
|
||||
parent: Parent = null,
|
||||
) {
|
||||
return {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
parent_tool_use_id: parent,
|
||||
uuid: uuid(),
|
||||
session_id: activeSdkSessionId(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A streamed and then consolidated assistant message. */
|
||||
export function* assistantTurn(
|
||||
messageId: string,
|
||||
blocks: Parameters<typeof streamMessage>[1],
|
||||
parent: Parent = null,
|
||||
) {
|
||||
yield* streamMessage(messageId, blocks, parent);
|
||||
yield assistant(
|
||||
messageId,
|
||||
blocks.map((block) =>
|
||||
block.type === "thinking" ? { ...block, signature: "sig" } : (block as any),
|
||||
),
|
||||
parent,
|
||||
);
|
||||
}
|
||||
|
||||
/** A tool_result user message. */
|
||||
export function toolResult(
|
||||
toolUseId: string,
|
||||
content: unknown,
|
||||
options: { isError?: boolean; structured?: unknown; parent?: Parent } = {},
|
||||
) {
|
||||
return {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUseId,
|
||||
content,
|
||||
...(options.isError ? { is_error: true } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
parent_tool_use_id: options.parent ?? null,
|
||||
...(options.structured !== undefined ? { tool_use_result: options.structured } : {}),
|
||||
uuid: uuid(),
|
||||
session_id: activeSdkSessionId(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A system message. */
|
||||
export function system(subtype: string, fields: Record<string, unknown> = {}) {
|
||||
return { type: "system", subtype, uuid: uuid(), session_id: activeSdkSessionId(), ...fields };
|
||||
}
|
||||
|
||||
/** The successful result of a turn. */
|
||||
export function result(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: uuid(),
|
||||
session_id: activeSdkSessionId(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A tool call that runs with permission: stream, approval, hook, and result. */
|
||||
export async function* toolCall(
|
||||
ctx: ScriptContext,
|
||||
tool: { id: string; name: string; input: Record<string, unknown> },
|
||||
outcome: {
|
||||
content: unknown;
|
||||
isError?: boolean;
|
||||
structured?: unknown;
|
||||
hookResponse?: unknown;
|
||||
ask?: boolean;
|
||||
parent?: Parent;
|
||||
/** The agent id that Claude Code passes to `canUseTool` inside a subagent. */
|
||||
agentID?: string;
|
||||
},
|
||||
) {
|
||||
const parent = outcome.parent ?? null;
|
||||
yield* assistantTurn(`msg_${tool.id}`, [{ type: "tool_use", ...tool }], parent);
|
||||
if (outcome.ask) {
|
||||
await ctx.canUseTool(
|
||||
tool.name,
|
||||
tool.input,
|
||||
tool.id,
|
||||
outcome.agentID ? { agentID: outcome.agentID } : {},
|
||||
);
|
||||
}
|
||||
yield toolResult(tool.id, outcome.content, {
|
||||
isError: outcome.isError,
|
||||
structured: outcome.structured,
|
||||
parent,
|
||||
});
|
||||
// The PostToolUse hook callback normally arrives after the tool_result.
|
||||
if (outcome.hookResponse !== undefined) {
|
||||
// The CLI writes the file of a Write before the hook, and the hook patch reads it.
|
||||
const written = outcome.hookResponse as { filePath?: unknown; content?: unknown };
|
||||
if (
|
||||
tool.name === "Write" &&
|
||||
typeof written.filePath === "string" &&
|
||||
typeof written.content === "string"
|
||||
) {
|
||||
fs.writeFileSync(written.filePath, written.content);
|
||||
}
|
||||
await ctx.postToolUse(tool.id, tool.name, tool.input, outcome.hookResponse);
|
||||
}
|
||||
}
|
||||
9
src/tests/acp-scenarios/origin-main/plain/ask-user-question.jsonl
generated
Normal file
9
src/tests/acp-scenarios/origin-main/plain/ask-user-question.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"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","rawInput":{},"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":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[{"content":{"text":"Which database?","type":"text"},"type":"content"}],"kind":"other","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":"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"}},"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":{"_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":"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}}}
|
||||
2
src/tests/acp-scenarios/origin-main/plain/async-task-ends-before-tool-id.jsonl
generated
Normal file
2
src/tests/acp-scenarios/origin-main/plain/async-task-ends-before-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_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}}}
|
||||
2
src/tests/acp-scenarios/origin-main/plain/async-task-held-until-tool-id.jsonl
generated
Normal file
2
src/tests/acp-scenarios/origin-main/plain/async-task-held-until-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_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}}}
|
||||
2
src/tests/acp-scenarios/origin-main/plain/async-task-released-at-turn-end.jsonl
generated
Normal file
2
src/tests/acp-scenarios/origin-main/plain/async-task-released-at-turn-end.jsonl
generated
Normal file
@@ -0,0 +1,2 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_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}}}
|
||||
10
src/tests/acp-scenarios/origin-main/plain/bash-background.jsonl
generated
Normal file
10
src/tests/acp-scenarios/origin-main/plain/bash-background.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"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"}},"content":[],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"kind":"execute","rawInput":{"command":"sleep 100"},"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"title":"Wait","toolName":"Bash"}},"kind":"execute","rawInput":{"command":"sleep 100","description":"Wait"},"sessionUpdate":"tool_call_update","title":"sleep 100","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":{"_meta":{"claudeCode":{"title":"Wait","toolName":"Bash"}},"content":[{"content":{"text":"Wait","type":"text"},"type":"content"}],"kind":"execute","rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"```console\nCommand running in background with ID: bash_1. Output is being written to: /tmp/tasks/bash_1.output. You will be notified when it completes.\n```","type":"text"},"type":"content"}],"rawOutput":"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.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
|
||||
{"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/origin-main/plain/bash-error.jsonl
generated
Normal file
9
src/tests/acp-scenarios/origin-main/plain/bash-error.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"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"}},"content":[],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"kind":"execute","rawInput":{"command":"false"},"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":{"claudeCode":{"title":"Fail","toolName":"Bash"}},"content":[{"content":{"text":"Fail","type":"text"},"type":"content"}],"kind":"execute","rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"```\nExit code 1\nboom\n```","type":"text"},"type":"content"}],"rawOutput":"Exit code 1\nboom","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/origin-main/plain/bash-foreground.jsonl
generated
Normal file
14
src/tests/acp-scenarios/origin-main/plain/bash-foreground.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"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"}},"content":[],"kind":"execute","name":"Bash","rawInput":{},"sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"kind":"execute","rawInput":{"command":"ls -la"},"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":{"claudeCode":{"title":"List files","toolName":"Bash"}},"content":[{"content":{"text":"List files","type":"text"},"type":"content"}],"kind":"execute","rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"permission":{"title":"ls -la","version":1}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"content":[{"content":{"text":"List files","type":"text"},"type":"content"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls -la","description":"List files"},"status":"pending","title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"content":{"text":"```console\na.ts\nb.ts\n```","type":"text"},"type":"content"}],"rawOutput":"a.ts\nb.ts","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"interrupted":false,"isImage":false,"stderr":"","stdout":"a.ts\nb.ts"}}},"sessionUpdate":"tool_call_update","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/origin-main/plain/compaction-failed-legacy.jsonl
generated
Normal file
4
src/tests/acp-scenarios/origin-main/plain/compaction-failed-legacy.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"compact"},"contextCompaction":{"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":{"claudeCode":{"toolName":"compact"},"contextCompaction":{"error":"Context too small","version":1}},"content":[{"content":{"text":"Compaction failed: Context too small","type":"text"},"type":"content"}],"rawOutput":{"error":"Context too small"},"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/origin-main/plain/compaction-legacy.jsonl
generated
Normal file
7
src/tests/acp-scenarios/origin-main/plain/compaction-legacy.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"compact"},"contextCompaction":{"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":{"claudeCode":{"toolName":"compact"},"contextCompaction":{"version":1}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"compact"},"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1}},"rawOutput":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic"},"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}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user