mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-31 14:31:05 +00:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e1fc1e8df | ||
|
|
92abb8fd4d | ||
|
|
96b6b4f478 | ||
|
|
03d84f6663 | ||
|
|
15034659f7 | ||
|
|
55c3106c60 | ||
|
|
060bdb212a | ||
|
|
446a7217fa | ||
|
|
908c994901 | ||
|
|
966be7abe6 | ||
|
|
d43fec3fa3 | ||
|
|
0304f743f8 | ||
|
|
6a162baf8b | ||
|
|
b2dbc8f5a1 | ||
|
|
390b0b9f8b | ||
|
|
0724b17c58 | ||
|
|
0e83a06c4a | ||
|
|
7b5c61a4ed | ||
|
|
5598efbc8a | ||
|
|
07c7ed3752 | ||
|
|
3a61172e19 | ||
|
|
4ddda8f37e | ||
|
|
96acfeccf9 | ||
|
|
52a92be959 | ||
|
|
8589a9cf15 | ||
|
|
a44c486019 | ||
|
|
d599333b72 | ||
|
|
b1d6cfc6c9 | ||
|
|
3d9bae941c | ||
|
|
98b17c28c8 | ||
|
|
22139f73c4 | ||
|
|
bd8a161c2e | ||
|
|
63aa3b8428 | ||
|
|
b9a6ec00a6 | ||
|
|
c1c8c0377d | ||
|
|
23b8ebf8c2 | ||
|
|
2b7fab7d98 | ||
|
|
d3e673b263 | ||
|
|
d83f28a403 | ||
|
|
ba5ed170ae | ||
|
|
d24272c6b0 | ||
|
|
190a00f68f | ||
|
|
ddd5f54187 | ||
|
|
3d4017b15a | ||
|
|
6b69852cfc | ||
|
|
c60fa48fd8 | ||
|
|
686c0c99b3 | ||
|
|
8e893f51ea | ||
|
|
0c862e2949 | ||
|
|
4ffa625977 | ||
|
|
7f8d5471e2 | ||
|
|
c84845272f | ||
|
|
af0b43de94 | ||
|
|
f53b0d7f40 | ||
|
|
4af807968c | ||
|
|
bdb50ad984 | ||
|
|
d5e6f33046 | ||
|
|
691328a919 | ||
|
|
49858b5273 | ||
|
|
77ceab0339 | ||
|
|
18de376240 | ||
|
|
875d75f2a4 | ||
|
|
e6681d2a57 | ||
|
|
5dbb453c63 | ||
|
|
b495e55a22 | ||
|
|
a2c362c948 | ||
|
|
b264b52bee | ||
|
|
da11f1feb2 | ||
|
|
df38cccae0 | ||
|
|
39062b63ff | ||
|
|
29a4e98477 | ||
|
|
6e97e071c5 | ||
|
|
2b0201a801 | ||
|
|
97394d93b6 | ||
|
|
388a0f5191 | ||
|
|
9392c69cc6 | ||
|
|
281e47eb81 | ||
|
|
43c27e97aa | ||
|
|
bd059c10e0 | ||
|
|
58c5db1fdc | ||
|
|
a2f4239543 | ||
|
|
90cde4b397 | ||
|
|
d571358e26 | ||
|
|
eb989e93f4 | ||
|
|
79851b8668 |
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
|
||||
@@ -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.80.0"
|
||||
".": "0.89.1"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ npm run format # prettier --write
|
||||
CI runs `format:check`, `lint`, `build` and `test:run`. Run `npm run check` before
|
||||
opening a PR.
|
||||
|
||||
## Code comments
|
||||
|
||||
Keep comments straightforward, concise, and concrete. Say what the code does and
|
||||
why, in plain terms: name the actual state, call, or failure involved instead of
|
||||
alluding to it. Prefer a short concrete example over an abstract description.
|
||||
Don't use vague phrases like "keeps it honest" or "the residual case" that make
|
||||
the reader reconstruct the meaning.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Squash merges use the PR title as the commit subject, and release-please parses it
|
||||
|
||||
148
CHANGELOG.md
148
CHANGELOG.md
@@ -1,5 +1,153 @@
|
||||
# Changelog
|
||||
|
||||
## [0.89.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.89.0...v0.89.1) (2026-10-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* push renamed sessions to list subscribers and reuse one listing across pages ([#1293](https://github.com/agentclientprotocol/claude-agent-acp/issues/1293)) ([92abb8f](https://github.com/agentclientprotocol/claude-agent-acp/commit/92abb8fd4db862c465c4b3a49ea32dac0720de2c))
|
||||
|
||||
## [0.89.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.88.0...v0.89.0) (2026-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add the AIR sessionIndex extension for fast session lists ([#1268](https://github.com/agentclientprotocol/claude-agent-acp/issues/1268)) ([03d84f6](https://github.com/agentclientprotocol/claude-agent-acp/commit/03d84f6663a2f561b3c59bc30e622e085b869024))
|
||||
* **deps:** Bump @agentclientprotocol/sdk to 1.8.0 ([#1287](https://github.com/agentclientprotocol/claude-agent-acp/issues/1287)) ([446a721](https://github.com/agentclientprotocol/claude-agent-acp/commit/446a7217fa2654a9bcfaff9ddbfcdf2917cef024))
|
||||
* Support custom instructions for agent sessions via meta ([#1177](https://github.com/agentclientprotocol/claude-agent-acp/issues/1177)) ([908c994](https://github.com/agentclientprotocol/claude-agent-acp/commit/908c994901b857918c23655670468298f6bbd33b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* recreate live sessions on logout when it clears a provider or gateway override ([#1278](https://github.com/agentclientprotocol/claude-agent-acp/issues/1278)) ([966be7a](https://github.com/agentclientprotocol/claude-agent-acp/commit/966be7abe60435f2f1c6379a5255ea7d533a1a20))
|
||||
* report a failed turn with the error stop reason on the experimental ACP v2 surface ([#1288](https://github.com/agentclientprotocol/claude-agent-acp/issues/1288)) ([060bdb2](https://github.com/agentclientprotocol/claude-agent-acp/commit/060bdb212a9354f2dd0c88e4234a7659aeb6d27e))
|
||||
* report an interrupted tool call as cancelled on the experimental ACP v2 surface ([#1289](https://github.com/agentclientprotocol/claude-agent-acp/issues/1289)) ([55c3106](https://github.com/agentclientprotocol/claude-agent-acp/commit/55c3106c60f58909af76f75df9f6c5815dcf0021))
|
||||
|
||||
## [0.88.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.87.0...v0.88.0) (2026-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1276](https://github.com/agentclientprotocol/claude-agent-acp/issues/1276)) ([0304f74](https://github.com/agentclientprotocol/claude-agent-acp/commit/0304f743f85e2505ad5a67c1fe7b7d24f2f9ad23))
|
||||
|
||||
## [0.87.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.86.0...v0.87.0) (2026-10-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump the minor group with 2 updates ([#1260](https://github.com/agentclientprotocol/claude-agent-acp/issues/1260)) ([0724b17](https://github.com/agentclientprotocol/claude-agent-acp/commit/0724b17c581e30b1242cfbec7bce44c21df99525))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* async task routing, Monitor tasks, structured task ids, and abandoned tool calls ([#1249](https://github.com/agentclientprotocol/claude-agent-acp/issues/1249)) ([0e83a06](https://github.com/agentclientprotocol/claude-agent-acp/commit/0e83a06c4a8825ba654bddf1ab909d1a773942e2))
|
||||
* settle prompts folded into a task-notification cycle ([#1233](https://github.com/agentclientprotocol/claude-agent-acp/issues/1233)) ([390b0b9](https://github.com/agentclientprotocol/claude-agent-acp/commit/390b0b9f8bf0cc0aa7d9273a7b614172c916d3a6))
|
||||
|
||||
## [0.86.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.1...v0.86.0) (2026-10-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* answer /mcp in chat with the MCP server status and a reconnect ([#1218](https://github.com/agentclientprotocol/claude-agent-acp/issues/1218)) ([190a00f](https://github.com/agentclientprotocol/claude-agent-acp/commit/190a00f68f773315e93630464397944e645616ff))
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.287 ([#1224](https://github.com/agentclientprotocol/claude-agent-acp/issues/1224)) ([ddd5f54](https://github.com/agentclientprotocol/claude-agent-acp/commit/ddd5f541879118edb18df7ff09cd6682d455c710))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* count a subagent's permission requests as the turn waiting on the user ([#1241](https://github.com/agentclientprotocol/claude-agent-acp/issues/1241)) ([d599333](https://github.com/agentclientprotocol/claude-agent-acp/commit/d599333b722a951553c0efd40a76559b1f32e6ab))
|
||||
* drop queued prompts a cancel ends from Claude Code's queue ([#1228](https://github.com/agentclientprotocol/claude-agent-acp/issues/1228)) ([d83f28a](https://github.com/agentclientprotocol/claude-agent-acp/commit/d83f28a4035c5a5e54950fd58c7b73bfc2313664))
|
||||
* end a cancelled turn when the force-cancel fires during an update ([#1243](https://github.com/agentclientprotocol/claude-agent-acp/issues/1243)) ([a44c486](https://github.com/agentclientprotocol/claude-agent-acp/commit/a44c486019e98ad478c549c4902e7092f1418d0b))
|
||||
* end a closed session's turns before session/close answers ([#1242](https://github.com/agentclientprotocol/claude-agent-acp/issues/1242)) ([b1d6cfc](https://github.com/agentclientprotocol/claude-agent-acp/commit/b1d6cfc6c9fc9ec397e607b68ac100f4d48745b3))
|
||||
* end a held turn cancelled mid-followup at its interrupt's idle ([#1229](https://github.com/agentclientprotocol/claude-agent-acp/issues/1229)) ([d3e673b](https://github.com/agentclientprotocol/claude-agent-acp/commit/d3e673b263cf47603a35a50bb3da8b483199f690))
|
||||
* excessive message/token_count API calls when probing context window ([#1258](https://github.com/agentclientprotocol/claude-agent-acp/issues/1258)) ([5598efb](https://github.com/agentclientprotocol/claude-agent-acp/commit/5598efbc8ab16720ea79f5c97db40771ad0b317b))
|
||||
* replay a session with a Write on the experimental ACP v2 surface ([#1238](https://github.com/agentclientprotocol/claude-agent-acp/issues/1238)) ([22139f7](https://github.com/agentclientprotocol/claude-agent-acp/commit/22139f73c48e621752089a91fb095269493896b1))
|
||||
* report a command's exit code only when the tool result says it ([#1252](https://github.com/agentclientprotocol/claude-agent-acp/issues/1252)) ([52a92be](https://github.com/agentclientprotocol/claude-agent-acp/commit/52a92be959f7f8ea90ab08d54bae6900596ab435))
|
||||
* restore main-thread agent config option ([#1250](https://github.com/agentclientprotocol/claude-agent-acp/issues/1250)) ([8589a9c](https://github.com/agentclientprotocol/claude-agent-acp/commit/8589a9cf150d8deca6d2f8ef24404d927b3d4828))
|
||||
|
||||
## [0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1) (2026-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* answer session/close without waiting for the interrupt reply ([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216)) ([0c862e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c862e29496bb018e7a4400697bd2315dfd5b845))
|
||||
* fail turns with unfinished tools ([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212)) ([7f8d547](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f8d5471e24b981b129b3c9c2d6736c4ba3f8011))
|
||||
* restore background task stops on replay instead of showing them as prompts ([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205)) ([4ffa625](https://github.com/agentclientprotocol/claude-agent-acp/commit/4ffa625977af7e85526b9a3098f4dee5844467b4))
|
||||
|
||||
## [0.85.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.84.0...v0.85.0) (2026-10-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.286 ([#1211](https://github.com/agentclientprotocol/claude-agent-acp/issues/1211)) ([f53b0d7](https://github.com/agentclientprotocol/claude-agent-acp/commit/f53b0d7f40bcd0e9bddace85f123ff4851afcb1f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt to SDK 0.3.284 replay and ultracode changes ([#1208](https://github.com/agentclientprotocol/claude-agent-acp/issues/1208)) ([4af8079](https://github.com/agentclientprotocol/claude-agent-acp/commit/4af807968c7f0813bcb20fe2bcab2370320e9074))
|
||||
* send the legacy custom answer key to AIR ([#1210](https://github.com/agentclientprotocol/claude-agent-acp/issues/1210)) ([af0b43d](https://github.com/agentclientprotocol/claude-agent-acp/commit/af0b43de944e36f81343f40b7aa1a96cd48c3218))
|
||||
|
||||
## [0.84.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.83.0...v0.84.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1189](https://github.com/agentclientprotocol/claude-agent-acp/issues/1189)) ([d5e6f33](https://github.com/agentclientprotocol/claude-agent-acp/commit/d5e6f33046614fca4798d49f0c77edba951a6771))
|
||||
|
||||
## [0.83.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.82.0...v0.83.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.283 ([#1186](https://github.com/agentclientprotocol/claude-agent-acp/issues/1186)) ([49858b5](https://github.com/agentclientprotocol/claude-agent-acp/commit/49858b527397a08d343fe2c148a5fe9e3eb84547))
|
||||
|
||||
## [0.82.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.2...v0.82.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* AIR tool call contract, exact git patches, plan file, and fixes for every client ([#1153](https://github.com/agentclientprotocol/claude-agent-acp/issues/1153)) ([875d75f](https://github.com/agentclientprotocol/claude-agent-acp/commit/875d75f2a4941d9c6313ed51f89da0f42443ac2f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use ACP auth errors without access updates ([#1179](https://github.com/agentclientprotocol/claude-agent-acp/issues/1179)) ([e6681d2](https://github.com/agentclientprotocol/claude-agent-acp/commit/e6681d2a5734857727352474c8c9aa848f9210ee))
|
||||
|
||||
## [0.81.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.1...v0.81.2) (2026-09-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* announce resumed subagent generations when they start ([#1173](https://github.com/agentclientprotocol/claude-agent-acp/issues/1173)) ([b495e55](https://github.com/agentclientprotocol/claude-agent-acp/commit/b495e55a2249f066dab76e56316080f151df99be))
|
||||
* continue clear-context plans approved in a background followup ([#1170](https://github.com/agentclientprotocol/claude-agent-acp/issues/1170)) ([a2c362c](https://github.com/agentclientprotocol/claude-agent-acp/commit/a2c362c948511b203b43982f13ab068ba14741ea))
|
||||
|
||||
## [0.81.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.0...v0.81.1) (2026-09-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* gate login result text on error state ([#1103](https://github.com/agentclientprotocol/claude-agent-acp/issues/1103)) ([6e97e07](https://github.com/agentclientprotocol/claude-agent-acp/commit/6e97e071c581002b43d9556dbd1d1376ebf9516f))
|
||||
* honor permissions.disableBypassPermissionsMode ([#1165](https://github.com/agentclientprotocol/claude-agent-acp/issues/1165)) ([281e47e](https://github.com/agentclientprotocol/claude-agent-acp/commit/281e47eb810da748b9e66798086f0010dbb4dc4b))
|
||||
* include model on usage updates ([#1032](https://github.com/agentclientprotocol/claude-agent-acp/issues/1032)) ([da11f1f](https://github.com/agentclientprotocol/claude-agent-acp/commit/da11f1feb269b93f13fde7470f2b5a1b070867a9))
|
||||
* keep the agent starting when the managed-policy tier cannot be read ([#1146](https://github.com/agentclientprotocol/claude-agent-acp/issues/1146)) ([58c5db1](https://github.com/agentclientprotocol/claude-agent-acp/commit/58c5db1fdc1b6fbce27224523fddebd3c52a65dd))
|
||||
* mark informational chunks with metadata ([#1055](https://github.com/agentclientprotocol/claude-agent-acp/issues/1055)) ([bd059c1](https://github.com/agentclientprotocol/claude-agent-acp/commit/bd059c10e042c126933f27abd33f5523f39fab72))
|
||||
* offer bypass alongside auto in plan approval ([#1164](https://github.com/agentclientprotocol/claude-agent-acp/issues/1164)) ([43c27e9](https://github.com/agentclientprotocol/claude-agent-acp/commit/43c27e97aacd9b711aefcb4b84271bf872c2eea7))
|
||||
* preserve context usage when quota is exhausted ([#1132](https://github.com/agentclientprotocol/claude-agent-acp/issues/1132)) ([39062b6](https://github.com/agentclientprotocol/claude-agent-acp/commit/39062b63ff3087fc7ce2655a9738450f5f61682c))
|
||||
* preserve native Vertex endpoint defaults ([#1104](https://github.com/agentclientprotocol/claude-agent-acp/issues/1104)) ([29a4e98](https://github.com/agentclientprotocol/claude-agent-acp/commit/29a4e9847761837ab85ea42d4bab9267be606755))
|
||||
* publish effective mode after plan approval ([#1163](https://github.com/agentclientprotocol/claude-agent-acp/issues/1163)) ([a2f4239](https://github.com/agentclientprotocol/claude-agent-acp/commit/a2f4239543b22d0928fbdbef7a9c7a4854b3d61d))
|
||||
* recreate resumed sessions when any session option changes ([#1097](https://github.com/agentclientprotocol/claude-agent-acp/issues/1097)) ([df38ccc](https://github.com/agentclientprotocol/claude-agent-acp/commit/df38cccae04ce4abb525447c5e8d9db78ffec61c))
|
||||
* render Write tool calls that use path/file_text aliases ([#1161](https://github.com/agentclientprotocol/claude-agent-acp/issues/1161)) ([90cde4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/90cde4b3974656794fbb274ac6ad8e57f701e618))
|
||||
* replay marker-only slash skill prompts ([#1035](https://github.com/agentclientprotocol/claude-agent-acp/issues/1035)) ([97394d9](https://github.com/agentclientprotocol/claude-agent-acp/commit/97394d93b695c141eda71b85025bc4e22141b8c4))
|
||||
* **session:** recreate query when configured skills change ([#968](https://github.com/agentclientprotocol/claude-agent-acp/issues/968)) ([388a0f5](https://github.com/agentclientprotocol/claude-agent-acp/commit/388a0f51914ef59f2168007b633927e1a9d6ec48)), closes [#955](https://github.com/agentclientprotocol/claude-agent-acp/issues/955)
|
||||
* settle steered turns on the result that answers the steer ([#1166](https://github.com/agentclientprotocol/claude-agent-acp/issues/1166)) ([9392c69](https://github.com/agentclientprotocol/claude-agent-acp/commit/9392c69cc66591ade56a08200d807577180aa682))
|
||||
|
||||
## [0.81.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.80.0...v0.81.0) (2026-09-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1157](https://github.com/agentclientprotocol/claude-agent-acp/issues/1157)) ([eb989e9](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb989e93f4e20315a85405ef099f97eec899df13))
|
||||
* Send experimental session notices for live advisories ([#1155](https://github.com/agentclientprotocol/claude-agent-acp/issues/1155)) ([79851b8](https://github.com/agentclientprotocol/claude-agent-acp/commit/79851b86685b8a0673dec54d85d6fa2a3b749cfb))
|
||||
|
||||
## [0.80.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.79.0...v0.80.0) (2026-09-22)
|
||||
|
||||
|
||||
|
||||
14
README.md
14
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,13 @@ 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)
|
||||
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
|
||||
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)
|
||||
- Concrete model and effort defaults through the opt-in [recommended config value extension](docs/air-extensions.md#recommended-config-values)
|
||||
- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/air-extensions.md#permission-presentation)
|
||||
- One fact per field in every tool call report, as the [ACP tool call contract](docs/air-extensions.md#tool-call-contract) defines
|
||||
- All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md)
|
||||
|
||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
@@ -40,7 +44,7 @@ in its initialize response. The canonical field remains supported and takes prec
|
||||
available. Without either client signal, Agent/Task lifecycle keeps its legacy ordinary ACP
|
||||
tool-call representation and child interactions stay on the root session. Clients that use the
|
||||
historical `_meta["subagent-transcript"]` capability or `forwardSubagentText` session option retain
|
||||
the flattened child transcript behavior.
|
||||
the flattened child transcript behavior. See [AIR extensions](docs/air-extensions.md#native-subagent-sessions).
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
|
||||
1555
docs/air-extensions.md
Normal file
1555
docs/air-extensions.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
# AIR diff statistics
|
||||
|
||||
The adapter supplies counts in each ACP diff block through `_meta.jetbrains.air.diffStats`.
|
||||
This uses the same contract as the Codex adapter. No capability negotiation is required.
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"jetbrains": {
|
||||
"air": {
|
||||
"version": 1,
|
||||
"diffStats": { "version": 1, "added": 20, "removed": 30 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both counts are nonnegative integers. They describe the operations in that block's patch.
|
||||
Context lines and EOF markers do not increase the counts.
|
||||
The extension carries no navigation position.
|
||||
|
||||
For `Edit` and `Write`, the adapter counts operations while it builds diff texts from the SDK's `structuredPatch`.
|
||||
Each hunk produces one block with its own counts. The adapter does not compare or scan the full file texts for statistics.
|
||||
|
||||
When a patch has unsafe, unordered, or mutually inconsistent coordinates, inconsistent line counts,
|
||||
misplaced EOF markers, or unsupported prefixes, the adapter omits the statistics.
|
||||
Empty patches, initial tool inputs, and history without structured patches retain their existing behavior.
|
||||
They provide no statistics because the adapter has no patch operations to count.
|
||||
AIR uses its existing comparison when statistics are absent.
|
||||
@@ -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,253 +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. |
|
||||
| `defaultToNo` | no | `true` | The ask must not be approvable by a stray keystroke; the reject options are listed first and a client that pre-selects an option should focus the decline. |
|
||||
|
||||
The permission title normally duplicates `toolCall.title`: one operation has one heading across the
|
||||
tool card and approval UI. `ExitPlanMode` is the deliberate exception and uses the action-oriented
|
||||
permission heading `Ready to code?`. Commands, paths, URLs, and other structured details remain in
|
||||
`rawInput`, `content`, and `locations`.
|
||||
|
||||
`description` temporarily carries the SDK's non-blank `decisionReason` for diagnostics, prefixed
|
||||
with `Reason: `. The SDK `description` operation subtitle is not copied there.
|
||||
|
||||
## Tool-call presentation
|
||||
|
||||
The permission request carries the same standard ACP tool information used for normal tool updates:
|
||||
|
||||
- `name` is the SDK tool name (for example `Read` or `mcp__server__tool`), matching the standard
|
||||
`name` field on the initial `tool_call` update;
|
||||
- `kind`, `title`, `content`, and `locations` come from `toolInfoFromToolUse`;
|
||||
- `status` is `pending`;
|
||||
- `rawInput` is the original SDK input object;
|
||||
- `blockedPath` is appended to `locations` when it is valid and not already present;
|
||||
- subagent calls include `_meta.claudeCode.parentToolUseId` on the tool call;
|
||||
- Sandbox Network and Computer Use requests synthesize JSON content when the normal renderer has no
|
||||
content.
|
||||
|
||||
Compact text removes control characters, collapses whitespace where appropriate, and enforces length
|
||||
limits. Invalid optional presentation text is omitted instead of being truncated into misleading UI.
|
||||
Shell permission titles (`Bash` and `PowerShell`) preserve the full command verbatim, including
|
||||
whitespace and line breaks, without applying compact-text normalization or length limits.
|
||||
|
||||
Permission options are fixed. When a durable suggestion contains a command prefix, path, host, or
|
||||
other rule, the adapter includes that value directly in `PermissionOption.name`, for example
|
||||
`Yes, and don't ask again for npm test commands`. Selecting the option applies the exact snapshotted
|
||||
Claude SDK `PermissionUpdate`; the client cannot edit it. A selected reject is distinct from
|
||||
`{ "outcome": "cancelled" }`: cancellation aborts the tool use instead of returning a user rejection.
|
||||
|
||||
## Standard option ids
|
||||
|
||||
| Option id | Meaning |
|
||||
| ------------------------------ | ----------------------------------------------------------- |
|
||||
| `allow-once` | Allow this call without changing permission state. |
|
||||
| `allow-with-updates` | Allow and apply the displayed durable effect. |
|
||||
| `allow-skill-exact` | Allow the exact Skill invocation in local settings. |
|
||||
| `allow-skill-prefix` | Allow the parameterized Skill prefix in local settings. |
|
||||
| `exit-plan-auto` | Exit plan mode and set session mode to `auto`. |
|
||||
| `exit-plan-bypass` | Exit plan mode and set session mode to `bypassPermissions`. |
|
||||
| `exit-plan-accept-edits` | Exit plan mode and set session mode to `acceptEdits`. |
|
||||
| `exit-plan-default` | Exit plan mode and set session mode to `default`. |
|
||||
| `exit-plan-clear-auto` | Clear context, continue the plan, and use `auto`. |
|
||||
| `exit-plan-clear-bypass` | Clear context, continue the plan, and bypass permissions. |
|
||||
| `exit-plan-clear-accept-edits` | Clear context, continue the plan, and accept edits. |
|
||||
| `reject` | Deny the call, optionally returning feedback to Claude. |
|
||||
|
||||
The adapter stably groups options as `allow_once`, `allow_always`, then reject kinds. Options of the
|
||||
same kind retain their builder order. Clients must return the selected option id unchanged.
|
||||
|
||||
## Durable SDK suggestions
|
||||
|
||||
Claude may provide `suggestions: PermissionUpdate[]`. Before a suggestion can appear as a durable
|
||||
choice, the adapter validates the complete array and takes a structured clone. Validation recognizes:
|
||||
|
||||
- `addRules`, `replaceRules`, and `removeRules`;
|
||||
- `setMode` for known Claude permission modes;
|
||||
- `addDirectories` and `removeDirectories`;
|
||||
- known destinations and rule behaviors;
|
||||
- bounded array sizes and bounded non-empty strings.
|
||||
|
||||
Unknown update types, invalid values, oversized bundles, non-cloneable data, and empty suggestion
|
||||
arrays are omitted. The snapshot prevents the label shown to the user from diverging from the effect
|
||||
returned after a delayed response.
|
||||
|
||||
An SDK suggestion is offered only when the tool-specific builder can accurately describe the entire
|
||||
effect. The adapter never labels one part of a mixed bundle while silently applying the rest.
|
||||
|
||||
When `matchedAskRule` is present, all persistent choices are suppressed. A configured `ask` rule must
|
||||
continue asking; accepting a generated allow rule that cannot override it would be misleading.
|
||||
|
||||
## Tool-specific behavior
|
||||
|
||||
| Tool family | Permission choices and effects |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Bash / PowerShell | One-time allow; exact representable SDK bundle with its complete effect in the option label; reject. |
|
||||
| Read / Glob / Grep | One-time allow; matching session-scoped read grant; reject. |
|
||||
| Edit / Write / NotebookEdit | One-time allow; matching session edit grant, including `.claude` handling; reject. |
|
||||
| WebFetch | One-time allow; generated `domain:<hostname>` local rule; reject. |
|
||||
| Skill | One-time allow; exact skill and optional `prefix:*` local rules; reject. |
|
||||
| EnterPlanMode | Enter plan mode once, or reject and continue implementing. |
|
||||
| ExitPlanMode | One keep-context and one clear-context elevated mode (`auto` > bypass > accept edits), manual approval, or reject. |
|
||||
| SandboxNetworkAccess | One-time allow; exact SDK host rule when representable; reject. |
|
||||
| Computer Use MCP | One-time allow; exact representable SDK allow suggestion; reject. |
|
||||
| MCP | One-time allow; exact representable SDK allow suggestion for that tool; reject. |
|
||||
| WebSearch / Agent / Task | One-time allow; generated whole-tool local rule; reject. |
|
||||
| ReviewArtifact / Workflow | Generic fallback behavior until SDK renderer state is available. |
|
||||
| Monitor / unknown tools | Generic fallback behavior. |
|
||||
|
||||
Generated WebFetch, Skill, and non-MCP fallback effects use `localSettings`. Filesystem grants are
|
||||
offered only when the SDK bundle is session-scoped and covers the current path. Shell labels may
|
||||
describe command rules, read paths, additional directories, or a representable combination.
|
||||
|
||||
`AskUserQuestion` is not a permission dialog. With ACP form elicitation support it is routed through
|
||||
ACP elicitation; without that support it is disabled when the Claude session is created.
|
||||
|
||||
## Permission modes and bypass
|
||||
|
||||
The session exposes the Claude modes available for the current model using Claude Code's labels:
|
||||
`Manual`, `Accept edits`, `Plan`, conditional `Auto`, and conditional `Bypass permissions`. The
|
||||
internal `dontAsk` SDK mode is accepted from settings for compatibility but is not advertised as a
|
||||
user-selectable mode.
|
||||
|
||||
Hosts can remove `Bypass permissions` from a session by sending
|
||||
`_meta.claudeCode.options.allowDangerouslySkipPermissions: false` on `session/new` (and again on
|
||||
`session/load`); the mode leaves the catalog and a `bypassPermissions` settings default clamps to
|
||||
`default`.
|
||||
|
||||
The ACP mode ids are the Claude SDK wire ids: `default`, `acceptEdits`, `plan`, `auto`, and
|
||||
`bypassPermissions`. `Manual` deliberately retains the SDK id `default`; `manual` is only a settings
|
||||
input alias. The adapter does not invent parallel ids for presentation labels.
|
||||
|
||||
Like Claude Code, `ExitPlanMode` offers one elevated keep-context choice by priority: `auto`, then
|
||||
`bypassPermissions`, then `acceptEdits`. A non-empty plan also gets exactly one clear-context choice
|
||||
with the same priority. This requires no ACP capability or setting: after selection the adapter
|
||||
interrupts the old private Claude query, hides that internal rejection from the client, creates a
|
||||
new private query under the same public ACP session, and continues the pending turn with
|
||||
`Implement the following plan:` followed by the complete plan. Mode, model, agent, effort, Fast
|
||||
mode, and accumulated turn usage are preserved.
|
||||
|
||||
The SDK cannot reapply `allowedPrompts` through a public control request, so those renderer hints do
|
||||
not survive this internal handoff. Ultraplan remains unavailable because its required renderer state
|
||||
is not exposed by the SDK.
|
||||
|
||||
The adapter does not auto-allow a callback merely because the session advertises
|
||||
`bypassPermissions`. Claude Code applies bypass before invoking `canUseTool`. A request that still
|
||||
reaches the callback is bypass-immune, safety-sensitive, interactive, or forced by an explicit ask
|
||||
rule and must remain visible to the user.
|
||||
|
||||
## Cancellation and failures
|
||||
|
||||
The SDK tool-call `AbortSignal` is forwarded to the ACP client. The adapter also races the client
|
||||
request locally against the same signal, so a client that ignores cancellation cannot leave Claude's
|
||||
tool call waiting forever.
|
||||
|
||||
These cases all abort the tool use with `Tool use aborted`:
|
||||
|
||||
- the signal was already aborted;
|
||||
- the signal aborts while the permission request is open;
|
||||
- the client returns a cancelled outcome;
|
||||
- the client rejects after cancellation.
|
||||
|
||||
A normal selected reject returns `behavior: "deny"`, `decisionClassification: "user_reject"`, and
|
||||
the default refusal message.
|
||||
|
||||
## Client requirements
|
||||
|
||||
A client implementing version 1 should:
|
||||
|
||||
- render standard ACP tool-call content and locations as the action subject;
|
||||
- treat request `_meta.permission.title` and `description` as optional presentation hints;
|
||||
- preserve option ids;
|
||||
- ignore unknown `_meta` fields and future feature names;
|
||||
- settle the request when its cancellation signal fires.
|
||||
|
||||
The client must not infer storage scope, lifetime, or permission effects from `kind` or button text.
|
||||
The adapter owns the Claude `PermissionUpdate` and applies it only after validating the response.
|
||||
|
||||
## Current limits
|
||||
|
||||
The SDK does not expose enough renderer state to reproduce every Claude Code dialog. Version 1 does
|
||||
not implement:
|
||||
|
||||
- Bash output-redirection rewriting;
|
||||
- classifier-specific prompt state;
|
||||
- ExitPlanMode Ultraplan actions;
|
||||
- Computer Use macOS/TCC or application-allowlist dialogs;
|
||||
- structured permission lifetime or storage metadata for clients.
|
||||
|
||||
These omissions do not weaken the one-time choice. When a durable effect cannot be represented
|
||||
honestly, the adapter sends only one-time allow and reject.
|
||||
|
||||
## Verification
|
||||
|
||||
The executable contract lives in:
|
||||
|
||||
- `src/tests/permission-presentation.test.ts` for capability and request presentation;
|
||||
- `src/tests/permission-options.test.ts` for tool-specific option construction;
|
||||
- `src/tests/permission-effects.test.ts` for response validation and effects;
|
||||
- `src/tests/acp-agent.test.ts` for request ordering, cancellation, bypass, and subagent attribution;
|
||||
- `src/tests/session-config-options.test.ts` for plan-mode integration;
|
||||
- `src/tests/live-capability-*-audit.test.ts` for opt-in live SDK audits.
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run check
|
||||
npm run test:run
|
||||
```
|
||||
@@ -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,209 +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` |
|
||||
| `verification_required`, `cloud_credential_error` | `access` (no `login` action) |
|
||||
| `billing_error`, `rate_limit`, `max_output_tokens`, usage/spend limit, budget/turn limit | `limit` |
|
||||
| `invalid_request`, `model_not_found` | `request` |
|
||||
| `overloaded`, `server_error`, unknown provider error, adapter internal error | `service` |
|
||||
| API retry without an HTTP response, query transport loss, worker shutdown | `connection` |
|
||||
| model fallback notice | `unknown` with `severity: warning` |
|
||||
|
||||
Unknown SDK error kinds degrade to `service`; they never become success. Category does not determine
|
||||
message text or client behavior beyond presentation.
|
||||
|
||||
## Severity
|
||||
|
||||
- `warning` means the operation may still succeed and normally has no actions while recovery is in
|
||||
progress. It does not terminate the turn. SDK `api_retry` progress reuses the active turn failure id;
|
||||
a later terminal failure updates that record with a higher revision.
|
||||
- `error` means the operation cannot continue without user action or another request.
|
||||
|
||||
Severity is always explicit.
|
||||
|
||||
## Title and details
|
||||
|
||||
`title` is the complete normal presentation: what happened, retry progress when present, and a short
|
||||
next step. The adapter must not replace a real Claude message with category-specific canned wording.
|
||||
|
||||
For Claude SDK failures the title is copied from the user-facing text Claude already emitted:
|
||||
|
||||
- top-level assistant error text, including synthetic usage-limit and login messages;
|
||||
- otherwise the terminal SDK result or error text;
|
||||
- the exact model-fallback notice for warning records.
|
||||
|
||||
Adapter-authored fallback text is allowed only when no Claude user-facing message exists, such as a
|
||||
query iterator throwing, worker-shutdown EOF, or an internal missing-result invariant. Raw thrown
|
||||
exceptions, stack traces, transport URLs, tokens, headers, environment values, and private paths are
|
||||
never used as titles.
|
||||
|
||||
`details` is reserved exclusively for required explanations too large to fit reasonably in `title`.
|
||||
If the complete message fits in `title`, `details` must be omitted. It is not a place for status text,
|
||||
retry counters, provider payloads, or diagnostics.
|
||||
|
||||
## Actions
|
||||
|
||||
Version 1 defines only:
|
||||
|
||||
| Action | Meaning |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `retry` | Retry the operation associated with this incident. |
|
||||
| `login` | Start authentication. |
|
||||
| `new_session` | Start a fresh agent session/runtime. |
|
||||
|
||||
The adapter supplies action ordering. The client filters actions it cannot safely execute and ignores
|
||||
unknown or duplicate values. The client must not infer actions from category.
|
||||
|
||||
Current Claude policies are:
|
||||
|
||||
- authentication: `login`;
|
||||
- provider overload/error and recoverable internal error: `retry`;
|
||||
- transport loss or worker shutdown: `new_session`;
|
||||
- context or configured session-budget exhaustion: `new_session`;
|
||||
- account quota and invalid request: no action.
|
||||
|
||||
## Internal recovery
|
||||
|
||||
Recovery policy is internal adapter state and is not serialized:
|
||||
|
||||
- restored quota remains active until a real model answer;
|
||||
- authentication remains active until successful `auth_status`;
|
||||
- transport loss and worker shutdown remain active until runtime replacement;
|
||||
- other turn-scoped failures stop being active at a later confirmed attempt boundary;
|
||||
- notices are not cleared merely by generic success.
|
||||
|
||||
Recovery never removes the transcript record. Publishing another incident is not evidence that an
|
||||
older one recovered.
|
||||
|
||||
## History replay
|
||||
|
||||
`session/load` scans top-level assistant history for SDK synthetic usage-limit messages. It recognizes
|
||||
only `<synthetic>` messages beginning with SDK-owned stable prefixes, not arbitrary model prose. Every
|
||||
recognized occurrence is replayed as a typed record at its original transcript position. The latest
|
||||
matching message stays active until a later real-model answer proves recovery; older and recovered
|
||||
records remain historical typed entries.
|
||||
|
||||
For a capable client, replay suppresses duplicate assistant prose and restores typed errors using the
|
||||
exact stored Claude text as `title`. Legacy clients receive the original transcript and no typed record.
|
||||
|
||||
## Verification
|
||||
|
||||
The executable contract lives primarily in `src/tests/acp-agent.test.ts`, under `usage-limit failure
|
||||
replay`, `stop reason propagation`, and `model refusal fallback handling`.
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run check
|
||||
npm run test:run
|
||||
```
|
||||
899
package-lock.json
generated
899
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.80.0",
|
||||
"version": "0.89.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.5.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.278",
|
||||
"@agentclientprotocol/sdk": "1.8.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.293",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.127.0",
|
||||
"@anthropic-ai/sdk": "0.132.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.70.1",
|
||||
"@typescript-eslint/parser": "8.70.1",
|
||||
"eslint": "10.11.0",
|
||||
"@types/node": "26.6.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.1",
|
||||
"@typescript-eslint/parser": "8.71.1",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.12.0",
|
||||
"prettier": "3.9.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.1"
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
3938
src/acp-agent.ts
3938
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,7 +2,56 @@ export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = "nativeSubagentSessions";
|
||||
export const AIR_ASYNC_TASKS_CAPABILITY = "asyncTasks";
|
||||
export const AIR_SESSION_FAILURE_CAPABILITY = "sessionFailure";
|
||||
export const AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY = "recommendedValue";
|
||||
export const AIR_DIFF_STATS_KEY = "diffStats";
|
||||
export const AIR_CUSTOM_INSTRUCTIONS_CAPABILITY = "customInstructions";
|
||||
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 session index: a bounded, ordered `session/list` with row metadata, and
|
||||
* `_session/rename`, `_session/archive`, `_session/unarchive` and the list
|
||||
* subscription. Advertised only to a client that declares it.
|
||||
*/
|
||||
export const AIR_SESSION_INDEX_CAPABILITY = "sessionIndex";
|
||||
/**
|
||||
* The agent supports `_session/archive` and `_session/unarchive`. Advertised
|
||||
* exactly when {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not
|
||||
* declare it.
|
||||
*/
|
||||
export const AIR_SESSION_ARCHIVE_CAPABILITY = "sessionArchive";
|
||||
/**
|
||||
* The agent supports `_session/rename`. Advertised exactly when
|
||||
* {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it.
|
||||
*/
|
||||
export const AIR_SESSION_RENAME_CAPABILITY = "sessionRename";
|
||||
/**
|
||||
* The agent supports `_session/list/subscribe`, `_session/list/unsubscribe`
|
||||
* and `_session/list/changes`. Advertised exactly when
|
||||
* {@link AIR_SESSION_INDEX_CAPABILITY} is; the client does not declare it.
|
||||
*/
|
||||
export const AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY = "sessionListSubscribe";
|
||||
|
||||
/** The `_meta.jetbrains.air` keys that the ACP tool call contract defines. */
|
||||
export const AIR_COMMAND_TITLE_KEY = "commandTitle";
|
||||
export const AIR_SUBAGENT_KEY = "subagent";
|
||||
export const AIR_SKILL_KEY = "skill";
|
||||
export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction";
|
||||
export const AIR_GOAL_KEY = "goal";
|
||||
export const AIR_KIND_KEY = "kind";
|
||||
/** The absolute path of the `SKILL.md` of a skill command in `available_commands_update`. */
|
||||
export const AIR_SKILL_PATH_KEY = "skillPath";
|
||||
export const AIR_PERMISSION_KEY = "permission";
|
||||
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
|
||||
/**
|
||||
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released
|
||||
* AIR versions read. Remove it when the ACP registry no longer serves AIR
|
||||
* versions that read only this spelling.
|
||||
*/
|
||||
export const LEGACY_AIR_CUSTOM_ANSWER_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
@@ -42,6 +91,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];
|
||||
@@ -70,6 +144,13 @@ export function clientSupportsAirCapability(capabilities: unknown, capability: s
|
||||
);
|
||||
}
|
||||
|
||||
/** Read custom instructions from `session/new` metadata. */
|
||||
export function airCustomInstructions(meta: unknown): string | undefined {
|
||||
const air = airExtensionMeta(meta);
|
||||
const instructions = air?.[AIR_CUSTOM_INSTRUCTIONS_CAPABILITY];
|
||||
return typeof instructions === "string" ? instructions : undefined;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk";
|
||||
import type { AcpSessionNotification, AsyncTaskState } from "./acp-subagents.js";
|
||||
import { AIR_ASYNC_TASKS_CAPABILITY, clientSupportsAirCapability } from "./air-extension.js";
|
||||
import { noticeOrTranscriptUpdate } from "./session-notices.js";
|
||||
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
/** Moves an update to its owner session, or drops it with `null`. */
|
||||
type Route = (notification: AcpSessionNotification) => AcpSessionNotification | null;
|
||||
type TerminalSource = "event" | "level" | "shutdown";
|
||||
|
||||
type AsyncTask = {
|
||||
@@ -13,7 +16,19 @@ type AsyncTask = {
|
||||
showInTranscript: boolean;
|
||||
outputFilePath?: string;
|
||||
toolCallId?: string;
|
||||
/**
|
||||
* The route to the session of the tool call that started the task. The
|
||||
* spawn sets it, so every update of the task goes to one session. A task
|
||||
* without a route stays in the root session.
|
||||
*/
|
||||
route?: Route;
|
||||
announced: boolean;
|
||||
/**
|
||||
* Whether the spawn waits for the tool call id. The updates of a held task
|
||||
* wait in {@link AsyncTask.deferred} and follow the spawn in order.
|
||||
*/
|
||||
held: boolean;
|
||||
deferred: (() => Promise<void>)[];
|
||||
ignored: boolean;
|
||||
startedObserved: boolean;
|
||||
panelOnlyRecovery: boolean;
|
||||
@@ -22,10 +37,14 @@ type AsyncTask = {
|
||||
state: AsyncTaskState;
|
||||
terminalSummary?: string;
|
||||
terminalSource?: TerminalSource;
|
||||
/** The optional fields that the client holds now, as JSON. */
|
||||
published: Map<string, string>;
|
||||
};
|
||||
|
||||
type TaskIdentity = { taskId?: unknown; task_id?: unknown };
|
||||
|
||||
const MAX_TOOL_RESULT_PATHS = 500;
|
||||
|
||||
export type AsyncTaskStarted = TaskIdentity & {
|
||||
taskType?: unknown;
|
||||
task_type?: unknown;
|
||||
@@ -58,6 +77,8 @@ type TaskPatch = {
|
||||
|
||||
export type AsyncTaskNotification = TaskIdentity & {
|
||||
status?: unknown;
|
||||
toolCallId?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
summary?: unknown;
|
||||
outputFilePath?: unknown;
|
||||
output_file?: unknown;
|
||||
@@ -65,6 +86,8 @@ export type AsyncTaskNotification = TaskIdentity & {
|
||||
|
||||
type TaskProgress = TaskIdentity & {
|
||||
description?: unknown;
|
||||
toolCallId?: unknown;
|
||||
tool_use_id?: unknown;
|
||||
summary?: unknown;
|
||||
lastToolName?: unknown;
|
||||
last_tool_name?: unknown;
|
||||
@@ -91,11 +114,29 @@ export class AsyncTaskRuntime {
|
||||
* a command was backgrounded can arrive after its terminal SDK edge.
|
||||
*/
|
||||
private readonly tasks = new Map<string, AsyncTask>();
|
||||
/**
|
||||
* The task output paths in the text of each tool result, by tool call
|
||||
* id. A task whose id and tool call are known takes its output path from
|
||||
* here. The tool result can come before or after the SDK names the task.
|
||||
* The oldest entries are dropped.
|
||||
*/
|
||||
private readonly outputPathsByToolCall = new Map<string, string[]>();
|
||||
|
||||
constructor(
|
||||
readonly enabled: boolean,
|
||||
private readonly sessionId: string,
|
||||
private readonly publish: Publish,
|
||||
/** `notices`: the client can present `notice` updates, so the stop
|
||||
* acknowledgement need not be a transcript line. `routeOf`: the route of
|
||||
* the tasks that a tool call in another session started, for example in
|
||||
* a native subagent session. `toolNameOf`: the name of the tool of a tool
|
||||
* call, or `undefined` when the tool call is unknown. A task that a
|
||||
* `Monitor` tool call started is ignored. */
|
||||
private readonly options: {
|
||||
notices?: boolean;
|
||||
routeOf?: (toolCallId: string) => Route | undefined;
|
||||
toolNameOf?: (toolCallId: string) => string | undefined;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
async taskStarted(message: AsyncTaskStarted): Promise<void> {
|
||||
@@ -103,8 +144,8 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,7 +157,8 @@ export class AsyncTaskRuntime {
|
||||
isBackgroundTask(
|
||||
field(message, "isBackgrounded", "is_backgrounded"),
|
||||
field(message, "taskType", "task_type"),
|
||||
)
|
||||
) ||
|
||||
task.held
|
||||
) {
|
||||
await this.announce(task);
|
||||
}
|
||||
@@ -133,8 +175,8 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.task(taskId);
|
||||
if (isSubagentTask(message)) {
|
||||
task.ignored = true;
|
||||
if (isSubagentTask(message) || isMonitorTask(message)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,7 +225,8 @@ export class AsyncTaskRuntime {
|
||||
}
|
||||
if (task.state !== state) {
|
||||
task.state = state;
|
||||
if (task.announced) await this.publishState(task, state, nonBlankString(patch.error));
|
||||
const error = nonBlankString(patch.error);
|
||||
await this.whenAnnounced(task, () => this.publishState(task, state, error));
|
||||
} else if (task.announced && task.outputFilePath !== previousOutputFilePath) {
|
||||
await this.publishMetadata(task);
|
||||
}
|
||||
@@ -198,10 +241,12 @@ export class AsyncTaskRuntime {
|
||||
const taskId = taskIdOf(message);
|
||||
if (!taskId) return;
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task || !task.announced || task.ignored) return;
|
||||
if (!task || task.ignored || !(task.announced || task.held)) return;
|
||||
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
const previousToolCallId = task.toolCallId;
|
||||
this.mergeOutputFilePath(task, message);
|
||||
this.mergeToolCallId(task, message);
|
||||
const outputChanged = task.outputFilePath !== previousOutputFilePath;
|
||||
if (isTerminal(task.state)) {
|
||||
if (outputChanged) await this.publishMetadata(task);
|
||||
@@ -212,13 +257,20 @@ export class AsyncTaskRuntime {
|
||||
const description = nonBlankString(message.description);
|
||||
const summary = nonBlankString(message.summary);
|
||||
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
|
||||
await this.publishProgress(task, {
|
||||
const progress = {
|
||||
...(description ? { description } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(lastToolName ? { lastToolName } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...(outputChanged && task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
});
|
||||
...(task.toolCallId !== previousToolCallId && task.toolCallId
|
||||
? { toolCallId: task.toolCallId }
|
||||
: {}),
|
||||
};
|
||||
await this.whenAnnounced(task, () => this.publishProgress(task, progress));
|
||||
// The progress brought the tool call id of a held task: send the spawn,
|
||||
// then the progress beats that waited for it.
|
||||
if (task.held && task.toolCallId) await this.announce(task);
|
||||
}
|
||||
|
||||
async taskNotification(message: AsyncTaskNotification): Promise<void>;
|
||||
@@ -253,6 +305,7 @@ export class AsyncTaskRuntime {
|
||||
const correctsLevelState = wasTerminal && task.terminalSource === "level";
|
||||
const previousOutputFilePath = task.outputFilePath;
|
||||
this.mergeOutputFilePath(task, message);
|
||||
this.mergeToolCallId(task, message);
|
||||
await this.finish(task, state, nonBlankString(message.summary), "event");
|
||||
if (
|
||||
wasTerminal &&
|
||||
@@ -289,8 +342,9 @@ export class AsyncTaskRuntime {
|
||||
live.add(taskId);
|
||||
const task = this.task(taskId);
|
||||
if (task.ignored || isTerminal(task.state)) continue;
|
||||
if (field(item, "taskType", "task_type") === "local_agent") {
|
||||
task.ignored = true;
|
||||
const levelTaskType = field(item, "taskType", "task_type");
|
||||
if (levelTaskType === "local_agent" || levelTaskType === "local_monitor") {
|
||||
this.ignore(task);
|
||||
continue;
|
||||
}
|
||||
if (!task.startedObserved) {
|
||||
@@ -306,11 +360,18 @@ export class AsyncTaskRuntime {
|
||||
}
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.announced && !task.ignored && !isTerminal(task.state) && !live.has(task.id)) {
|
||||
if (
|
||||
(task.announced || task.held) &&
|
||||
!task.ignored &&
|
||||
!isTerminal(task.state) &&
|
||||
!live.has(task.id)
|
||||
) {
|
||||
// This replace-semantics level is itself the authoritative liveness
|
||||
// boundary. Close immediately so a lost terminal edge cannot leave a
|
||||
// permanent running card; a following task_notification may correct
|
||||
// the best-effort stopped state to completed/failed.
|
||||
// permanent running card. Since SDK 0.3.292 a finishing task's level
|
||||
// follows its terminal edges, so this is a lost or late edge; a late
|
||||
// task_notification still corrects the best-effort stopped state to
|
||||
// completed/failed.
|
||||
await this.finish(task, "stopped", undefined, "level");
|
||||
}
|
||||
}
|
||||
@@ -319,7 +380,7 @@ export class AsyncTaskRuntime {
|
||||
async finishAll(state: Extract<AsyncTaskState, "failed" | "stopped">): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.announced && !task.ignored && !isTerminal(task.state)) {
|
||||
if ((task.announced || task.held) && !task.ignored && !isTerminal(task.state)) {
|
||||
try {
|
||||
await this.finish(task, state, undefined, "shutdown");
|
||||
} catch (error) {
|
||||
@@ -354,9 +415,10 @@ export class AsyncTaskRuntime {
|
||||
// No terminal summary: a stopped task leaves the Async Tasks panel at once,
|
||||
// so anything said there is said to nobody.
|
||||
await this.finish(task, "stopped", undefined, "event");
|
||||
// The transcript is where the acknowledgement has to land, and it is the
|
||||
// only one the user gets -- the SDK injects nothing into the model's
|
||||
// context for a stopped shell task.
|
||||
// This acknowledgement is the only one the user gets -- the SDK injects
|
||||
// nothing into the model's context for a stopped shell task. A client on
|
||||
// the notice contract shows it as a live `notice`; otherwise the
|
||||
// transcript is where it has to land.
|
||||
//
|
||||
// Terminal state deliberately does not gate this. The SDK's own
|
||||
// `task_notification` routinely wins the race against the `stopTask`
|
||||
@@ -367,17 +429,76 @@ export class AsyncTaskRuntime {
|
||||
// `showInTranscript` does not gate it either: that flag decides whether the
|
||||
// task owns a transcript *card* (a background Bash task is already drawn as
|
||||
// its tool call), not whether the agent may answer a direct user action.
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: `**Task stopped by user:** ${task.name}.` },
|
||||
},
|
||||
});
|
||||
await this.send(
|
||||
task,
|
||||
noticeOrTranscriptUpdate(
|
||||
{ severity: "info", title: "Task stopped by user", description: `${task.name}.` },
|
||||
this.options.notices ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a task of the tool call went to the background, so the client gets it. */
|
||||
isBackgroundedToolCall(toolCallId: string): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.toolCallId === toolCallId && !task.ignored && (task.announced || task.held)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the tool results in `content` for the output path of a known task.
|
||||
* Only a path that ends with `tasks/<task id>.output` in the result of
|
||||
* the task's own tool call counts. A tool result never creates a task.
|
||||
*/
|
||||
async toolResults(content: unknown): Promise<void> {
|
||||
if (!this.enabled || !Array.isArray(content)) return;
|
||||
for (const result of content.flatMap(toolResultBlock)) {
|
||||
const text = textContent(result.content);
|
||||
const paths = text ? taskOutputPaths(text) : [];
|
||||
if (paths.length === 0) continue;
|
||||
this.outputPathsByToolCall.delete(result.toolUseId);
|
||||
this.outputPathsByToolCall.set(result.toolUseId, paths);
|
||||
if (this.outputPathsByToolCall.size > MAX_TOOL_RESULT_PATHS) {
|
||||
const oldest = this.outputPathsByToolCall.keys().next().value;
|
||||
if (oldest !== undefined) this.outputPathsByToolCall.delete(oldest);
|
||||
}
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.toolCallId !== result.toolUseId || task.ignored || task.outputFilePath) continue;
|
||||
this.mergeToolResultPath(task);
|
||||
if (task.outputFilePath && task.announced) await this.publishMetadata(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the spawn of each held task without a tool call id. The prompt
|
||||
* result ends the model turn, and the result of the tool call that started a
|
||||
* task comes before it. So a held task is never held for longer than one turn.
|
||||
*/
|
||||
async releaseHeld(): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.held) await this.announce(task, { withoutToolCall: true });
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tasks.clear();
|
||||
this.outputPathsByToolCall.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a task that the client never sees. Only a task that is not announced
|
||||
* can become ignored, so the client never holds a task without its end.
|
||||
*/
|
||||
private ignore(task: AsyncTask): void {
|
||||
if (task.announced) return;
|
||||
task.ignored = true;
|
||||
task.held = false;
|
||||
task.deferred = [];
|
||||
}
|
||||
|
||||
private task(taskId: string): AsyncTask {
|
||||
@@ -390,12 +511,15 @@ export class AsyncTaskRuntime {
|
||||
description: "Background task",
|
||||
showInTranscript: true,
|
||||
announced: false,
|
||||
held: false,
|
||||
deferred: [],
|
||||
ignored: false,
|
||||
startedObserved: false,
|
||||
panelOnlyRecovery: false,
|
||||
stopping: false,
|
||||
stopAnnounced: false,
|
||||
state: "running",
|
||||
published: new Map(),
|
||||
};
|
||||
this.tasks.set(taskId, task);
|
||||
return task;
|
||||
@@ -422,6 +546,32 @@ export class AsyncTaskRuntime {
|
||||
this.mergeOutputFilePath(task, message);
|
||||
const toolCallId = nonBlankString(field(message, "toolCallId", "tool_use_id"));
|
||||
if (toolCallId) task.toolCallId = toolCallId;
|
||||
this.mergeToolResultPath(task);
|
||||
}
|
||||
|
||||
/** Keeps the first tool call id that a later SDK message brings. */
|
||||
private mergeToolCallId(
|
||||
task: AsyncTask,
|
||||
value: { toolCallId?: unknown; tool_use_id?: unknown },
|
||||
): void {
|
||||
task.toolCallId ??= nonBlankString(field(value, "toolCallId", "tool_use_id"));
|
||||
this.mergeToolResultPath(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the output path from the result of the task's tool call when no
|
||||
* structured source gave one. A structured path later replaces it.
|
||||
*/
|
||||
private mergeToolResultPath(task: AsyncTask): void {
|
||||
if (task.outputFilePath || !task.toolCallId) return;
|
||||
const paths = this.outputPathsByToolCall.get(task.toolCallId);
|
||||
if (paths) task.outputFilePath = outputFilePathOf(paths, task.id);
|
||||
}
|
||||
|
||||
/** Sends an update of an announced task. A held task sends it after its spawn. */
|
||||
private async whenAnnounced(task: AsyncTask, send: () => Promise<void>): Promise<void> {
|
||||
if (task.announced) await send();
|
||||
else if (task.held) task.deferred.push(send);
|
||||
}
|
||||
|
||||
private mergePatch(task: AsyncTask, patch: TaskPatch): void {
|
||||
@@ -451,23 +601,54 @@ export class AsyncTaskRuntime {
|
||||
if (outputFilePath) task.outputFilePath = outputFilePath;
|
||||
}
|
||||
|
||||
private async announce(task: AsyncTask): Promise<void> {
|
||||
/**
|
||||
* Sends the spawn of the task. The spawn names the tool call that started the
|
||||
* task, so it waits for the tool call id: the task is held until the id
|
||||
* arrives. The id comes as `tool_use_id` of the SDK `task_started`,
|
||||
* `task_progress`, or `task_notification`, or from the Bash result of a
|
||||
* backgrounded command. A held task gets its spawn without the id when it
|
||||
* ends first, or when the prompt result ends the model turn
|
||||
* ({@link releaseHeld}). No guess by the command text binds a task.
|
||||
*
|
||||
* A task that a `Monitor` tool call started is ignored: Monitor streams its
|
||||
* output to the model only, so the task has nothing for the client. The
|
||||
* tool_use streams before the tool runs, so the tool name is known when the
|
||||
* spawn has the tool call id.
|
||||
*/
|
||||
private async announce(
|
||||
task: AsyncTask,
|
||||
options: { withoutToolCall?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (task.announced || task.ignored) return;
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_spawned",
|
||||
asyncTaskId: task.id,
|
||||
name: task.name,
|
||||
taskType: task.taskType,
|
||||
description: task.description,
|
||||
showInTranscript: task.showInTranscript,
|
||||
canStop: true,
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
},
|
||||
const toolName = task.toolCallId ? this.options.toolNameOf?.(task.toolCallId) : undefined;
|
||||
if (toolName !== undefined && HIDDEN_TASK_TOOLS.has(toolName)) {
|
||||
this.ignore(task);
|
||||
return;
|
||||
}
|
||||
if (!task.toolCallId && !options.withoutToolCall && !isTerminal(task.state)) {
|
||||
task.held = true;
|
||||
return;
|
||||
}
|
||||
if (task.toolCallId) task.route ??= this.options.routeOf?.(task.toolCallId);
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_spawned",
|
||||
asyncTaskId: task.id,
|
||||
name: task.name,
|
||||
taskType: task.taskType,
|
||||
description: task.description,
|
||||
showInTranscript: task.showInTranscript,
|
||||
canStop: true,
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
});
|
||||
task.announced = true;
|
||||
task.held = false;
|
||||
recordPublished(task, {
|
||||
description: task.description,
|
||||
outputFilePath: task.outputFilePath,
|
||||
toolCallId: task.toolCallId,
|
||||
});
|
||||
for (const send of task.deferred.splice(0)) await send();
|
||||
if (isTerminal(task.state)) {
|
||||
await this.publishState(task, task.state, task.terminalSummary);
|
||||
}
|
||||
@@ -481,8 +662,9 @@ export class AsyncTaskRuntime {
|
||||
): Promise<void> {
|
||||
if (task.ignored) return;
|
||||
if (isTerminal(task.state)) {
|
||||
// A level event may precede the authoritative terminal edge. Correct its
|
||||
// best-effort stopped state if that edge later arrives.
|
||||
// A level event can precede a late terminal edge (older CLIs sent the
|
||||
// level first). Correct its best-effort stopped state if that edge
|
||||
// later arrives.
|
||||
if (task.terminalSource !== "level" || source !== "event") return;
|
||||
}
|
||||
const previous = {
|
||||
@@ -495,6 +677,9 @@ export class AsyncTaskRuntime {
|
||||
task.terminalSource = source;
|
||||
try {
|
||||
if (task.announced) await this.publishState(task, state, summary);
|
||||
// The task ended before its tool call id arrived. The spawn goes out now
|
||||
// without the id, so the task still appears.
|
||||
else if (task.held) await this.announce(task);
|
||||
} catch (error) {
|
||||
task.state = previous.state;
|
||||
task.terminalSummary = previous.terminalSummary;
|
||||
@@ -516,6 +701,10 @@ export class AsyncTaskRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the progress fields that changed since the last report of the
|
||||
* task. A beat with nothing new is not sent.
|
||||
*/
|
||||
private async publishProgress(
|
||||
task: AsyncTask,
|
||||
update: {
|
||||
@@ -527,36 +716,72 @@ export class AsyncTaskRuntime {
|
||||
toolCallId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_progress",
|
||||
asyncTaskId: task.id,
|
||||
...update,
|
||||
},
|
||||
const changed = changedFields(task, update);
|
||||
if (Object.keys(changed).length === 0) return;
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_progress",
|
||||
asyncTaskId: task.id,
|
||||
...changed,
|
||||
});
|
||||
recordPublished(task, changed);
|
||||
}
|
||||
|
||||
/** Publishes a state, with the output path and the tool call only when they changed. */
|
||||
private async publishState(
|
||||
task: AsyncTask,
|
||||
state: AsyncTaskState,
|
||||
summary?: string,
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: task.id,
|
||||
state,
|
||||
...(summary ? { summary } : {}),
|
||||
...(task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId ? { toolCallId: task.toolCallId } : {}),
|
||||
},
|
||||
const changed = changedFields(task, {
|
||||
outputFilePath: task.outputFilePath,
|
||||
toolCallId: task.toolCallId,
|
||||
});
|
||||
await this.send(task, {
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: task.id,
|
||||
state,
|
||||
...(summary ? { summary } : {}),
|
||||
...changed,
|
||||
});
|
||||
recordPublished(task, changed);
|
||||
}
|
||||
|
||||
/** Publishes an update of the task in the session that owns the task. */
|
||||
private async send(task: AsyncTask, update: AcpSessionNotification["update"]): Promise<void> {
|
||||
const notification: AcpSessionNotification = { sessionId: this.sessionId, update };
|
||||
const routed = task.route ? task.route(notification) : notification;
|
||||
if (routed) await this.publish(routed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recovers background Bash lifecycle data exposed only on its tool result. */
|
||||
/**
|
||||
* The fields whose value differs from what the client holds. An undefined
|
||||
* value is not a field. The caller records the fields with
|
||||
* {@link recordPublished} after the send succeeds, so a retry after a failed
|
||||
* send carries them again.
|
||||
*/
|
||||
function changedFields<T extends Record<string, unknown>>(task: AsyncTask, fields: T): Partial<T> {
|
||||
const changed: Partial<T> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value === undefined) continue;
|
||||
if (task.published.get(key) === JSON.stringify(value)) continue;
|
||||
(changed as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Records the fields that the client holds now. An undefined value is not a field. */
|
||||
function recordPublished(task: AsyncTask, fields: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value !== undefined) task.published.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovers a background Bash task from the structured `backgroundTaskId` of
|
||||
* its tool result. The text of a tool result never gives a task id: a
|
||||
* foreground command can print any text.
|
||||
*/
|
||||
export function backgroundBashTaskFromToolResult(
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
@@ -568,12 +793,7 @@ export function backgroundBashTaskFromToolResult(
|
||||
if (bashResults.length === 0) return undefined;
|
||||
|
||||
const backgroundResults = structuredBackgroundResults(toolUseResult, toolUses);
|
||||
const distinctTaskIds = new Set([
|
||||
...backgroundResults.map((result) => result.taskId),
|
||||
...bashResults
|
||||
.map((result) => backgroundTaskIdFromText(result.content))
|
||||
.filter((taskId): taskId is string => taskId !== undefined),
|
||||
]);
|
||||
const distinctTaskIds = new Set(backgroundResults.map((result) => result.taskId));
|
||||
if (distinctTaskIds.size !== 1) return undefined;
|
||||
const taskId = [...distinctTaskIds][0];
|
||||
if (!taskId) return undefined;
|
||||
@@ -607,15 +827,36 @@ export function backgroundBashTaskFromToolResult(
|
||||
};
|
||||
}
|
||||
|
||||
function backgroundTaskIdFromText(content: unknown): string | undefined {
|
||||
const text = textContent(content);
|
||||
if (!text) return undefined;
|
||||
const marker = "Command running in background with ID: ";
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) return undefined;
|
||||
const valueStart = start + marker.length;
|
||||
const valueEnd = text.indexOf(".", valueStart);
|
||||
return valueEnd < 0 ? undefined : nonBlankString(text.slice(valueStart, valueEnd));
|
||||
/**
|
||||
* The Bash tool calls in `content` whose command went to the background. Only
|
||||
* structured data counts: the task from the structured tool result, a task
|
||||
* that the runtime knows for the tool call, or a `run_in_background` input of
|
||||
* a call that did not fail.
|
||||
*/
|
||||
export function backgroundedBashToolCallIds(
|
||||
content: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
runtime: AsyncTaskRuntime,
|
||||
structuredTask?: AsyncTaskStarted,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (!runtime.enabled || !Array.isArray(content)) return ids;
|
||||
const structuredToolCallId = structuredTask
|
||||
? nonBlankString(field(structuredTask, "toolCallId", "tool_use_id"))
|
||||
: undefined;
|
||||
for (const result of content.flatMap(toolResultBlock)) {
|
||||
const toolUse = toolUses[result.toolUseId];
|
||||
if (toolUse?.name !== "Bash") continue;
|
||||
const input = isRecord(toolUse.input) ? toolUse.input : undefined;
|
||||
if (
|
||||
result.toolUseId === structuredToolCallId ||
|
||||
runtime.isBackgroundedToolCall(result.toolUseId) ||
|
||||
(input?.run_in_background === true && !result.isError)
|
||||
) {
|
||||
ids.add(result.toolUseId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function structuredBackgroundResults(
|
||||
@@ -647,27 +888,73 @@ function structuredBackgroundResults(
|
||||
return results;
|
||||
}
|
||||
|
||||
function toolResultBlock(value: unknown): { toolUseId: string; content?: unknown }[] {
|
||||
function toolResultBlock(
|
||||
value: unknown,
|
||||
): { toolUseId: string; content?: unknown; isError: boolean }[] {
|
||||
if (!isRecord(value) || value.type !== "tool_result") return [];
|
||||
const toolUseId = nonBlankString(field(value, "toolUseId", "tool_use_id"));
|
||||
return toolUseId ? [{ toolUseId, content: value.content }] : [];
|
||||
return toolUseId
|
||||
? [
|
||||
{
|
||||
toolUseId,
|
||||
content: value.content,
|
||||
isError: field(value, "isError", "is_error") === true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
function asyncTaskOutputFilePath(content: unknown, taskId: string): string | undefined {
|
||||
const text = textContent(content);
|
||||
if (!text) return undefined;
|
||||
const marker = "Output is being written to: ";
|
||||
const start = text.indexOf(marker);
|
||||
if (start < 0) return undefined;
|
||||
const valueStart = start + marker.length;
|
||||
const valueEnd = text.indexOf(". You will be notified", valueStart);
|
||||
if (valueEnd < 0) return undefined;
|
||||
const path = text.slice(valueStart, valueEnd).trim();
|
||||
const expectedPosixSuffix = `/tasks/${taskId}.output`;
|
||||
const expectedWindowsSuffix = `\\tasks\\${taskId}.output`;
|
||||
return path.endsWith(expectedPosixSuffix) || path.endsWith(expectedWindowsSuffix)
|
||||
? path
|
||||
: undefined;
|
||||
return text ? outputFilePathOf(taskOutputPaths(text), taskId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a task output path: `tasks/<id>.output`, followed by a sentence
|
||||
* period, whitespace or the end of the text. Claude Code writes the output of
|
||||
* a background task there.
|
||||
*/
|
||||
const TASK_OUTPUT_SUFFIX = /[/\\]tasks[/\\][^\s/\\]+?\.output(?=\.(?!\w)|\s|$)/g;
|
||||
/** An absolute POSIX, Windows drive or UNC path. */
|
||||
const ABSOLUTE_PATH = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/;
|
||||
|
||||
/**
|
||||
* The task output paths in a tool result text. Each text of Claude Code puts
|
||||
* the path after "Output is being written to: ", so a path starts after the
|
||||
* nearest `": "` before its end. A drive colon has no space after it, so a
|
||||
* path can hold spaces and a drive. The search for the start stops at a line
|
||||
* break and at the end of the previous path end, so a path never crosses a
|
||||
* line or holds another path. Each character is read at most once, so the
|
||||
* parse is linear in the text length. A path that is not absolute does not
|
||||
* count.
|
||||
*/
|
||||
function taskOutputPaths(text: string): string[] {
|
||||
const paths: string[] = [];
|
||||
let previousEnd = 0;
|
||||
for (const match of text.matchAll(TASK_OUTPUT_SUFFIX)) {
|
||||
const end = match.index + match[0].length;
|
||||
let start = -1;
|
||||
for (let i = match.index - 1; i >= previousEnd; i--) {
|
||||
const char = text[i];
|
||||
if (char === "\n" || char === "\r") break;
|
||||
if (char === ":" && text[i + 1] === " ") {
|
||||
start = i + 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
previousEnd = end;
|
||||
if (start < 0) continue;
|
||||
const path = text.slice(start, end);
|
||||
if (ABSOLUTE_PATH.test(path)) paths.push(path);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** The output path of the task `taskId` among the paths, if one names it. */
|
||||
function outputFilePathOf(paths: readonly string[], taskId: string): string | undefined {
|
||||
const posix = `/tasks/${taskId}.output`;
|
||||
const windows = `\\tasks\\${taskId}.output`;
|
||||
return paths.find((path) => path.endsWith(posix) || path.endsWith(windows));
|
||||
}
|
||||
|
||||
function textContent(value: unknown): string | undefined {
|
||||
@@ -698,6 +985,13 @@ function isBackgroundTask(isBackgrounded: unknown, taskType: unknown): boolean {
|
||||
return taskType !== "local_bash" && taskType !== "local_agent";
|
||||
}
|
||||
|
||||
/** The tools whose async tasks the client never sees. */
|
||||
const HIDDEN_TASK_TOOLS = new Set(["Monitor"]);
|
||||
|
||||
function isMonitorTask(message: AsyncTaskStarted): boolean {
|
||||
return field(message, "taskType", "task_type") === "local_monitor";
|
||||
}
|
||||
|
||||
function isSubagentTask(message: AsyncTaskStarted): boolean {
|
||||
return (
|
||||
field(message, "subagentType", "subagent_type") !== undefined ||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PermissionMode,
|
||||
SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID } from "./session-config-ids.js";
|
||||
|
||||
export type ClearContextReset = {
|
||||
toolUseId: string;
|
||||
@@ -37,6 +37,7 @@ 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;
|
||||
@@ -88,6 +89,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ContextCompactionMetadata,
|
||||
createContextCompactionMeta,
|
||||
} from "./context-compaction-meta.js";
|
||||
import { compactionToolCall } from "./tool-calls/reporters/compaction.js";
|
||||
|
||||
type CompactionStatus = "completed" | "failed";
|
||||
type TerminalStatus = CompactionStatus | "cancelled";
|
||||
@@ -23,6 +24,8 @@ type CompactionState = {
|
||||
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. */
|
||||
@@ -37,6 +40,8 @@ export type ContextCompactionLifecycleOptions = {
|
||||
/** 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 {
|
||||
@@ -151,6 +156,7 @@ export class ContextCompactionLifecycle {
|
||||
private readonly sessionId: string;
|
||||
readonly presentation: CompactionPresentation;
|
||||
private readonly logError: (message: string, error: unknown) => void;
|
||||
private readonly airClient: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly sendUpdate: SendUpdate,
|
||||
@@ -159,6 +165,7 @@ export class ContextCompactionLifecycle {
|
||||
this.sessionId = options.sessionId;
|
||||
this.presentation = options.presentation ?? "tool_call";
|
||||
this.logError = options.logError ?? (() => {});
|
||||
this.airClient = options.airClient ?? false;
|
||||
}
|
||||
|
||||
get hasDeliveredOutput(): boolean {
|
||||
@@ -240,18 +247,11 @@ export class ContextCompactionLifecycle {
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "in_progress",
|
||||
_meta: createContextCompactionMeta(),
|
||||
...(this.airClient ? { _meta: createContextCompactionMeta() } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: compactionId,
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
status: "in_progress",
|
||||
_meta: compactionToolMeta(),
|
||||
});
|
||||
await this.send(compactionToolCall.started(compactionId, this.airClient));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,6 +269,7 @@ export class ContextCompactionLifecycle {
|
||||
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,
|
||||
@@ -281,12 +282,7 @@ export class ContextCompactionLifecycle {
|
||||
const state = this.activeCompaction;
|
||||
if (!state || state.terminalStatus || state.heartbeatSent) return;
|
||||
state.heartbeatSent = true;
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: state.compactionId,
|
||||
status: "in_progress",
|
||||
_meta: compactionToolMeta(),
|
||||
});
|
||||
await this.send(compactionToolCall.inProgress(state.compactionId, this.airClient));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,7 +336,14 @@ export class ContextCompactionLifecycle {
|
||||
const hasMetadata = Object.keys(metadata).length > 0;
|
||||
|
||||
if (this.presentation === "compaction_update") {
|
||||
const summary = firstTerminal && terminalStatus === "completed" ? state.summary : undefined;
|
||||
// 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,
|
||||
@@ -350,37 +353,24 @@ export class ContextCompactionLifecycle {
|
||||
// `_meta` is a replace-patch: seed it with the first terminal, then
|
||||
// only re-send it when the boundary adds facts, so a status-only
|
||||
// duplicate can't wipe the token counts.
|
||||
...(firstTerminal || hasMetadata ? { _meta: createContextCompactionMeta(metadata) } : {}),
|
||||
// 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)) }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rawOutput = hasMetadata ? metadata : undefined;
|
||||
const errorContent =
|
||||
status === "failed" && metadata.error
|
||||
? { content: [compactionErrorContent(metadata.error)] }
|
||||
: {};
|
||||
if (opened) {
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: state.compactionId,
|
||||
title: "Compact conversation",
|
||||
kind: "think",
|
||||
status,
|
||||
...errorContent,
|
||||
...(rawOutput ? { rawOutput } : {}),
|
||||
_meta: compactionToolMeta(metadata),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: state.compactionId,
|
||||
...(firstTerminal ? { status } : {}),
|
||||
...errorContent,
|
||||
...(rawOutput ? { rawOutput } : {}),
|
||||
_meta: compactionToolMeta(metadata),
|
||||
});
|
||||
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
|
||||
@@ -411,6 +401,13 @@ export class ContextCompactionLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
function withoutError({
|
||||
error: _error,
|
||||
...facts
|
||||
}: Omit<ContextCompactionMetadata, "version">): Omit<ContextCompactionMetadata, "version"> {
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function contextCompactionMetadataFromBoundary(compactMetadata: {
|
||||
trigger: "manual" | "auto";
|
||||
pre_tokens: number;
|
||||
@@ -428,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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
732
src/diff.ts
732
src/diff.ts
@@ -1,7 +1,45 @@
|
||||
import { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import { AIR_DIFF_STATS_KEY, withAirMeta } from "./air-extension.js";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { structuredPatch } from "diff";
|
||||
import { constants } from "node:fs";
|
||||
import { type FileHandle, open } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { AIR_DIFF_PATCH_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
import { normalizeWriteInput } from "./tool-calls/reporters/file-edit.js";
|
||||
|
||||
interface DiffToolResponseHunk {
|
||||
/**
|
||||
* 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;
|
||||
@@ -11,23 +49,251 @@ interface DiffToolResponseHunk {
|
||||
|
||||
interface DiffToolResponse {
|
||||
filePath?: string;
|
||||
structuredPatch?: DiffToolResponseHunk[];
|
||||
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;
|
||||
/** FileWriteOutput only: the pre-write content — null on create, or on an
|
||||
* update whose previous content was too large to include. */
|
||||
/** The pre-change content. It is null on a Write create, or on a Write
|
||||
* update whose previous content was too large to include. An Edit that
|
||||
* creates a file reports an empty string, as for an existing empty file. */
|
||||
originalFile?: string | null;
|
||||
/** FileEditOutput only: the text that the Edit replaced. It is empty when
|
||||
* the Edit created the file or filled an existing empty file. */
|
||||
oldString?: string;
|
||||
}
|
||||
|
||||
interface EditPreviewInput {
|
||||
file_path?: unknown;
|
||||
old_string?: unknown;
|
||||
new_string?: unknown;
|
||||
replace_all?: unknown;
|
||||
}
|
||||
|
||||
/** The kind of file change that a git patch describes. */
|
||||
type FileChange = "create" | "update";
|
||||
|
||||
/** A file change and the hunks of its git patch. */
|
||||
interface FilePatch {
|
||||
change: FileChange;
|
||||
hunks: PatchHunk[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds diff ToolUpdate content from the structured toolResponse provided by
|
||||
* the PostToolUse hook for diff-producing tools (Edit, Write). Unlike parsing
|
||||
* the plain unified diff string, this uses the pre-parsed structuredPatch
|
||||
* which supports multiple replacement sites (replaceAll) and always includes
|
||||
* context lines for better readability.
|
||||
* The diff that carries an exact patch: AIR's `diffPatch` extension of a v1
|
||||
* diff, or an ACP v2 diff.
|
||||
*/
|
||||
export type PatchForm = "air" | "v2";
|
||||
|
||||
/**
|
||||
* How a git patch names its file. `git` is git's own form: the leading slash
|
||||
* dropped and the `a/` and `b/` prefixes added. `absolute` is the form of an
|
||||
* ACP v2 `git_patch`, whose paths must be absolute: the path itself.
|
||||
*/
|
||||
type GitPatchPaths = "git" | "absolute";
|
||||
|
||||
/** An ACP v2 diff, as tool call content. */
|
||||
type V2DiffContent = v2.Diff & { type: "diff" };
|
||||
|
||||
/**
|
||||
* Builds the exact patch shown before Claude runs an Edit or Write tool.
|
||||
*
|
||||
* Returns undefined when the adapter cannot predict the file change exactly.
|
||||
* The caller then keeps the standard tool-call content. The adapter declines
|
||||
* a file that is missing for an Edit, too large, binary, or that has CR line
|
||||
* endings. Claude converts the line endings of an edited file, so an in-memory
|
||||
* replacement would not match the bytes that Claude writes. It also declines
|
||||
* an `old_string` that does not match exactly once, because Claude then
|
||||
* normalizes quotes or fails.
|
||||
*
|
||||
* A Write always gets content that shows the change: the patch, the standard
|
||||
* diff (in v2, the change alone) of a text that cannot have an exact patch, or
|
||||
* a notice that the Write overwrites a file that the adapter cannot show.
|
||||
*
|
||||
* The preview uses the tool input as it is. Claude can still remove the
|
||||
* trailing whitespace of a line before it writes. The PostToolUse hook then
|
||||
* sends the patch of the written file.
|
||||
*/
|
||||
export async function previewPatchContent(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
cwd?: string,
|
||||
form: PatchForm = "air",
|
||||
): Promise<ToolCallContent[] | undefined> {
|
||||
if (toolName === "Edit") {
|
||||
const edit = input as EditPreviewInput;
|
||||
if (
|
||||
typeof edit.file_path !== "string" ||
|
||||
typeof edit.old_string !== "string" ||
|
||||
typeof edit.new_string !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const oldString = edit.old_string;
|
||||
const filePath = resolveToolPath(edit.file_path, cwd);
|
||||
const oldText = await readPatchSource(filePath);
|
||||
if (oldText === undefined) return undefined;
|
||||
const newString = edit.new_string;
|
||||
if (oldString === newString || !isPatchableText(newString)) return undefined;
|
||||
if (oldString.length === 0) {
|
||||
// An empty old_string creates the file, or fills an existing file that
|
||||
// holds only whitespace. `trim` also removes a byte order mark, whose
|
||||
// handling the adapter does not predict, so such a file is declined.
|
||||
if (oldText !== null && (oldText.trim() !== "" || oldText.includes("\uFEFF"))) {
|
||||
return undefined;
|
||||
}
|
||||
return exactPatch(filePath, oldText, newString, form);
|
||||
}
|
||||
if (oldText === null) return undefined;
|
||||
const occurrences = oldText.split(oldString).length - 1;
|
||||
if (occurrences === 0 || (edit.replace_all !== true && occurrences !== 1)) return undefined;
|
||||
if (replacedTextBytesBound(oldText, oldString, newString, occurrences) > MAX_PATCH_FILE_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
const newText = replacedText(oldText, oldString, newString, edit.replace_all === true);
|
||||
return exactPatch(filePath, oldText, newText, form);
|
||||
}
|
||||
|
||||
if (toolName === "Write") {
|
||||
const write = normalizeWriteInput(input);
|
||||
if (typeof write?.file_path !== "string" || typeof write.content !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
// The Write tool call shows no diff, so the approval is the only place that
|
||||
// shows what the Write changes.
|
||||
const content = write.content;
|
||||
const filePath = resolveToolPath(write.file_path, cwd);
|
||||
const oldText = await readPatchSource(filePath);
|
||||
if (oldText === undefined) {
|
||||
return [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Overwrites the existing file \`${write.file_path}\`. The adapter cannot show its current content.`,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (oldText === content) return undefined;
|
||||
const standard: ToolCallContent[] = [
|
||||
form === "v2"
|
||||
? v2DiffContent(filePath, oldText === null ? "create" : "update")
|
||||
: { type: "diff", path: write.file_path, oldText, newText: content },
|
||||
];
|
||||
if (!isPatchableText(content)) return standard;
|
||||
// A dense change can exceed the diff budget. The approval then shows the standard diff.
|
||||
return exactPatch(filePath, oldText, content, form) ?? standard;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* An upper bound of the UTF-8 size, in bytes, of {@link replacedText}.
|
||||
*
|
||||
* The caller checks the bound before it builds the text. A `replace_all` of
|
||||
* many short matches with a long `newString` can otherwise build a text of
|
||||
* gigabytes before the timed line diff starts.
|
||||
*/
|
||||
function replacedTextBytesBound(
|
||||
fileText: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
occurrences: number,
|
||||
): number {
|
||||
const growth = Buffer.byteLength(newString, "utf8") - Buffer.byteLength(oldString, "utf8");
|
||||
return Buffer.byteLength(fileText, "utf8") + occurrences * Math.max(0, growth);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file text after Claude replaces `oldString` with `newString`.
|
||||
*
|
||||
* An empty `newString` deletes a line: when `oldString` does not end with a
|
||||
* line break and the file holds `oldString` and a line break, Claude also
|
||||
* removes that line break. With `replaceAll`, it then replaces only the
|
||||
* occurrences that a line break follows.
|
||||
*/
|
||||
function replacedText(
|
||||
fileText: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
): string {
|
||||
const target =
|
||||
newString === "" && !oldString.endsWith("\n") && fileText.includes(`${oldString}\n`)
|
||||
? `${oldString}\n`
|
||||
: oldString;
|
||||
return replaceAll
|
||||
? fileText.split(target).join(newString)
|
||||
: fileText.replace(target, () => newString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the git patch for a finished Edit or Write from the file on disk.
|
||||
*
|
||||
* The Claude SDK `structuredPatch` is display data: Claude converts leading
|
||||
* tabs to spaces and CRLF line endings to LF before it computes the hunks. A
|
||||
* patch built from those hunks does not match the file. This function diffs
|
||||
* `originalFile` against the written file instead. It returns undefined when
|
||||
* it cannot build an exact patch, and the caller then sends the standard diff.
|
||||
* A written file that contains a CR or a byte order mark is declined, because
|
||||
* Claude removed those bytes from `originalFile`.
|
||||
*/
|
||||
export async function patchUpdateFromDiffToolResponse(
|
||||
toolResponse: unknown,
|
||||
form: PatchForm = "air",
|
||||
): Promise<{ content: ToolCallContent[]; locations: ToolCallLocation[] } | undefined> {
|
||||
if (!toolResponse || typeof toolResponse !== "object") return undefined;
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
if (typeof response.filePath !== "string") return undefined;
|
||||
// An Edit with an empty old_string reports "" as the original of a created
|
||||
// file and of an existing empty file. The response does not tell the two
|
||||
// apart, so the caller keeps the standard diff.
|
||||
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
|
||||
return undefined;
|
||||
}
|
||||
// A Write whose content is the previous text changed nothing. The file on
|
||||
// disk can hold a later change, and that change is not the change of this Write.
|
||||
if (
|
||||
response.type === "update" &&
|
||||
typeof response.content === "string" &&
|
||||
response.originalFile === response.content
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const oldText =
|
||||
response.type === "create"
|
||||
? null
|
||||
: typeof response.originalFile === "string"
|
||||
? response.originalFile
|
||||
: undefined;
|
||||
if (oldText === undefined) return undefined;
|
||||
// The same size limit as the new side: a larger text gets the standard diff.
|
||||
if (oldText !== null && Buffer.byteLength(oldText, "utf8") > MAX_PATCH_FILE_BYTES)
|
||||
return undefined;
|
||||
const newText = await readPatchSource(response.filePath);
|
||||
if (typeof newText !== "string" || newText.startsWith("\uFEFF")) return undefined;
|
||||
if (response.type === "create" && newText !== response.content) return undefined;
|
||||
const patch = filePatch(oldText, newText);
|
||||
if (!patch) return undefined;
|
||||
return {
|
||||
content: [patchContent(response.filePath, patch, form)],
|
||||
// A created file keeps the location of its Write tool call.
|
||||
locations:
|
||||
oldText === null
|
||||
? [{ path: response.filePath }]
|
||||
: patch.hunks.map(({ newStart }) => ({ path: response.filePath!, line: newStart })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds standard ACP diff content from the structured toolResponse provided
|
||||
* by the PostToolUse hook for diff-producing tools (Edit, Write). Unlike
|
||||
* parsing the plain unified diff string, this uses the pre-parsed
|
||||
* structuredPatch which supports multiple replacement sites (replaceAll) and
|
||||
* always includes context lines for better readability.
|
||||
*/
|
||||
export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
content?: ToolCallContent[];
|
||||
@@ -39,67 +305,17 @@ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
|
||||
const content: ToolCallContent[] = [];
|
||||
const locations: ToolCallLocation[] = [];
|
||||
let previousOldEnd = 0;
|
||||
let previousNewEnd = 0;
|
||||
let accumulatedDelta = 0;
|
||||
let coordinatesRemainConsistent = true;
|
||||
|
||||
for (const { lines, oldStart, newStart, oldLines, newLines } of response.structuredPatch) {
|
||||
for (const { lines, newStart } of response.structuredPatch) {
|
||||
const oldText: string[] = [];
|
||||
const newText: string[] = [];
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let validPrefixes = true;
|
||||
let validEofMarkers = true;
|
||||
const oldPosition = oldStart + (oldLines === 0 ? 1 : 0);
|
||||
const newPosition = newStart + (newLines === 0 ? 1 : 0);
|
||||
const oldEnd = oldPosition + oldLines;
|
||||
const newEnd = newPosition + newLines;
|
||||
const nextDelta = accumulatedDelta + newLines - oldLines;
|
||||
const validCoordinates: boolean =
|
||||
coordinatesRemainConsistent &&
|
||||
Number.isSafeInteger(oldStart) &&
|
||||
Number.isSafeInteger(newStart) &&
|
||||
Number.isSafeInteger(oldLines) &&
|
||||
Number.isSafeInteger(newLines) &&
|
||||
oldStart >= (oldLines === 0 ? 0 : 1) &&
|
||||
newStart >= (newLines === 0 ? 0 : 1) &&
|
||||
oldLines >= 0 &&
|
||||
newLines >= 0 &&
|
||||
Number.isSafeInteger(oldPosition) &&
|
||||
Number.isSafeInteger(newPosition) &&
|
||||
Number.isSafeInteger(oldEnd) &&
|
||||
Number.isSafeInteger(newEnd) &&
|
||||
Number.isSafeInteger(nextDelta) &&
|
||||
oldPosition >= previousOldEnd &&
|
||||
newPosition >= previousNewEnd &&
|
||||
newPosition - oldPosition === accumulatedDelta;
|
||||
coordinatesRemainConsistent = validCoordinates;
|
||||
if (validCoordinates) {
|
||||
previousOldEnd = oldEnd;
|
||||
previousNewEnd = newEnd;
|
||||
accumulatedDelta = nextDelta;
|
||||
}
|
||||
for (const [index, line] of lines.entries()) {
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("-")) {
|
||||
oldText.push(line.slice(1));
|
||||
removed++;
|
||||
} else if (line.startsWith("+")) {
|
||||
newText.push(line.slice(1));
|
||||
added++;
|
||||
} else if (line === "\\ No newline at end of file") {
|
||||
const previousLine = lines[index - 1];
|
||||
if (
|
||||
typeof previousLine !== "string" ||
|
||||
(!previousLine.startsWith("-") &&
|
||||
!previousLine.startsWith("+") &&
|
||||
!previousLine.startsWith(" "))
|
||||
) {
|
||||
validEofMarkers = false;
|
||||
}
|
||||
} else if (line === NO_NEWLINE_MARKER) {
|
||||
continue;
|
||||
} else {
|
||||
if (!line.startsWith(" ")) validPrefixes = false;
|
||||
oldText.push(line.slice(1));
|
||||
newText.push(line.slice(1));
|
||||
}
|
||||
@@ -111,13 +327,6 @@ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
path: response.filePath,
|
||||
oldText: oldText.join("\n") || null,
|
||||
newText: newText.join("\n"),
|
||||
...(validCoordinates &&
|
||||
validPrefixes &&
|
||||
validEofMarkers &&
|
||||
oldText.length === oldLines &&
|
||||
newText.length === newLines
|
||||
? { _meta: withAirMeta(undefined, AIR_DIFF_STATS_KEY, { version: 1, added, removed }) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -130,7 +339,9 @@ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
// 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.
|
||||
// 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(
|
||||
@@ -156,3 +367,382 @@ export function toolUpdateFromDiffToolResponse(toolResponse: unknown): {
|
||||
if (locations.length > 0) result.locations = locations;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a finished Edit or Write: the exact patch of
|
||||
* {@link patchUpdateFromDiffToolResponse} when it can be built, else the
|
||||
* change with a patch from what the toolResponse holds.
|
||||
*
|
||||
* The fallback patch of an update comes from the `structuredPatch`, which is
|
||||
* display data (see {@link patchUpdateFromDiffToolResponse}). That is enough
|
||||
* for a v2 patch, which is renderable text, not a patch to apply. An update
|
||||
* without hunks, such as one whose previous content was too large to diff,
|
||||
* gets the change alone. An Edit with an empty `old_string` reports the same
|
||||
* response for a created file and a filled empty one, so it gets no diff.
|
||||
*/
|
||||
export async function v2UpdateFromDiffToolResponse(toolResponse: unknown): Promise<{
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
}> {
|
||||
const exact = await patchUpdateFromDiffToolResponse(toolResponse, "v2");
|
||||
if (exact) return exact;
|
||||
if (!toolResponse || typeof toolResponse !== "object") return {};
|
||||
const response = toolResponse as DiffToolResponse;
|
||||
const filePath = response.filePath;
|
||||
if (typeof filePath !== "string") return {};
|
||||
if (response.type === "create") {
|
||||
const hunk =
|
||||
typeof response.content === "string" && isPatchableText(response.content)
|
||||
? wholeFileHunk(response.content)
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "create", hunk && [hunk])],
|
||||
locations: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
if (response.type === undefined && response.oldString === "" && response.originalFile === "") {
|
||||
return {};
|
||||
}
|
||||
// A Write whose content is the previous text changed nothing.
|
||||
if (response.type === "update" && response.content === response.originalFile) return {};
|
||||
const hunks =
|
||||
Array.isArray(response.structuredPatch) && response.structuredPatch.length > 0
|
||||
? response.structuredPatch
|
||||
: undefined;
|
||||
return {
|
||||
content: [v2DiffContent(filePath, "update", hunks)],
|
||||
locations: hunks
|
||||
? hunks.map(({ newStart }) => ({ path: filePath, line: newStart }))
|
||||
: [{ path: filePath }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of one git patch for `filePath`.
|
||||
*
|
||||
* The headers follow `git diff`: the path is quoted when git would quote it,
|
||||
* and a created file gets its mode line and a `/dev/null` side. With `git`
|
||||
* paths, the path loses its leading slash and gets the `a/` and `b/` prefixes.
|
||||
*/
|
||||
export function gitPatchText(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks: PatchHunk[],
|
||||
paths: GitPatchPaths = "git",
|
||||
): string {
|
||||
return [
|
||||
...gitPatchHeader(filePath, change, paths),
|
||||
...hunks.flatMap((hunk) => [hunkHeader(hunk), ...hunk.lines]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function gitPatchHeader(filePath: string, change: FileChange, paths: GitPatchPaths): string[] {
|
||||
// A Windows path gets forward slashes so the header names the same file on
|
||||
// every platform.
|
||||
const slashed = filePath.replaceAll("\\", "/");
|
||||
// git drops one leading slash of an absolute path.
|
||||
const name = paths === "git" ? slashed.replace(/^\/+/u, "") : slashed;
|
||||
const oldName = quoteGitPath(paths === "git" ? "a/" : "", name);
|
||||
const newName = quoteGitPath(paths === "git" ? "b/" : "", name);
|
||||
// git ends a ---/+++ name that contains a space with a tab, for GNU patch.
|
||||
const tab = name.includes(" ") ? "\t" : "";
|
||||
return [
|
||||
`diff --git ${oldName} ${newName}`,
|
||||
...(change === "create" ? ["new file mode 100644"] : []),
|
||||
`--- ${change === "create" ? "/dev/null" : `${oldName}${tab}`}`,
|
||||
`+++ ${newName}${tab}`,
|
||||
];
|
||||
}
|
||||
|
||||
const GIT_PATH_ESCAPES: Record<number, string> = {
|
||||
0x07: "a",
|
||||
0x08: "b",
|
||||
0x09: "t",
|
||||
0x0a: "n",
|
||||
0x0b: "v",
|
||||
0x0c: "f",
|
||||
0x0d: "r",
|
||||
0x22: '"',
|
||||
0x5c: "\\",
|
||||
};
|
||||
|
||||
/**
|
||||
* Quotes `prefix + name` like git with the default `core.quotePath`.
|
||||
*
|
||||
* A double quote, a backslash, a control byte, or a non-ASCII byte makes git
|
||||
* put the whole name in double quotes. git then writes a C escape or a
|
||||
* three-digit octal escape for each such UTF-8 byte.
|
||||
*/
|
||||
function quoteGitPath(prefix: string, name: string): string {
|
||||
const full = `${prefix}${name}`;
|
||||
let quoted = "";
|
||||
let needsQuotes = false;
|
||||
for (const byte of Buffer.from(full, "utf8")) {
|
||||
const escape = GIT_PATH_ESCAPES[byte];
|
||||
if (escape !== undefined) {
|
||||
quoted += `\\${escape}`;
|
||||
needsQuotes = true;
|
||||
} else if (byte < 0x20 || byte >= 0x7f) {
|
||||
quoted += `\\${byte.toString(8).padStart(3, "0")}`;
|
||||
needsQuotes = true;
|
||||
} else {
|
||||
quoted += String.fromCharCode(byte);
|
||||
}
|
||||
}
|
||||
return needsQuotes ? `"${quoted}"` : full;
|
||||
}
|
||||
|
||||
function hunkHeader({ oldStart, oldLines, newStart, newLines }: PatchHunk): string {
|
||||
return `@@ -${hunkRange(oldStart, oldLines)} +${hunkRange(newStart, newLines)} @@`;
|
||||
}
|
||||
|
||||
/** A git hunk range. A side with zero lines names the line before the change. */
|
||||
function hunkRange(start: number, count: number): string {
|
||||
if (count === 0) return `${Math.max(0, start - 1)},0`;
|
||||
return count === 1 ? String(start) : `${start},${count}`;
|
||||
}
|
||||
|
||||
/** The hunk that adds every line of `text` to an empty file. */
|
||||
function wholeFileHunk(text: string): PatchHunk | undefined {
|
||||
if (text.length === 0) return undefined;
|
||||
const terminated = text.endsWith("\n");
|
||||
const lines = (terminated ? text.slice(0, -1) : text).split("\n");
|
||||
return {
|
||||
oldStart: 1,
|
||||
oldLines: 0,
|
||||
newStart: 1,
|
||||
newLines: lines.length,
|
||||
lines: [...lines.map((line) => `+${line}`), ...(terminated ? [] : [NO_NEWLINE_MARKER])],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The line-diff hunks between two texts. Returns undefined when the diff runs
|
||||
* out of its time budget.
|
||||
*/
|
||||
function diffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
|
||||
if (oldText === null) {
|
||||
const hunk = wholeFileHunk(newText);
|
||||
return hunk ? [hunk] : [];
|
||||
}
|
||||
const oldLines = textLines(oldText);
|
||||
const newLines = textLines(newText);
|
||||
// The diff gets only the changed middle and its context lines. An edit
|
||||
// usually changes a small part of a large file.
|
||||
const common = Math.min(oldLines.length, newLines.length);
|
||||
let prefix = 0;
|
||||
while (prefix < common && oldLines[prefix] === newLines[prefix]) prefix++;
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < common - prefix &&
|
||||
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
||||
) {
|
||||
suffix++;
|
||||
}
|
||||
const skipped = Math.max(0, prefix - PATCH_CONTEXT_LINES);
|
||||
const skippedEnd = Math.max(0, suffix - PATCH_CONTEXT_LINES);
|
||||
const patch = structuredPatch(
|
||||
"",
|
||||
"",
|
||||
oldLines.slice(skipped, oldLines.length - skippedEnd).join(""),
|
||||
newLines.slice(skipped, newLines.length - skippedEnd).join(""),
|
||||
"",
|
||||
"",
|
||||
{ context: PATCH_CONTEXT_LINES, timeout: PATCH_DIFF_TIMEOUT_MS },
|
||||
);
|
||||
return patch?.hunks.map((hunk) => ({
|
||||
...hunk,
|
||||
oldStart: hunk.oldStart + skipped,
|
||||
newStart: hunk.newStart + skipped,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The lines of a text, each with its line break. The last line can have none. */
|
||||
function textLines(text: string): string[] {
|
||||
return text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch of a change from `oldText`, null for a missing file, to
|
||||
* `newText`. Returns undefined when nothing changed or the diff runs out of
|
||||
* its time budget.
|
||||
*/
|
||||
function filePatch(oldText: string | null, newText: string): FilePatch | undefined {
|
||||
const hunks = memoizedDiffHunks(oldText, newText);
|
||||
if (!hunks || hunks.length === 0) return undefined;
|
||||
return { change: oldText === null ? "create" : "update", hunks };
|
||||
}
|
||||
|
||||
/** The number of recent line diffs that {@link memoizedDiffHunks} keeps. */
|
||||
const DIFF_MEMO_SIZE = 4;
|
||||
|
||||
/** Recent line diffs not read again yet, the most recent last. A text of at
|
||||
* most {@link MAX_PATCH_FILE_BYTES} is held until it is read again or later
|
||||
* diffs push it out. */
|
||||
const diffMemo: { oldText: string | null; newText: string; hunks: PatchHunk[] }[] = [];
|
||||
|
||||
/**
|
||||
* {@link diffHunks} of a recent pair of texts again, without a second diff.
|
||||
*
|
||||
* The approval of an Edit or Write diffs the file against the predicted text,
|
||||
* and the PostToolUse hook diffs the same texts once Claude wrote them. On a
|
||||
* large file each diff can block the event loop for its whole time budget.
|
||||
* Only a diff that completed is remembered: one that ran out of its budget
|
||||
* says nothing about the texts, so a later call diffs them again. The hunks
|
||||
* are shared, and no caller changes them.
|
||||
*/
|
||||
function memoizedDiffHunks(oldText: string | null, newText: string): PatchHunk[] | undefined {
|
||||
const index = diffMemo.findIndex(
|
||||
(entry) => entry.oldText === oldText && entry.newText === newText,
|
||||
);
|
||||
if (index >= 0) {
|
||||
// The hook is normally the last reader of a pair, so the entry goes: the
|
||||
// memo does not hold two large texts longer than needed.
|
||||
const [entry] = diffMemo.splice(index, 1);
|
||||
return entry.hunks;
|
||||
}
|
||||
const hunks = diffHunks(oldText, newText);
|
||||
if (hunks) {
|
||||
diffMemo.push({ oldText, newText, hunks });
|
||||
if (diffMemo.length > DIFF_MEMO_SIZE) diffMemo.shift();
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/** Forget the remembered line diffs. For tests. */
|
||||
export function clearDiffMemo(): void {
|
||||
diffMemo.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a file as patch input.
|
||||
*
|
||||
* Returns null when the file does not exist. Returns undefined when the file
|
||||
* cannot be read, is not a regular file, is larger than
|
||||
* {@link MAX_PATCH_FILE_BYTES}, is binary, is not valid UTF-8, or contains a
|
||||
* CR.
|
||||
*/
|
||||
async function readPatchSource(filePath: string): Promise<string | null | undefined> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
// O_NONBLOCK keeps the open of a FIFO from waiting for a writer. It does not change a regular file.
|
||||
handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
return isMissingFileError(error) ? null : undefined;
|
||||
}
|
||||
try {
|
||||
// The checks and the read use one open file, so a swap of the path between them has no effect.
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || stats.size > MAX_PATCH_FILE_BYTES) return undefined;
|
||||
// One byte more than the checked size shows a file that grew after the check.
|
||||
const buffer = Buffer.alloc(stats.size + 1);
|
||||
let length = 0;
|
||||
while (length < buffer.length) {
|
||||
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
||||
if (bytesRead === 0) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > stats.size) return undefined;
|
||||
const text = decodeFileText(buffer.subarray(0, length));
|
||||
return text?.includes("\r") ? undefined : text;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The text of a file of at most {@link MAX_PATCH_FILE_BYTES} that is not binary and is valid UTF-8. */
|
||||
function decodeFileText(bytes: Uint8Array): string | undefined {
|
||||
if (bytes.length > MAX_PATCH_FILE_BYTES) return undefined;
|
||||
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return undefined;
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tool input text can be the new side of an exact patch. */
|
||||
function isPatchableText(text: string): boolean {
|
||||
return (
|
||||
Buffer.byteLength(text, "utf8") <= MAX_PATCH_FILE_BYTES &&
|
||||
!text.includes("\0") &&
|
||||
!text.includes("\r")
|
||||
);
|
||||
}
|
||||
|
||||
/** The exact patch content of a change, or undefined when it has no patch. */
|
||||
function exactPatch(
|
||||
filePath: string,
|
||||
oldText: string | null,
|
||||
newText: string,
|
||||
form: PatchForm,
|
||||
): ToolCallContent[] | undefined {
|
||||
const patch = filePatch(oldText, newText);
|
||||
return patch && [patchContent(filePath, patch, form)];
|
||||
}
|
||||
|
||||
/** The content that carries a patch, in the given form. */
|
||||
function patchContent(filePath: string, patch: FilePatch, form: PatchForm): ToolCallContent {
|
||||
return form === "v2"
|
||||
? v2DiffContent(filePath, patch.change, patch.hunks)
|
||||
: airPatchContent(filePath, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* A diff block in the AIR patch form.
|
||||
*
|
||||
* `oldText: null` and `newText: ""` only satisfy the ACP schema. The adapter
|
||||
* sends this form only to a client that advertised `diffPatch`.
|
||||
*/
|
||||
function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCallContent {
|
||||
return {
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText: null,
|
||||
newText: "",
|
||||
_meta: withAirMeta(undefined, AIR_DIFF_PATCH_CAPABILITY, {
|
||||
version: 1,
|
||||
format: "git_patch",
|
||||
text: gitPatchText(filePath, change, hunks),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP v2 diff of a change to the text file `filePath`: the change and,
|
||||
* given hunks, its git patch.
|
||||
*
|
||||
* The agent builds tool call content in v1 types, and v1 cannot express a v2
|
||||
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
|
||||
* diff, and only a v2 client gets one: the v2 surface sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
export function v2DiffContent(
|
||||
filePath: string,
|
||||
change: FileChange,
|
||||
hunks?: PatchHunk[],
|
||||
): ToolCallContent {
|
||||
const fileChange: v2.DiffChange =
|
||||
change === "create"
|
||||
? { operation: "add", path: filePath, fileType: "text" }
|
||||
: { operation: "modify", path: filePath, fileType: "text" };
|
||||
const diff: V2DiffContent = {
|
||||
type: "diff",
|
||||
changes: [fileChange],
|
||||
...(hunks?.length
|
||||
? { patch: { format: "git_patch", text: gitPatchText(filePath, change, hunks, "absolute") } }
|
||||
: {}),
|
||||
};
|
||||
return diff as unknown as ToolCallContent;
|
||||
}
|
||||
|
||||
function resolveToolPath(filePath: string, cwd?: string): string {
|
||||
return path.isAbsolute(filePath) ? filePath : path.resolve(cwd ?? process.cwd(), filePath);
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
@@ -148,14 +153,6 @@ function questionCustomFieldKey(index: number): string {
|
||||
*/
|
||||
const OPTION_META_KEY = "_claude/askUserQuestionOption";
|
||||
|
||||
/**
|
||||
* Shared `_meta` key for marking a per-question free-text field as the custom
|
||||
* answer companion for a select question. This intentionally has no
|
||||
* agent-specific namespace so ACP clients can recognize the same marker across
|
||||
* Codex, Claude, and other AskUserQuestion bridges.
|
||||
*/
|
||||
const CUSTOM_ANSWER_META_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
/**
|
||||
* Render the AskUserQuestion tool's questions as an ACP form elicitation.
|
||||
*
|
||||
@@ -178,6 +175,7 @@ export function askUserQuestionsToCreateRequest(
|
||||
questions: AskUserQuestion[],
|
||||
sessionId: string,
|
||||
toolCallId: string | undefined,
|
||||
airClient = false,
|
||||
): CreateElicitationRequest {
|
||||
const single = questions.length === 1;
|
||||
const properties: Record<string, ElicitationPropertySchema> = {};
|
||||
@@ -210,18 +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: question.multiSelect
|
||||
? "Type your own answer to add to your selection above (optional)."
|
||||
: "Type your own answer, or add a note to the option you chose above (optional).",
|
||||
_meta: {
|
||||
[CUSTOM_ANSWER_META_KEY]: {
|
||||
questionId: questionFieldKey(index),
|
||||
isCustomAnswer: true,
|
||||
},
|
||||
},
|
||||
// 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,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,39 @@
|
||||
/**
|
||||
* `--hide-claude-auth`: this integration must never bill a claude.ai
|
||||
* subscription. The flag hides the claude.ai login method from `initialize`
|
||||
* and makes the agent behave like a logged-out CLI whenever a subscription
|
||||
* would pay for a turn. `acp-agent.ts` decides *when* the guard applies (flag
|
||||
* on, no provider override) and calls in here for the *what*.
|
||||
* subscription. Under the flag the agent hides the claude.ai login method from
|
||||
* `initialize`, and refuses with `authRequired` every session and every turn
|
||||
* that a subscription would pay for. `acp-agent.ts` decides when the guard
|
||||
* applies (flag on, no provider override); this module decides what it refuses.
|
||||
*
|
||||
* The account the CLI reports at `initialize` is the source of truth. The SDK
|
||||
* memoizes it, so it is a constant for the life of a query, and two layers
|
||||
* keep it honest:
|
||||
* The guard judges the `AccountInfo` that the SDK caches at `initialize`. That
|
||||
* cache never changes during the life of a query, but the CLI itself picks up
|
||||
* a login or logout made elsewhere. So the cached account can go stale: a
|
||||
* session started on an API key keeps passing the guard after the user swaps
|
||||
* the key for a claude.ai login, and the subscription pays.
|
||||
*
|
||||
* 1. Session creation refuses an account that holds no credential this
|
||||
* integration accepts. A logged-out session never exists, so the client's
|
||||
* sign-in always leads to a new session with a fresh `initialize`. The
|
||||
* per-turn guard repeats the check on the same cached account, which costs
|
||||
* microseconds and covers a session created under a provider override that
|
||||
* `providers/disable` later removed.
|
||||
* 2. A sign-out during the session ends the query. The next turn recreates it,
|
||||
* so the next `initialize` reports the real account and layer 1 judges it.
|
||||
* The recreation resumes the stored conversation. When the CLI never wrote
|
||||
* one, because the first turn was the one that signed out, the recreation
|
||||
* starts a fresh query under the same session id instead.
|
||||
* `authStatus` cannot fix this: it only reports to the client, and the guard
|
||||
* does not read it. The agent therefore recreates the query whenever the
|
||||
* account may have changed, and the new `initialize` gives the guard a current
|
||||
* account. Two signals trigger it:
|
||||
*
|
||||
* A file-based credential replaced by a subscription between two turns is seen
|
||||
* by the CLI probe the agent fires at the START of every user prompt. That read
|
||||
* is never awaited, so it usually lands mid-turn; all it does there is mark the
|
||||
* session. The guard consumes the mark at the turn boundary — the next prompt
|
||||
* recreates the query and layer 1 judges the account the new `initialize`
|
||||
* reports — so a probe never interrupts a running turn. A swap that happens
|
||||
* after the last probe stays unseen until the next prompt reads the store
|
||||
* again. Closing that window would cost a blocking read per turn.
|
||||
* - A turn fails with `auth_required`, which means a sign-out. The query is
|
||||
* closed at once and recreated on the next turn.
|
||||
* - The `claude auth status` check that runs at the start of every prompt (the
|
||||
* same check that feeds `authStatus`) reports a different kind of identity
|
||||
* than the session started with. The session is flagged and recreated at the
|
||||
* next turn boundary, so a running turn is never interrupted.
|
||||
*
|
||||
* The recreation resumes the conversation, or starts a new one under the same
|
||||
* session id when the CLI never saved it. Session creation also refuses an
|
||||
* account with no credential this integration accepts. So a signed-out session
|
||||
* never exists, and a sign-in always leads to a fresh `initialize`.
|
||||
*
|
||||
* The check is not awaited, so a credential swapped after it ran stays unseen
|
||||
* until the next prompt. Awaiting it would add a CLI run to every turn.
|
||||
*/
|
||||
|
||||
import { RequestError } from "@agentclientprotocol/sdk";
|
||||
import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionFailureController } from "./session-failure-extension.js";
|
||||
|
||||
export function shouldHideClaudeAuth(): boolean {
|
||||
return process.argv.includes("--hide-claude-auth");
|
||||
@@ -144,7 +144,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 +190,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 +217,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 +238,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 +256,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 +285,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;
|
||||
}
|
||||
}
|
||||
392
src/mcp-command.ts
Normal file
392
src/mcp-command.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import type { AvailableCommand } from "@agentclientprotocol/sdk";
|
||||
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { escapeMarkdown } from "./usage-markdown.js";
|
||||
import { raceTimeoutAndAbort } from "./utils.js";
|
||||
|
||||
/** The `/mcp` entry of `available_commands_update`. The adapter shows the
|
||||
* servers as a list, and runs a reconnect, an enable, or a disable through
|
||||
* the SDK control API. */
|
||||
export const MCP_AVAILABLE_COMMAND: AvailableCommand = {
|
||||
name: "mcp",
|
||||
description: "Show the MCP servers and their status, or reconnect, enable, or disable a server",
|
||||
input: { hint: "[reconnect|enable|disable [<server>|all]]" },
|
||||
};
|
||||
|
||||
/** The time that the status read after a `/mcp` command can take. */
|
||||
const MCP_STATUS_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** The time that one reconnect, enable, or disable can take. */
|
||||
const MCP_ACTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** A `/mcp` prompt that gets a replacement for its Claude Code text. `all` is
|
||||
* true for `all` and for a reconnect without a server name, as in Claude
|
||||
* Code. `server` is undefined for an enable or a disable without a name. */
|
||||
export type McpCommand =
|
||||
| { action: "status" }
|
||||
| { action: "reconnect" | "enable" | "disable"; all: boolean; server?: string };
|
||||
|
||||
/** Parse a prompt that is exactly `/mcp`, or `/mcp reconnect`, `/mcp enable`,
|
||||
* or `/mcp disable` with an optional server name or `all`. Any other `/mcp`
|
||||
* argument gives null, and the prompt gets no replacement. */
|
||||
export function parseMcpCommand(text: string): McpCommand | null {
|
||||
// The first word is `/mcp` only when the text starts with it after the
|
||||
// leading whitespace. Checking that first keeps a long prompt from being
|
||||
// split into words. `trimStart` removes the characters that `\s` matches.
|
||||
if (!text.trimStart().startsWith("/mcp")) return null;
|
||||
const words = text.trim().split(/\s+/);
|
||||
if (words[0] !== "/mcp") return null;
|
||||
if (words.length === 1) return { action: "status" };
|
||||
const action = words[1]?.toLowerCase();
|
||||
if (action !== "reconnect" && action !== "enable" && action !== "disable") return null;
|
||||
const server = words.slice(2).join(" ");
|
||||
if (server === "all" || (server === "" && action === "reconnect")) {
|
||||
return { action, all: true };
|
||||
}
|
||||
return server === "" ? { action, all: false } : { action, all: false, server };
|
||||
}
|
||||
|
||||
/** Read the MCP server status for the list after a `/mcp` command. Resolves
|
||||
* to null when the read fails, takes too long, or `signal` aborts. Then the
|
||||
* turn keeps the text of Claude Code. */
|
||||
export async function readMcpServerStatus(
|
||||
query: Pick<Query, "mcpServerStatus">,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpServerStatus[] | null> {
|
||||
if (signal.aborted) return null;
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(
|
||||
query.mcpServerStatus(),
|
||||
MCP_STATUS_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
if (outcome.type === "timeout") {
|
||||
logError("The MCP server status read timed out; keeping the Claude Code text of /mcp");
|
||||
}
|
||||
return outcome.type === "done" ? outcome.value : null;
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
logError(`The MCP server status read failed; keeping the Claude Code text of /mcp: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The known statuses in the order of the groups and of the summary counts.
|
||||
* A disabled server is not a group. It is on one line at the end. */
|
||||
const GROUP_ORDER: McpServerStatus["status"][] = ["failed", "needs-auth", "pending", "connected"];
|
||||
|
||||
const GROUP_TITLES: Partial<Record<McpServerStatus["status"], string>> = {
|
||||
failed: "Failed",
|
||||
"needs-auth": "Needs authentication",
|
||||
pending: "Connecting",
|
||||
connected: "Connected",
|
||||
};
|
||||
|
||||
const SUMMARY_LABELS: Record<McpServerStatus["status"], string> = {
|
||||
connected: "connected",
|
||||
failed: "failed",
|
||||
"needs-auth": "need authentication",
|
||||
pending: "connecting",
|
||||
disabled: "disabled",
|
||||
};
|
||||
|
||||
const KNOWN_STATUSES: string[] = [...GROUP_ORDER, "disabled"];
|
||||
|
||||
const MAX_ERROR_LENGTH = 160;
|
||||
|
||||
/** The longest segment that can be a wrapper. A longer segment is content. */
|
||||
const MAX_WRAPPER_LENGTH = 60;
|
||||
|
||||
/** True for a server that a `/mcp reconnect` without a name must retry. */
|
||||
function needsReconnect(status: McpServerStatus): boolean {
|
||||
return (
|
||||
status.status === "failed" || status.status === "pending" || status.status === "needs-auth"
|
||||
);
|
||||
}
|
||||
|
||||
/** The text as a Markdown code span. The fence is longer than the longest
|
||||
* backtick run in the text, so the span shows the text unchanged. The user
|
||||
* can copy a server name from it into `/mcp reconnect`. */
|
||||
function codeSpan(text: string): string {
|
||||
const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
||||
const fence = "`".repeat(longestRun + 1);
|
||||
// CommonMark strips one space from each end of a span, so a pad keeps the text unchanged.
|
||||
const pad = text.startsWith("`") || text.endsWith("`") || /^ .*[^ ].* $/.test(text) ? " " : "";
|
||||
return `${fence}${pad}${text}${pad}${fence}`;
|
||||
}
|
||||
|
||||
function isErrorCode(segment: string): boolean {
|
||||
return /^-?\d+$/.test(segment);
|
||||
}
|
||||
|
||||
/** True for a segment that only wraps the real error, such as
|
||||
* `MCP startup failed`, `McpError`, `MCP error -32001`, an error code, or a
|
||||
* Rust or TypeScript type path. */
|
||||
function isWrapperSegment(segment: string): boolean {
|
||||
return (
|
||||
isErrorCode(segment) ||
|
||||
segment.includes("::") ||
|
||||
(segment.length <= MAX_WRAPPER_LENGTH &&
|
||||
!/[.!?]/.test(segment) &&
|
||||
/(failed|error|exception)(\s+-?\d+)?$/i.test(segment))
|
||||
);
|
||||
}
|
||||
|
||||
/** Cut the text to `MAX_ERROR_LENGTH` code points, so a surrogate pair stays whole. */
|
||||
function truncate(text: string): string {
|
||||
const chars = Array.from(text);
|
||||
if (chars.length <= MAX_ERROR_LENGTH) return text;
|
||||
const cut = chars.slice(0, MAX_ERROR_LENGTH - 1).join("");
|
||||
return `${cut.trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** Make an MCP error short and readable for one list item. The result has
|
||||
* no leading wrapper segments, at most two sentences, and at most
|
||||
* `MAX_ERROR_LENGTH` code points. A wrapper before an error code stays, so
|
||||
* an error code is never alone. The result is empty for an error with only
|
||||
* whitespace. */
|
||||
export function cleanMcpError(error: string): string {
|
||||
const segments = error.replace(/\s+/g, " ").trim().split(": ");
|
||||
let start = 0;
|
||||
while (
|
||||
start < segments.length - 1 &&
|
||||
isWrapperSegment(segments[start]!) &&
|
||||
!segments.slice(start + 1).every(isErrorCode)
|
||||
) {
|
||||
start++;
|
||||
}
|
||||
const content = segments.slice(start).join(": ");
|
||||
return truncate(
|
||||
content
|
||||
.split(/(?<=[.!?]) /)
|
||||
.slice(0, 2)
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
function toolCount(status: McpServerStatus): string | undefined {
|
||||
if (!status.tools) return undefined;
|
||||
return status.tools.length === 1 ? "1 tool" : `${status.tools.length} tools`;
|
||||
}
|
||||
|
||||
/** The server name as inline code. Claude Code keys the servers by name, so
|
||||
* two servers have the same name only from different scopes. Then the
|
||||
* scope tells them apart. */
|
||||
function serverName(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const origin = status.scope ?? status.source;
|
||||
return duplicates.has(status.name) && origin
|
||||
? `${codeSpan(status.name)} (${escapeMarkdown(origin)})`
|
||||
: codeSpan(status.name);
|
||||
}
|
||||
|
||||
function serverItem(status: McpServerStatus, duplicates: Set<string>): string {
|
||||
const error = status.error ? cleanMcpError(status.error) : "";
|
||||
const details = [error === "" ? undefined : escapeMarkdown(error), toolCount(status)]
|
||||
.filter((detail) => detail !== undefined)
|
||||
.join("; ");
|
||||
const name = serverName(status, duplicates);
|
||||
return details === "" ? `- ${name}` : `- ${name}: ${details}`;
|
||||
}
|
||||
|
||||
function summaryLine(statuses: McpServerStatus[]): string {
|
||||
// The counts follow the group order. An unknown status also counts, so the counts add up to the total.
|
||||
const counts = [...GROUP_ORDER, ...unknownStatuses(statuses), "disabled"]
|
||||
.map((state) => ({
|
||||
state,
|
||||
count: statuses.filter((status) => status.status === state).length,
|
||||
}))
|
||||
.filter(({ count }) => count > 0);
|
||||
const parts = counts.map(({ state, count }) =>
|
||||
count === 1 && state === "needs-auth"
|
||||
? "1 needs authentication"
|
||||
: `${count} ${SUMMARY_LABELS[state as McpServerStatus["status"]] ?? escapeMarkdown(state)}`,
|
||||
);
|
||||
return `**MCP servers:** ${statuses.length} (${parts.join(", ")})`;
|
||||
}
|
||||
|
||||
/** The statuses that this adapter does not know, in the SDK order. The SDK
|
||||
* can report a new status before the adapter knows it. */
|
||||
function unknownStatuses(statuses: McpServerStatus[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
statuses
|
||||
.map((status) => status.status as string)
|
||||
.filter((state) => !KNOWN_STATUSES.includes(state)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** The group title of a status that the adapter does not know. It starts with a capital letter, as a known title does. */
|
||||
function unknownTitle(state: string): string {
|
||||
return escapeMarkdown(state.charAt(0).toUpperCase() + state.slice(1));
|
||||
}
|
||||
|
||||
/** Markdown for `/mcp`: a summary line, one group of servers for each
|
||||
* status, and the disabled servers on one line. A list reads better than a
|
||||
* table in a narrow chat. */
|
||||
export function formatMcpStatus(statuses: McpServerStatus[]): string {
|
||||
if (statuses.length === 0) return "No MCP servers are configured.";
|
||||
const blocks: string[] = [];
|
||||
const names = statuses.map((status) => status.name);
|
||||
const duplicates = new Set(names.filter((name, index) => names.indexOf(name) !== index));
|
||||
blocks.push(summaryLine(statuses));
|
||||
const groups: [string, string][] = [
|
||||
...GROUP_ORDER.map((state): [string, string] => [state, GROUP_TITLES[state]!]),
|
||||
...unknownStatuses(statuses).map((state): [string, string] => [state, unknownTitle(state)]),
|
||||
];
|
||||
for (const [state, title] of groups) {
|
||||
const members = statuses.filter((status) => status.status === state);
|
||||
if (members.length === 0) continue;
|
||||
blocks.push(
|
||||
[`**${title}**`, ...members.map((status) => serverItem(status, duplicates))].join("\n"),
|
||||
);
|
||||
}
|
||||
const disabled = statuses.filter((status) => status.status === "disabled");
|
||||
if (disabled.length > 0) {
|
||||
blocks.push(
|
||||
`**Disabled:** ${disabled.map((status) => serverName(status, duplicates)).join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (statuses.some(needsReconnect)) {
|
||||
blocks.push(
|
||||
"Run `/mcp reconnect <server>` to reconnect one server, or `/mcp reconnect` to reconnect every server that is not connected and not disabled.",
|
||||
);
|
||||
}
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
|
||||
/** The SDK query methods that `/mcp` uses. */
|
||||
export type McpControlQuery = Pick<
|
||||
Query,
|
||||
"mcpServerStatus" | "reconnectMcpServer" | "toggleMcpServer"
|
||||
>;
|
||||
|
||||
/** The result of {@link runMcpCommand}. `markdown` is null when the turn
|
||||
* keeps the text of Claude Code. `reconnected` holds the servers that the
|
||||
* adapter tried to reconnect. */
|
||||
export type McpCommandOutcome = { markdown: string | null; reconnected: string[] };
|
||||
|
||||
type CallResult = { type: "done" } | { type: "failed"; error: string } | { type: "aborted" };
|
||||
|
||||
/** Wait for one control call. The wait stops when `signal` aborts or the
|
||||
* call takes more than {@link MCP_ACTION_TIMEOUT_MS}. */
|
||||
async function awaitCall(call: () => Promise<void>, signal: AbortSignal): Promise<CallResult> {
|
||||
if (signal.aborted) return { type: "aborted" };
|
||||
try {
|
||||
const outcome = await raceTimeoutAndAbort(call(), MCP_ACTION_TIMEOUT_MS, signal);
|
||||
if (outcome.type === "timeout") return { type: "failed", error: "The request timed out." };
|
||||
return outcome.type === "done" ? { type: "done" } : outcome;
|
||||
} catch (error) {
|
||||
return signal.aborted
|
||||
? { type: "aborted" }
|
||||
: { type: "failed", error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_DONE = { reconnect: "Reconnected", enable: "Enabled", disable: "Disabled" };
|
||||
|
||||
/** The Claude Code sentence that refuses an action in SDK mode. */
|
||||
const UNAVAILABLE_SENTENCE = /aren't available in this session/i;
|
||||
|
||||
/** A Claude Code sentence that points to a terminal, which an ACP client
|
||||
* does not have. */
|
||||
function isTerminalSentence(sentence: string): boolean {
|
||||
return /in the terminal/i.test(sentence) || sentence.includes("`/mcp reconnect all` here");
|
||||
}
|
||||
|
||||
function sentences(text: string): string[] {
|
||||
return text
|
||||
.trim()
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.filter((sentence) => sentence !== "");
|
||||
}
|
||||
|
||||
/** The servers that the action changes. A named server is always a target,
|
||||
* so the control API reports its own error. */
|
||||
function actionTargets(
|
||||
command: Extract<McpCommand, { all: boolean }>,
|
||||
statuses: McpServerStatus[],
|
||||
): string[] {
|
||||
if (!command.all) return command.server === undefined ? [] : [command.server];
|
||||
const names = statuses
|
||||
.filter((status) => {
|
||||
if (command.action === "reconnect") return needsReconnect(status);
|
||||
if (command.action === "enable") return status.status === "disabled";
|
||||
return status.status !== "disabled";
|
||||
})
|
||||
.map((status) => status.name);
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
function nothingToDoLine(action: McpCommand["action"]): string {
|
||||
if (action === "enable") return "Every MCP server is already enabled.";
|
||||
if (action === "disable") return "Every MCP server is already disabled.";
|
||||
return "Every MCP server is connected or disabled. There is nothing to reconnect.";
|
||||
}
|
||||
|
||||
/** Run a `/mcp` command after Claude Code sent `originalOutput`, its text for
|
||||
* the command. Claude Code refuses a reconnect, an enable, and a disable in
|
||||
* SDK mode, so the adapter runs them through the SDK control API. The result
|
||||
* of each server goes above the server list. An error of one server does
|
||||
* not stop the others. When the first status read fails or `signal`
|
||||
* aborts, the turn keeps the text of Claude Code. */
|
||||
export async function runMcpCommand(
|
||||
query: McpControlQuery,
|
||||
command: McpCommand,
|
||||
originalOutput: string,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<McpCommandOutcome> {
|
||||
const reconnected: string[] = [];
|
||||
const keep: McpCommandOutcome = { markdown: null, reconnected };
|
||||
if (command.action === "status") {
|
||||
// The list replaces the Claude Code summary line.
|
||||
const statuses = await readMcpServerStatus(query, signal, logError);
|
||||
return { markdown: statuses ? formatMcpStatus(statuses) : null, reconnected };
|
||||
}
|
||||
const before = await readMcpServerStatus(query, signal, logError);
|
||||
if (!before) return keep;
|
||||
|
||||
const cliSentences = sentences(originalOutput).filter(
|
||||
(sentence) => !isTerminalSentence(sentence),
|
||||
);
|
||||
const cliNamesUnknown = cliSentences.some((sentence) => /no MCP server named/i.test(sentence));
|
||||
const results: string[] = [];
|
||||
if (!command.all && command.server === undefined) {
|
||||
results.push(`Name a server or \`all\`, for example \`/mcp ${command.action} <server>\`.`);
|
||||
}
|
||||
const known = new Set(before.map((status) => status.name));
|
||||
const targets = actionTargets(command, before);
|
||||
if (command.all && targets.length === 0) results.push(nothingToDoLine(command.action));
|
||||
for (const server of targets) {
|
||||
if (!known.has(server)) {
|
||||
// Claude Code checks the name first. Its sentence then stays.
|
||||
if (!cliNamesUnknown) results.push(`There's no MCP server named ${codeSpan(server)}.`);
|
||||
continue;
|
||||
}
|
||||
if (command.action === "reconnect") reconnected.push(server);
|
||||
const call =
|
||||
command.action === "reconnect"
|
||||
? () => query.reconnectMcpServer(server)
|
||||
: () => query.toggleMcpServer(server, command.action === "enable");
|
||||
const result = await awaitCall(call, signal);
|
||||
if (result.type === "aborted") return keep;
|
||||
if (result.type === "done") {
|
||||
results.push(`- ${ACTION_DONE[command.action]} ${codeSpan(server)}`);
|
||||
} else {
|
||||
const error = cleanMcpError(result.error);
|
||||
const failure = `- Couldn't ${command.action} ${codeSpan(server)}`;
|
||||
results.push(error === "" ? failure : `${failure}: ${escapeMarkdown(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
const kept = cliSentences.filter((sentence) => !UNAVAILABLE_SENTENCE.test(sentence));
|
||||
if (kept.length > 0) blocks.push(kept.join(" "));
|
||||
if (results.length > 0) blocks.push(results.join("\n"));
|
||||
const after = await readMcpServerStatus(query, signal, logError);
|
||||
if (signal.aborted) return keep;
|
||||
if (after) blocks.push(formatMcpStatus(after));
|
||||
return { markdown: blocks.join("\n\n"), reconnected };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
|
||||
export type NativeSubagent = {
|
||||
sessionId: string;
|
||||
@@ -6,6 +7,11 @@ export type NativeSubagent = {
|
||||
parentToolUseId?: string;
|
||||
name: string;
|
||||
task: string;
|
||||
/**
|
||||
* The exact prompt of this generation, sent as `prompt` in
|
||||
* `subagent_spawned`. It is absent when the adapter has no prompt.
|
||||
*/
|
||||
prompt?: string;
|
||||
announced?: boolean;
|
||||
terminalState?: SubagentState;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
@@ -41,6 +47,8 @@ type SubagentIdentity = {
|
||||
const MAX_PENDING_PARENTS = 64;
|
||||
const MAX_PENDING_UPDATES = 256;
|
||||
const MAX_PENDING_UPDATES_PER_PARENT = 32;
|
||||
/** The number of child tool calls whose owning child session the runtime remembers. */
|
||||
const MAX_CHILD_TOOL_CALLS = 2048;
|
||||
|
||||
/**
|
||||
* Owns the connection-local native subagent registry and all ACP lifecycle
|
||||
@@ -55,6 +63,12 @@ export class NativeSubagentRuntime {
|
||||
private readonly identityByToolUse = new Map<string, SubagentIdentity>();
|
||||
private readonly controlByToolUse = new Map<string, AcpSessionNotification>();
|
||||
private readonly childByParentToolUse = new Map<string, NativeSubagent>();
|
||||
/**
|
||||
* The child session of each tool call that went to a child session. A later
|
||||
* update of that tool call can lose `parentToolUseId`, for example a progress
|
||||
* beat after the child finished. It still belongs to the child session.
|
||||
*/
|
||||
private readonly childByToolCall = new Map<string, NativeSubagent>();
|
||||
private readonly taskFinishPromises = new Map<string, Promise<void>>();
|
||||
private readonly generationByTaskId = new Map<string, number>();
|
||||
private readonly pending = new Map<string, AcpSessionNotification[]>();
|
||||
@@ -85,7 +99,7 @@ export class NativeSubagentRuntime {
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const { update } = notification;
|
||||
const claudeMeta = update._meta?.claudeCode as
|
||||
{ parentToolUseId?: string | null; subagent?: true; toolName?: string } | undefined;
|
||||
{ parentToolUseId?: string | null; toolName?: string } | undefined;
|
||||
const isControl = isNativeSubagentControlUpdate(update);
|
||||
|
||||
if (!this.enabled) return notification;
|
||||
@@ -126,11 +140,23 @@ export class NativeSubagentRuntime {
|
||||
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update"
|
||||
? update.toolCallId
|
||||
: undefined;
|
||||
|
||||
// A permission request may have had to create the tool call before native
|
||||
// child ownership was known. Keep every later update in that original ACP
|
||||
// session; moving a lifecycle after its initial call creates an orphan in
|
||||
// both transcripts.
|
||||
if (forcedSessionId) return { ...notification, sessionId: forcedSessionId };
|
||||
if (forcedSessionId) {
|
||||
const forcedChild = this.childOfSession(forcedSessionId);
|
||||
if (toolCallId && forcedChild) this.rememberToolCallOwner(toolCallId, forcedChild);
|
||||
return { ...notification, sessionId: forcedSessionId };
|
||||
}
|
||||
|
||||
const owner = toolCallId ? this.childByToolCall.get(toolCallId) : undefined;
|
||||
if (owner) return this.toChild(owner, notification, toolCallId);
|
||||
|
||||
if (this.enabled && claudeMeta?.parentToolUseId) {
|
||||
const child = this.childByParentToolUse.get(claudeMeta.parentToolUseId);
|
||||
@@ -138,13 +164,7 @@ export class NativeSubagentRuntime {
|
||||
this.buffer(claudeMeta.parentToolUseId, notification);
|
||||
return null;
|
||||
}
|
||||
if (child.terminalState !== undefined || child.terminalPromise) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { ...notification, sessionId: child.sessionId };
|
||||
return this.toChild(child, notification, toolCallId);
|
||||
}
|
||||
|
||||
return notification;
|
||||
@@ -162,40 +182,64 @@ export class NativeSubagentRuntime {
|
||||
const previous = this.children.get(task.taskId);
|
||||
if (previous && previous.terminalState === undefined) return;
|
||||
|
||||
const knownParentSessionId = task.toolUseId
|
||||
? this.parentByToolUse.get(task.toolUseId)
|
||||
: undefined;
|
||||
// A SendMessage resume reuses the finished generation's tool id, whose
|
||||
// control state is already cleaned up.
|
||||
const knownParentSessionId =
|
||||
(task.toolUseId ? this.parentByToolUse.get(task.toolUseId) : undefined) ??
|
||||
(previous && this.resumedParentSessionId(previous));
|
||||
const identity = task.toolUseId ? this.identityByToolUse.get(task.toolUseId) : undefined;
|
||||
const child: NativeSubagent = {
|
||||
sessionId: this.nextChildSessionId(task.taskId, previous),
|
||||
parentSessionId: knownParentSessionId ?? this.rootSessionId,
|
||||
parentToolUseId: task.toolUseId ?? undefined,
|
||||
name: subagentDisplayName(
|
||||
identity?.name,
|
||||
identity?.description ?? task.description,
|
||||
identity?.subagentType ?? task.subagentType,
|
||||
task.taskId,
|
||||
),
|
||||
task: subagentDescription(
|
||||
identity?.prompt ?? task.prompt,
|
||||
identity?.description ?? task.description,
|
||||
),
|
||||
};
|
||||
this.children.set(task.taskId, child);
|
||||
if (task.toolUseId) {
|
||||
this.taskByToolUse.set(task.toolUseId, task.taskId);
|
||||
this.childByParentToolUse.set(task.toolUseId, child);
|
||||
this.controlByToolUse.delete(task.toolUseId);
|
||||
}
|
||||
|
||||
// A nested child must wait for the spawning Agent/Task frame to establish
|
||||
// its immediate parent. Root children without a tool id can be announced.
|
||||
if (knownParentSessionId || !task.toolUseId) {
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of task.toolUseId ? this.takePending(task.toolUseId) : []) {
|
||||
await deliver(pending);
|
||||
}
|
||||
}
|
||||
await this.openGeneration(
|
||||
task.taskId,
|
||||
previous,
|
||||
{
|
||||
parentSessionId: knownParentSessionId ?? this.rootSessionId,
|
||||
parentToolUseId: task.toolUseId ?? undefined,
|
||||
name: subagentDisplayName(
|
||||
identity?.name,
|
||||
identity?.description ?? task.description,
|
||||
identity?.subagentType ?? task.subagentType,
|
||||
task.taskId,
|
||||
),
|
||||
task: subagentDescription(
|
||||
identity?.prompt ?? task.prompt,
|
||||
identity?.description ?? task.description,
|
||||
),
|
||||
...promptField(promptText(task.prompt) ?? identity?.prompt),
|
||||
},
|
||||
!!knownParentSessionId || !task.toolUseId,
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a new generation of a finished child when the SDK resumes the same
|
||||
* agent id. The SDK can resume a child without a new `task_started`, so a
|
||||
* running `task_updated` patch or a SendMessage `resumedAgentId` is the
|
||||
* signal. A child that did not finish is not changed. The `prompt` is the
|
||||
* SendMessage text that resumed the child, when the adapter knows it.
|
||||
*/
|
||||
async taskResumed(taskId: string, deliver: Publish, prompt?: string): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const previous = this.children.get(taskId);
|
||||
if (!previous) return;
|
||||
const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
|
||||
if (finishing) await finishing.catch(() => {});
|
||||
if (this.children.get(taskId) !== previous || previous.terminalState === undefined) return;
|
||||
await this.openGeneration(
|
||||
taskId,
|
||||
previous,
|
||||
{
|
||||
parentSessionId: this.resumedParentSessionId(previous),
|
||||
parentToolUseId: previous.parentToolUseId,
|
||||
name: previous.name,
|
||||
task: previous.task,
|
||||
...promptField(promptText(prompt)),
|
||||
},
|
||||
true,
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
|
||||
async finishTask(
|
||||
@@ -258,8 +302,74 @@ export class NativeSubagentRuntime {
|
||||
this.takePending(parentToolUseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an update to the child session. An update of a finished child is
|
||||
* dropped: it never goes to the root session.
|
||||
*/
|
||||
private toChild(
|
||||
child: NativeSubagent,
|
||||
notification: AcpSessionNotification,
|
||||
toolCallId: string | undefined,
|
||||
): AcpSessionNotification | null {
|
||||
if (child.terminalState !== undefined || child.terminalPromise) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (toolCallId) this.rememberToolCallOwner(toolCallId, child);
|
||||
return { ...notification, sessionId: child.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* The route of the work that a child tool call started, for example an async
|
||||
* task. The route sends each update to the child generation that owned the
|
||||
* tool call when the work started. An async task can outlive that child: a
|
||||
* subagent can start a background command and end its turn. So the route
|
||||
* still sends `async_task_spawned`, `async_task_progress` and
|
||||
* `async_task_state_update` to the child generation after it finished. The route drops every other update
|
||||
* after the child finished. `undefined` means that the root session owns the
|
||||
* tool call. `eagerSessionId` is the session where a permission request
|
||||
* created the tool call before the stream routed it.
|
||||
*/
|
||||
routeOfToolCall(
|
||||
toolCallId: string,
|
||||
eagerSessionId?: string,
|
||||
): ((notification: AcpSessionNotification) => AcpSessionNotification | null) | undefined {
|
||||
if (!this.enabled) return undefined;
|
||||
const owner =
|
||||
this.childByToolCall.get(toolCallId) ??
|
||||
(eagerSessionId ? this.childOfSession(eagerSessionId) : undefined);
|
||||
return (
|
||||
owner &&
|
||||
((notification) =>
|
||||
isLateAsyncTaskUpdate(notification)
|
||||
? { ...notification, sessionId: owner.sessionId }
|
||||
: this.toChild(owner, notification, undefined))
|
||||
);
|
||||
}
|
||||
|
||||
private rememberToolCallOwner(toolCallId: string, child: NativeSubagent): void {
|
||||
this.childByToolCall.delete(toolCallId);
|
||||
this.childByToolCall.set(toolCallId, child);
|
||||
if (this.childByToolCall.size > MAX_CHILD_TOOL_CALLS) {
|
||||
const oldest = this.childByToolCall.keys().next().value;
|
||||
if (oldest !== undefined) this.childByToolCall.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** The child generation with the ACP session `sessionId`, if one exists. */
|
||||
private childOfSession(sessionId: string): NativeSubagent | undefined {
|
||||
if (sessionId === this.rootSessionId) return undefined;
|
||||
for (const child of this.children.values()) {
|
||||
if (child.sessionId === sessionId) return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.children.clear();
|
||||
this.childByToolCall.clear();
|
||||
this.taskByToolUse.clear();
|
||||
this.parentByToolUse.clear();
|
||||
this.identityByToolUse.clear();
|
||||
@@ -303,6 +413,52 @@ export class NativeSubagentRuntime {
|
||||
this.parentByToolUse.delete(toolUseId);
|
||||
}
|
||||
|
||||
/** The parent of a resumed generation: the old parent while it is live, else the root. */
|
||||
private resumedParentSessionId(previous: NativeSubagent): string {
|
||||
return this.isLiveSession(previous.parentSessionId)
|
||||
? previous.parentSessionId
|
||||
: this.rootSessionId;
|
||||
}
|
||||
|
||||
private isLiveSession(sessionId: string): boolean {
|
||||
if (sessionId === this.rootSessionId) return true;
|
||||
for (const child of this.children.values()) {
|
||||
if (child.sessionId === sessionId) return child.terminalState === undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new child session for the task and makes it the owner of its
|
||||
* parent tool call. With `announce`, it publishes `subagent_spawned` and
|
||||
* delivers the updates that waited for the child.
|
||||
*/
|
||||
private async openGeneration(
|
||||
taskId: string,
|
||||
previous: NativeSubagent | undefined,
|
||||
fields: Pick<
|
||||
NativeSubagent,
|
||||
"parentSessionId" | "parentToolUseId" | "name" | "task" | "prompt"
|
||||
>,
|
||||
announce: boolean,
|
||||
deliver: Publish,
|
||||
): Promise<void> {
|
||||
const child: NativeSubagent = {
|
||||
sessionId: this.nextChildSessionId(taskId, previous),
|
||||
...fields,
|
||||
};
|
||||
const toolUseId = child.parentToolUseId;
|
||||
this.children.set(taskId, child);
|
||||
if (toolUseId) {
|
||||
this.taskByToolUse.set(toolUseId, taskId);
|
||||
this.childByParentToolUse.set(toolUseId, child);
|
||||
this.controlByToolUse.delete(toolUseId);
|
||||
}
|
||||
if (!announce) return;
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
|
||||
}
|
||||
|
||||
private nextChildSessionId(taskId: string, previous: NativeSubagent | undefined): string {
|
||||
if (!previous) {
|
||||
this.generationByTaskId.set(taskId, 1);
|
||||
@@ -328,6 +484,7 @@ export async function announceNativeSubagent(
|
||||
subagentSessionId: child.sessionId,
|
||||
name: child.name,
|
||||
task: child.task,
|
||||
...promptField(child.prompt),
|
||||
capabilities: {},
|
||||
},
|
||||
});
|
||||
@@ -370,6 +527,41 @@ export async function finishNativeSubagent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent id that a successful SendMessage result resumed. The SDK puts it
|
||||
* in `tool_use_result.resumedAgentId` when a finished agent runs again.
|
||||
*/
|
||||
export function resumedNativeSubagentId(toolUseResult: unknown): string | undefined {
|
||||
if (typeof toolUseResult !== "object" || toolUseResult === null) return undefined;
|
||||
const result = toolUseResult as { success?: unknown; resumedAgentId?: unknown };
|
||||
return result.success === true ? nonBlankString(result.resumedAgentId) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SendMessage text that resumed the agent `agentId`. The tool uses of
|
||||
* `resultToolUseIds` come first: they are the SendMessage calls whose result
|
||||
* carried the resume. Otherwise the latest SendMessage call to `agentId` counts.
|
||||
*/
|
||||
export function sendMessageResumePrompt(
|
||||
toolUses: Record<string, { name: string; input: unknown } | undefined>,
|
||||
agentId: string,
|
||||
resultToolUseIds: readonly string[] = [],
|
||||
): string | undefined {
|
||||
for (const toolUseId of resultToolUseIds) {
|
||||
const toolUse = toolUses[toolUseId];
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const text = promptText((toolUse.input as { message?: unknown } | null)?.message);
|
||||
if (text) return text;
|
||||
}
|
||||
if (resultToolUseIds.length > 0) return undefined;
|
||||
for (const toolUse of Object.values(toolUses).reverse()) {
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const input = toolUse.input as { to?: unknown; message?: unknown } | null;
|
||||
if (input?.to === agentId) return promptText(input.message);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function nativeSubagentState(status: unknown): SubagentState | undefined {
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "failed") return "failed";
|
||||
@@ -387,14 +579,31 @@ export function isNativeSubagentControlUpdate(
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return false;
|
||||
}
|
||||
const claudeMeta = update._meta?.claudeCode as { subagent?: true; toolName?: string } | undefined;
|
||||
return claudeMeta?.subagent === true || isNativeSubagentControlTool(claudeMeta?.toolName);
|
||||
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
|
||||
return (
|
||||
airExtensionMeta(update._meta)?.[AIR_SUBAGENT_KEY] === true ||
|
||||
isNativeSubagentControlTool(claudeMeta?.toolName)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNativeSubagentControlTool(toolName: unknown): boolean {
|
||||
return toolName === "Agent" || toolName === "Task";
|
||||
}
|
||||
|
||||
/**
|
||||
* An async task update that may reach a child generation after it finished.
|
||||
* The spawn is one of them: a held task can get its tool call id, and so its
|
||||
* spawn, after the child finished.
|
||||
*/
|
||||
function isLateAsyncTaskUpdate(notification: AcpSessionNotification): boolean {
|
||||
const kind = notification.update.sessionUpdate;
|
||||
return (
|
||||
kind === "async_task_spawned" ||
|
||||
kind === "async_task_progress" ||
|
||||
kind === "async_task_state_update"
|
||||
);
|
||||
}
|
||||
|
||||
function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boolean {
|
||||
return (
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
@@ -466,8 +675,14 @@ function ordinaryToolMeta(
|
||||
(value) => (value?.claudeCode as Record<string, unknown> | null | undefined) ?? {},
|
||||
),
|
||||
);
|
||||
delete claudeCode.subagent;
|
||||
return { ...merged, claudeCode };
|
||||
const result: Record<string, unknown> = { ...merged, claudeCode };
|
||||
const air = airExtensionMeta(merged);
|
||||
if (air && AIR_SUBAGENT_KEY in air) {
|
||||
const rest = { ...air };
|
||||
delete rest[AIR_SUBAGENT_KEY];
|
||||
result.jetbrains = { ...(merged.jetbrains as Record<string, unknown>), air: rest };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function subagentDisplayName(
|
||||
@@ -496,7 +711,7 @@ function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
const identity: SubagentIdentity = {
|
||||
name: nonBlankString(value.name),
|
||||
description: nonBlankString(value.description),
|
||||
prompt: nonBlankString(value.prompt),
|
||||
prompt: promptText(value.prompt),
|
||||
subagentType: nonBlankString(value.subagent_type),
|
||||
};
|
||||
return Object.values(identity).some(Boolean) ? identity : undefined;
|
||||
@@ -530,6 +745,16 @@ function applySubagentIdentity(
|
||||
if (identity.prompt || identity.description) {
|
||||
child.task = subagentDescription(identity.prompt, identity.description);
|
||||
}
|
||||
child.prompt ??= identity.prompt;
|
||||
}
|
||||
|
||||
/** The prompt text unchanged, or `undefined` when it is not a non-blank string. */
|
||||
function promptText(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function promptField(prompt: string | undefined): { prompt?: string } {
|
||||
return prompt === undefined ? {} : { prompt };
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | undefined {
|
||||
|
||||
7
src/paths.ts
Normal file
7
src/paths.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** The Claude configuration directory: `CLAUDE_CONFIG_DIR`, or `~/.claude`. */
|
||||
export function claudeConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export interface PermissionOptionContext {
|
||||
* 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,12 +1,29 @@
|
||||
import type { RequestPermissionRequest, ToolCallLocation } from "@agentclientprotocol/sdk";
|
||||
import { toolInfoFromToolUse } from "../tools.js";
|
||||
import type { RequestPermissionRequest, ToolCallContent } from "@agentclientprotocol/sdk";
|
||||
import { AIR_PERMISSION_KEY, withAirMeta } from "../air-extension.js";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
|
||||
|
||||
/**
|
||||
* A permission request as the agent builds it. For a v2 client it also
|
||||
* carries the heading and the explanation of the prompt, which ACP v2 sends as
|
||||
* the request's `title` and `description`. v1 has no field for them; AIR gets
|
||||
* them in `_meta`.
|
||||
*/
|
||||
export type AcpPermissionRequest = RequestPermissionRequest & {
|
||||
/** The heading of the prompt. It is not the title of the tool call. */
|
||||
title?: string;
|
||||
/** Why the permission is asked. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export interface ClaudePermissionPresentationInput {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
toolUseID: string;
|
||||
cwd?: string;
|
||||
supportsTerminalOutput?: boolean;
|
||||
capabilities?: ClientCapabilities;
|
||||
/** The exact patch of the change to approve, for a negotiated `diffPatch` client or a v2 client. */
|
||||
previewContent?: ToolCallContent[];
|
||||
blockedPath?: string;
|
||||
title?: string;
|
||||
displayName?: string;
|
||||
@@ -39,46 +56,33 @@ function compactText(value: unknown): string | undefined {
|
||||
return humanText(value, 160, true);
|
||||
}
|
||||
|
||||
function withBlockedPath(
|
||||
locations: ToolCallLocation[] | undefined,
|
||||
blockedPath: unknown,
|
||||
): ToolCallLocation[] | undefined {
|
||||
const path = humanText(blockedPath, 4_096, true);
|
||||
if (!path) return locations;
|
||||
const result = [...(locations ?? [])];
|
||||
if (!result.some((location) => location.path === path)) result.push({ path });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission request presentation.
|
||||
*
|
||||
* The request `toolCall` carries `toolCallId`, `title`, and `rawInput`. The
|
||||
* adapter emits the `tool_call` before the request, so the client already
|
||||
* holds the rest. The request adds only what it shows new: the exact preview
|
||||
* patch, and the blocked path when the tool call has no such location.
|
||||
*
|
||||
* The heading and the explanation of the prompt go to AIR in `_meta`, and to a
|
||||
* v2 client as `title` and `description`.
|
||||
*/
|
||||
export function buildClaudePermissionPresentation(
|
||||
value: ClaudePermissionPresentationInput,
|
||||
): Pick<RequestPermissionRequest, "toolCall" | "_meta"> {
|
||||
const info = toolInfoFromToolUse(
|
||||
{ id: value.toolUseID, name: value.toolName, input: value.input },
|
||||
value.supportsTerminalOutput ?? false,
|
||||
value.cwd,
|
||||
);
|
||||
): Pick<AcpPermissionRequest, "toolCall" | "_meta" | "title" | "description"> {
|
||||
const capabilities = value.capabilities ?? new ClientCapabilities();
|
||||
const renderer = new AcpToolCallRenderer(capabilities);
|
||||
const toolUse = { id: value.toolUseID, name: value.toolName, input: value.input };
|
||||
const facts = renderer.facts(toolUse, value.cwd);
|
||||
const host =
|
||||
value.toolName === "SandboxNetworkAccess" ? compactText(value.input.host) : undefined;
|
||||
const isComputerUse = value.toolName.startsWith("mcp__computer-use__");
|
||||
const subjectTitle = host ?? (isComputerUse ? compactText(value.displayName) : undefined);
|
||||
const subjectContent =
|
||||
(host || isComputerUse) && info.content.length === 0
|
||||
? [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "text" as const,
|
||||
text: `\`\`\`json\n${JSON.stringify(value.input, null, 2)}\n\`\`\``,
|
||||
},
|
||||
},
|
||||
]
|
||||
: info.content;
|
||||
// Reuse the exact standard tool-call heading as the permission heading so
|
||||
// the approval never maintains a second, divergent name for the operation.
|
||||
// decisionReason is temporarily exposed as the permission description so
|
||||
// its actual SDK values can be inspected; it remains diagnostic policy text.
|
||||
const toolCallTitle = subjectTitle ?? info.title;
|
||||
const toolCallTitle = subjectTitle ?? facts.title;
|
||||
const permissionTitle = value.toolName === "ExitPlanMode" ? "Ready to code?" : toolCallTitle;
|
||||
// Shell titles are executable input: compacting whitespace changes quoted
|
||||
// arguments and comment boundaries, and length limits can hide the command.
|
||||
@@ -88,31 +92,46 @@ export function buildClaudePermissionPresentation(
|
||||
: (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 } : {}),
|
||||
// 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 } : {}),
|
||||
},
|
||||
},
|
||||
_meta: withAirMeta(undefined, AIR_PERMISSION_KEY, {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// The CLI's own hint, forwarded so a client that can pre-select
|
||||
// an option keeps the decline focused; the option order already
|
||||
// leads with the reject options when this is set.
|
||||
...(value.defaultToNo === true ? { defaultToNo: true } : {}),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,177 @@ export async function readResumedSession(
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** What a resume restores from the end of the local transcript. */
|
||||
export type ResumedTail = {
|
||||
model?: string;
|
||||
/** The last permission mode of the main thread. See {@link TailScan}. */
|
||||
permissionMode?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the resume model and the permission mode of a session from the end
|
||||
* of its local transcript.
|
||||
*
|
||||
* A resume needs only these two values, not the messages. The transcript file
|
||||
* is read backwards until both are known, so the cost does not grow with the
|
||||
* length of the session. A session without a local transcript file falls back
|
||||
* to {@link readResumedSession} for the model.
|
||||
*
|
||||
* A caller that already has the model passes `withModel: false`. The read then
|
||||
* looks for the permission mode only and has no fallback.
|
||||
*/
|
||||
export async function readResumedTail(
|
||||
sessionId: string,
|
||||
logger?: ResumeLogger,
|
||||
withModel = true,
|
||||
): Promise<ResumedTail> {
|
||||
const timing = new SessionTiming(logger, "models", sessionId);
|
||||
try {
|
||||
const filePath = await findTranscript(sessionId);
|
||||
if (filePath) {
|
||||
const tail = await lastTailRecords(filePath, withModel);
|
||||
timing.phase(
|
||||
"read-transcript-tail",
|
||||
` model=${tail.model ?? "unknown"} permissionMode=${tail.permissionMode ?? "unknown"}`,
|
||||
);
|
||||
return tail;
|
||||
}
|
||||
} catch (error) {
|
||||
timing.phase("read-transcript-tail", " outcome=error");
|
||||
logger?.error(`Failed to read the transcript tail of resumed session ${sessionId}:`, error);
|
||||
}
|
||||
return withModel ? { model: (await readResumedSession(sessionId, logger)).model } : {};
|
||||
}
|
||||
|
||||
/** The local transcript of a session in any project directory, as the SDK looks it up. */
|
||||
export async function findTranscript(sessionId: string): Promise<string | undefined> {
|
||||
const projects = path.join(claudeConfigDir(), "projects");
|
||||
let directories: string[];
|
||||
try {
|
||||
directories = await readdir(projects);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
for (const directory of directories) {
|
||||
const candidate = path.join(projects, directory, `${sessionId}.jsonl`);
|
||||
try {
|
||||
await access(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Not in this project directory.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The model of the last real assistant record and the last permission mode
|
||||
* of the main thread in a JSONL transcript. */
|
||||
async function lastTailRecords(filePath: string, withModel: boolean): Promise<ResumedTail> {
|
||||
const handle = await open(filePath, "r");
|
||||
const scan = new TailScan(withModel);
|
||||
try {
|
||||
let end = (await handle.stat()).size;
|
||||
// The bytes of the line whose start is not read yet, in file order.
|
||||
let pending: Buffer[] = [];
|
||||
while (end > 0) {
|
||||
const start = Math.max(0, end - TAIL_CHUNK_BYTES);
|
||||
let chunk = Buffer.alloc(end - start);
|
||||
await handle.read(chunk, 0, chunk.length, start);
|
||||
for (let at = chunk.lastIndexOf(0x0a); at >= 0; at = chunk.lastIndexOf(0x0a)) {
|
||||
if (scan.visit(Buffer.concat([chunk.subarray(at + 1), ...pending]))) return scan.tail;
|
||||
pending = [];
|
||||
chunk = chunk.subarray(0, at);
|
||||
}
|
||||
pending.unshift(chunk);
|
||||
end = start;
|
||||
}
|
||||
scan.visit(Buffer.concat(pending));
|
||||
return scan.tail;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
type TranscriptRecord = {
|
||||
type?: unknown;
|
||||
isSidechain?: unknown;
|
||||
message?: { model?: unknown };
|
||||
permissionMode?: unknown;
|
||||
origin?: { kind?: unknown };
|
||||
attachment?: { type?: unknown };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the lines of a transcript from the end and keeps what a resume needs.
|
||||
*
|
||||
* Claude Code records the permission mode on each prompt record of type `user`.
|
||||
* The `permission-mode` records of the CLI metadata block are not used: they
|
||||
* can disagree with the prompts, and the SDK does not write them.
|
||||
* The SDK writes no record when it leaves plan mode through ExitPlanMode.
|
||||
* It writes a `plan_mode_exit` attachment only, without the new mode. Thus a
|
||||
* `plan` mode before a later plan exit is not the current mode, and the scan
|
||||
* gives no mode.
|
||||
*
|
||||
* The mode search stops at the first mode record or at the last human prompt.
|
||||
* Each human prompt of a current Claude Code has the mode, so a prompt without
|
||||
* it means that the transcript does not record modes. A compaction does not
|
||||
* stop the search: the mode of the prompt before it is still current.
|
||||
*/
|
||||
class TailScan {
|
||||
readonly tail: ResumedTail = {};
|
||||
private modeDone = false;
|
||||
private planExited = false;
|
||||
|
||||
constructor(private readonly withModel: boolean) {}
|
||||
|
||||
/** Visit the previous line. Return true when the scan has all it needs. */
|
||||
visit(line: Buffer): boolean {
|
||||
let parsed: TranscriptRecord | null | undefined;
|
||||
// Most lines have no marker of interest. The check skips their JSON parse.
|
||||
const record = (marker: string): TranscriptRecord | undefined => {
|
||||
if (line.indexOf(marker) < 0) return undefined;
|
||||
if (parsed === undefined) parsed = parseMainThreadRecord(line);
|
||||
return parsed ?? undefined;
|
||||
};
|
||||
if (this.withModel && this.tail.model === undefined) {
|
||||
const entry = record('"assistant"');
|
||||
if (entry?.type === "assistant") this.tail.model = concreteModel(entry.message?.model);
|
||||
}
|
||||
if (!this.modeDone) this.visitForMode(record);
|
||||
return (!this.withModel || this.tail.model !== undefined) && this.modeDone;
|
||||
}
|
||||
|
||||
private visitForMode(record: (marker: string) => TranscriptRecord | undefined): void {
|
||||
const modeEntry = record('"permissionMode"');
|
||||
if (
|
||||
modeEntry?.type === "user" &&
|
||||
typeof modeEntry.permissionMode === "string" &&
|
||||
modeEntry.permissionMode.trim() !== ""
|
||||
) {
|
||||
const mode = modeEntry.permissionMode.trim();
|
||||
this.modeDone = true;
|
||||
if (!(this.planExited && mode === "plan")) this.tail.permissionMode = mode;
|
||||
return;
|
||||
}
|
||||
if (record('"plan_mode_exit"')?.attachment?.type === "plan_mode_exit") {
|
||||
this.planExited = true;
|
||||
return;
|
||||
}
|
||||
const human = record('"human"');
|
||||
if (human?.type === "user" && human.origin?.kind === "human") this.modeDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The parsed record of one line, or null for a subagent record or a bad line. */
|
||||
function parseMainThreadRecord(line: Buffer): TranscriptRecord | null {
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line.toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const record = entry as TranscriptRecord;
|
||||
return record.isSidechain === true ? null : record;
|
||||
}
|
||||
|
||||
51
src/serve.ts
Normal file
51
src/serve.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* Serves ACP on stdio.
|
||||
*
|
||||
* With `experimentalV2`, the connection goes through {@link acpProtocolRouter}.
|
||||
* Without it, the adapter serves ACP v1 alone and never routes by protocol
|
||||
* version, exactly as before.
|
||||
*/
|
||||
export function serveAcp(
|
||||
logger: Logger | undefined,
|
||||
options: { experimentalV2: boolean },
|
||||
): { closed: Promise<void>; dispose(): Promise<void> } {
|
||||
const output = nodeToWebWritable(process.stdout);
|
||||
const input = nodeToWebReadable(process.stdin);
|
||||
// The router opens the connection of the negotiated version only when it
|
||||
// reads `initialize`, so a client that leaves before then leaves no agent.
|
||||
let agent: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = options.experimentalV2
|
||||
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
|
||||
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
|
||||
return {
|
||||
closed,
|
||||
dispose: async () => {
|
||||
await agent?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a connection to the protocol version that the client negotiates in
|
||||
* `initialize`: the experimental draft ACP v2 for a client that requests
|
||||
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
|
||||
* the connection once the router has opened it.
|
||||
*/
|
||||
export function acpProtocolRouter(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentProtocolRouter {
|
||||
return v2
|
||||
.agentProtocolRouter()
|
||||
.withV1(v1AgentApp(logger, onAgent))
|
||||
.withV2(v2AgentApp(logger, onAgent));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 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()
|
||||
|
||||
@@ -99,7 +99,7 @@ const AIR_FAILURE_POLICY: Record<
|
||||
// credentials must be refreshed outside Claude. No `login` action, and not
|
||||
// sticky like `auth_required` (nothing clears at `auth_status`).
|
||||
access_denied: {
|
||||
category: "access",
|
||||
category: "service",
|
||||
actions: ["retry"],
|
||||
fallbackTitle: "Claude could not access the account or provider credentials.",
|
||||
},
|
||||
|
||||
130
src/session-index/activity.ts
Normal file
130
src/session-index/activity.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* The `activity` and `cost` of a session list row.
|
||||
*
|
||||
* A session that this connection runs reports the SDK's own state, and
|
||||
* `error` when it is idle and its last turn failed with an error (not a
|
||||
* cancel). Another session reports what the CLI registry and the transcript
|
||||
* tell:
|
||||
*
|
||||
* - no live process holds it: `idle`, or `error` when its last turn ended
|
||||
* with an API error;
|
||||
* - an interactive CLI that is not driven by an SDK holds it, and its registry
|
||||
* status is newer than the transcript: the registry status (an idle one is
|
||||
* `error` after such a turn);
|
||||
* - otherwise the transcript tail: a finished turn is `idle` (`error` after
|
||||
* an API error), an unfinished turn written in the last 10 minutes is
|
||||
* `running`, and anything else has no known state.
|
||||
*
|
||||
* A user interrupt is no error. A new turn clears it.
|
||||
*
|
||||
* The SDK-driven CLIs (`entrypoint: sdk-*`, which includes the ones this
|
||||
* adapter starts) keep `busy` in the registry long after a turn ends, so their
|
||||
* registry status is not used.
|
||||
*/
|
||||
|
||||
import type { LiveRecord } from "./live-registry.js";
|
||||
import type { TranscriptFacts } from "./transcript-scan.js";
|
||||
|
||||
export type ActivityState = "running" | "idle" | "requires_action" | "error";
|
||||
|
||||
export type SessionActivity = {
|
||||
state?: ActivityState;
|
||||
lastTurnEndedAt?: string;
|
||||
};
|
||||
|
||||
/** What this connection knows about a session it runs. */
|
||||
export type OwnSessionState = {
|
||||
state?: ActivityState;
|
||||
/** Epoch ms of the end of the last turn. */
|
||||
lastTurnEndedAt?: number;
|
||||
/** `total_cost_usd` of the last result. */
|
||||
costUsd?: number;
|
||||
/** The last turn failed with an error (not a cancel). */
|
||||
lastTurnFailed?: boolean;
|
||||
/** The query of the session failed here at this time (epoch ms) and no
|
||||
* longer runs: the session is reported as any other one, `error` until
|
||||
* its transcript shows a prompt or a turn end after it. */
|
||||
queryFailedAt?: number;
|
||||
};
|
||||
|
||||
const RECENT_UNFINISHED_TURN_MS = 10 * 60 * 1000;
|
||||
|
||||
function registryState(status: string | undefined): ActivityState | undefined {
|
||||
switch (status) {
|
||||
case "busy":
|
||||
case "shell":
|
||||
return "running";
|
||||
case "waiting":
|
||||
return "requires_action";
|
||||
case "idle":
|
||||
return "idle";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function usesRegistryStatus(record: LiveRecord, transcriptMtimeMs: number): boolean {
|
||||
return (
|
||||
record.kind === "interactive" &&
|
||||
!(record.entrypoint ?? "").startsWith("sdk-") &&
|
||||
record.statusUpdatedAt !== undefined &&
|
||||
record.statusUpdatedAt > transcriptMtimeMs
|
||||
);
|
||||
}
|
||||
|
||||
function iso(ms: number | undefined): string | undefined {
|
||||
return ms === undefined || !Number.isFinite(ms) ? undefined : new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
export function deriveActivity(input: {
|
||||
own?: OwnSessionState;
|
||||
live?: LiveRecord;
|
||||
facts: TranscriptFacts;
|
||||
transcriptMtimeMs: number;
|
||||
now: number;
|
||||
}): SessionActivity | undefined {
|
||||
const { live, facts, transcriptMtimeMs, now } = input;
|
||||
const failedAt = input.own?.queryFailedAt;
|
||||
const own = failedAt === undefined ? input.own : undefined;
|
||||
let state: ActivityState | undefined;
|
||||
// A finished turn that ended with an API error, or this connection's query
|
||||
// of the session failed and nothing was written since.
|
||||
const failed =
|
||||
(facts.turnState === "finished" && facts.lastTurnError === true) ||
|
||||
(failedAt !== undefined &&
|
||||
!((facts.lastPromptAt ?? 0) > failedAt) &&
|
||||
!((facts.lastTurnEndedAt ?? 0) > failedAt));
|
||||
if (own) {
|
||||
state = own.state ?? "idle";
|
||||
if (state === "idle" && own.lastTurnFailed) state = "error";
|
||||
} else if (!live) {
|
||||
state = failed ? "error" : "idle";
|
||||
} else if (usesRegistryStatus(live, transcriptMtimeMs)) {
|
||||
// A busy or waiting CLI wins; an idle one shows how its turn ended.
|
||||
state = registryState(live.status);
|
||||
if (state === "idle" && failed) state = "error";
|
||||
} else if (failed) {
|
||||
state = "error";
|
||||
} else if (facts.turnState === "finished") {
|
||||
state = "idle";
|
||||
} else if (
|
||||
facts.turnState === "unfinished" &&
|
||||
now - transcriptMtimeMs < RECENT_UNFINISHED_TURN_MS
|
||||
) {
|
||||
state = "running";
|
||||
}
|
||||
const lastTurnEndedAt = iso(own?.lastTurnEndedAt ?? facts.lastTurnEndedAt);
|
||||
if (state === undefined && lastTurnEndedAt === undefined) return undefined;
|
||||
return {
|
||||
...(state !== undefined && { state }),
|
||||
...(lastTurnEndedAt !== undefined && { lastTurnEndedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The cost of a session this connection runs: the `total_cost_usd` the SDK
|
||||
* gave in its last result. Only a positive amount; none for any other
|
||||
* session. */
|
||||
export function selectCost(own: OwnSessionState | undefined): number | undefined {
|
||||
const amount = own?.costUsd;
|
||||
return amount !== undefined && Number.isFinite(amount) && amount > 0 ? amount : undefined;
|
||||
}
|
||||
96
src/session-index/archive-title.ts
Normal file
96
src/session-index/archive-title.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The session archive, in the on-disk format of JetBrains AIR's own Claude
|
||||
* integration: a session is archived when its title carries the
|
||||
* `[archived] ` prefix, and archive, unarchive and rename append the title as
|
||||
* a pair of records to the transcript:
|
||||
*
|
||||
* ```
|
||||
* {"type":"custom-title","customTitle":"[archived] Title","sessionId":"…"}
|
||||
* {"type":"agent-name","agentName":"[archived] Title","sessionId":"…"}
|
||||
* ```
|
||||
*
|
||||
* The archive state follows AIR's native Claude provider (see
|
||||
* {@link effectiveTitle}): the last agent name decides, else the custom title
|
||||
* the SDK reports (`SDKSessionInfo.customTitle`). Archive writes the prefix
|
||||
* and the current title without it; unarchive writes the title without the
|
||||
* prefix. Both are cut to the CLI's 200-character title limit. The records
|
||||
* are metadata: they move neither `updatedAt` nor the list order. The
|
||||
* `claude --resume` picker shows the prefix.
|
||||
*/
|
||||
|
||||
/** The title prefix of an archived session. */
|
||||
export const ARCHIVED_TITLE_PREFIX = "[archived] ";
|
||||
|
||||
/** What the CLI keeps of a stored title: `title.slice(0, 200).trim()`. */
|
||||
const CLI_TITLE_LIMIT = 200;
|
||||
|
||||
/** A title once normalized starts with the prefix: leading space (what a
|
||||
* trim removes), the marker, a run of white space, then a title character.
|
||||
* The white space AIR collapses is ASCII white space, as in Java's `\s`. */
|
||||
const ARCHIVED_PATTERN = /^\s*\[archived\][ \t\n\v\f\r]+(?=[^ \t\n\v\f\r])/;
|
||||
|
||||
/** Every leading archive prefix at once. */
|
||||
const ARCHIVED_PREFIXES = /^(?:\s*\[archived\][ \t\n\v\f\r]+(?=[^ \t\n\v\f\r]))+/;
|
||||
|
||||
const WHITESPACE_RUN = /[ \t\n\v\f\r]+/g;
|
||||
|
||||
/** Whitespace collapsed to single spaces and trimmed, as AIR stores a title. */
|
||||
export function normalizeStoredTitle(title: string): string {
|
||||
return title.replace(WHITESPACE_RUN, " ").trim();
|
||||
}
|
||||
|
||||
/** Cut to what the CLI keeps of a title. */
|
||||
function capTitle(title: string): string {
|
||||
return title.length <= CLI_TITLE_LIMIT ? title : title.slice(0, CLI_TITLE_LIMIT).trim();
|
||||
}
|
||||
|
||||
/** Whether a session name (see {@link effectiveTitle}) marks its session
|
||||
* archived. */
|
||||
export function isArchivedTitle(title: string | undefined): boolean {
|
||||
return title !== undefined && ARCHIVED_PATTERN.test(title);
|
||||
}
|
||||
|
||||
/** The title shown for a stored title: without the archive prefix,
|
||||
* removed once, as AIR shows it. */
|
||||
export function visibleTitle(title: string): string {
|
||||
return title.replace(ARCHIVED_PATTERN, "");
|
||||
}
|
||||
|
||||
/** The title of a session that has none at all, as AIR names it. */
|
||||
export function defaultSessionTitle(sessionId: string): string {
|
||||
return `Session ${sessionId.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** The title that `title` is stored as in the given archive state: the
|
||||
* visible title, with the prefix when archived, normalized and cut to the
|
||||
* CLI limit. */
|
||||
export function storedTitle(title: string, archived: boolean, sessionId: string): string {
|
||||
// Every prefix: a title stored twice prefixed must not stay archived.
|
||||
const visible =
|
||||
normalizeStoredTitle(title.replace(ARCHIVED_PREFIXES, "")) || defaultSessionTitle(sessionId);
|
||||
return capTitle(archived ? ARCHIVED_TITLE_PREFIX + visible : visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* The title of a session and its archive state, by the rule of AIR's native
|
||||
* Claude provider: the session's name is its last agent name, else its custom
|
||||
* title, and the session is archived exactly when that name starts with the
|
||||
* prefix. The title is that agent name, else `summary`, the title the SDK
|
||||
* gives the session; it is stored, so it may carry the prefix.
|
||||
*/
|
||||
export function effectiveTitle(
|
||||
agentName: string | undefined,
|
||||
{ customTitle, summary }: { customTitle?: string; summary?: string },
|
||||
): { title: string | undefined; archived: boolean } {
|
||||
return { title: agentName ?? summary, archived: isArchivedTitle(agentName ?? customTitle) };
|
||||
}
|
||||
|
||||
/** The `custom-title` and `agent-name` records of `title`, one per line. */
|
||||
export function titleRecords(sessionId: string, title: string): string {
|
||||
return (
|
||||
JSON.stringify({ type: "custom-title", customTitle: title, sessionId }) +
|
||||
"\n" +
|
||||
JSON.stringify({ type: "agent-name", agentName: title, sessionId }) +
|
||||
"\n"
|
||||
);
|
||||
}
|
||||
459
src/session-index/connection.ts
Normal file
459
src/session-index/connection.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* The session index of one ACP connection: what `ClaudeAcpAgent` does for a
|
||||
* client that declared `sessionIndex`, and the archive of an AIR client
|
||||
* without it. The agent keeps thin call sites; everything else
|
||||
* lives here.
|
||||
*
|
||||
* Wire contract: docs/air-extensions.md, "Session index".
|
||||
*/
|
||||
|
||||
import {
|
||||
RequestError,
|
||||
type AgentApp,
|
||||
type DeleteSessionRequest,
|
||||
type DeleteSessionResponse,
|
||||
type InitializeRequest,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, Logger, Session } from "../acp-agent.js";
|
||||
import {
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
clientSupportsAirCapability,
|
||||
withAirMeta,
|
||||
} from "../air-extension.js";
|
||||
import type { OwnSessionState } from "./activity.js";
|
||||
import { isArchivedTitle, storedTitle, visibleTitle } from "./archive-title.js";
|
||||
import {
|
||||
LIST_CHANGES_METHOD,
|
||||
LIST_SUBSCRIBE_METHOD,
|
||||
LIST_UNSUBSCRIBE_METHOD,
|
||||
parseListSubscribeRequest,
|
||||
parseListUnsubscribeRequest,
|
||||
type ListSubscribeRequest,
|
||||
type ListSubscribeResponse,
|
||||
type ListUnsubscribeRequest,
|
||||
} from "./list-subscriptions.js";
|
||||
import {
|
||||
archiveInsteadOfDelete,
|
||||
archiveTo,
|
||||
renameTo,
|
||||
parseRenameSessionRequest,
|
||||
parseSessionIdRequest,
|
||||
SESSION_ARCHIVE_METHOD,
|
||||
SESSION_RENAME_METHOD,
|
||||
SESSION_UNARCHIVE_METHOD,
|
||||
SessionIndexService,
|
||||
type RenameSessionRequest,
|
||||
type RetitleOptions,
|
||||
type SessionIdRequest,
|
||||
type TitleChange,
|
||||
} from "./service.js";
|
||||
|
||||
/** What the session index keeps on each `Session`. */
|
||||
export type SessionIndexFields = {
|
||||
/** When the last turn ended (the last `session_state_changed: idle`), epoch
|
||||
* ms. Reported as `lastTurnEndedAt` in the session index. */
|
||||
lastTurnEndedAt?: number;
|
||||
/** The last turn failed with an error, not a cancel; a new turn clears
|
||||
* it. Reported as the `error` state in the session index. */
|
||||
lastTurnFailed?: boolean;
|
||||
/** `total_cost_usd` of the last result, reported as the cost of the session
|
||||
* in the session index. */
|
||||
lastTotalCostUsd?: number;
|
||||
/** The query resumed a stored conversation (load, resume, fork): the
|
||||
* session has a transcript, unlike a new one before its first turn. */
|
||||
resumedFromHistory?: boolean;
|
||||
};
|
||||
|
||||
/** Records the end of a turn at `session_state_changed`. */
|
||||
export function noteSessionState(
|
||||
session: Session,
|
||||
previous: Session["lastSessionState"],
|
||||
state: Session["lastSessionState"],
|
||||
): void {
|
||||
if (state === "idle" && previous !== "idle") session.lastTurnEndedAt = Date.now();
|
||||
if (state === "running" && previous !== "running") session.lastTurnFailed = false;
|
||||
}
|
||||
|
||||
/** What the connection needs from the agent. */
|
||||
export type SessionIndexHost = {
|
||||
/** Read when used: the agent may still be under construction. */
|
||||
agent: {
|
||||
readonly sessions: { [key: string]: Session };
|
||||
readonly client: AcpClient;
|
||||
readonly logger: Logger;
|
||||
};
|
||||
/** Whether the client is an AIR client. */
|
||||
isAirClient(): boolean;
|
||||
/** Cancels the running turns of a session loaded here and sends its CLI
|
||||
* the interrupt, without awaiting the reply. */
|
||||
interruptSession(sessionId: string): Promise<void>;
|
||||
/** Closes a session loaded here, as `session/close` does. */
|
||||
teardownSession(sessionId: string): Promise<void>;
|
||||
};
|
||||
|
||||
type EmptyResponse = Record<string, never>;
|
||||
|
||||
export class SessionIndexConnection {
|
||||
/**
|
||||
* The session index of a client that declared `sessionIndex`. Undefined for
|
||||
* every other client, which keeps the session list, delete and the watchers
|
||||
* exactly as before.
|
||||
*/
|
||||
service?: SessionIndexService;
|
||||
/** The archive behind `session/delete` of an AIR client without
|
||||
* `sessionIndex`: the same writes, without a list or a watcher. */
|
||||
private archiver?: SessionIndexService;
|
||||
/** When the query of a session failed here, by session id. */
|
||||
private readonly failedQueries = new Map<string, number>();
|
||||
|
||||
constructor(private readonly host: SessionIndexHost) {}
|
||||
|
||||
/** Sets the index up for the client of `initialize`. ACP v2 does not route
|
||||
* the session index methods yet. */
|
||||
negotiate(request: InitializeRequest, options: { v2: boolean }): void {
|
||||
this.service?.dispose();
|
||||
this.service = undefined;
|
||||
if (
|
||||
!options.v2 &&
|
||||
this.host.isAirClient() &&
|
||||
clientSupportsAirCapability(request.clientCapabilities, AIR_SESSION_INDEX_CAPABILITY)
|
||||
) {
|
||||
this.service = new SessionIndexService({
|
||||
ownSessionState: (sessionId) => this.ownSessionState(sessionId),
|
||||
notifyListChanges: (changes) =>
|
||||
this.host.agent.client.extNotification(LIST_CHANGES_METHOD, changes),
|
||||
logError: (message, error) =>
|
||||
this.host.agent.logger.error(`[session-index] ${message}:`, error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A stored title as the client shows it: an AIR client gets the title
|
||||
* of an archived session without the archive prefix, as in its list;
|
||||
* every other client the title as stored. */
|
||||
clientTitle(title: string): string {
|
||||
return this.host.isAirClient() && isArchivedTitle(title) ? visibleTitle(title) : title;
|
||||
}
|
||||
|
||||
/** The AIR capabilities the agent advertises for the index: the index
|
||||
* itself, archive, rename and the list subscription, all only to a client
|
||||
* that declared `sessionIndex`. */
|
||||
capabilities(): string[] {
|
||||
return this.service
|
||||
? [
|
||||
AIR_SESSION_INDEX_CAPABILITY,
|
||||
AIR_SESSION_ARCHIVE_CAPABILITY,
|
||||
AIR_SESSION_RENAME_CAPABILITY,
|
||||
AIR_SESSION_LIST_SUBSCRIBE_CAPABILITY,
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.service?.dispose();
|
||||
}
|
||||
|
||||
/** The query of a session that ran here failed (not a cancel): its row
|
||||
* shows `error` until its transcript changes. */
|
||||
onQueryFailed(sessionId: string): void {
|
||||
this.failedQueries.set(sessionId, Date.now());
|
||||
if (this.failedQueries.size > 1024) {
|
||||
this.failedQueries.delete(this.failedQueries.keys().next().value as string);
|
||||
}
|
||||
this.service?.ownSessionChanged(sessionId);
|
||||
}
|
||||
|
||||
/** A turn of a session started here: an earlier failed query of it no
|
||||
* longer counts. */
|
||||
onTurnStarted(sessionId: string): void {
|
||||
this.failedQueries.delete(sessionId);
|
||||
}
|
||||
|
||||
onTeardown(sessionId: string): void {
|
||||
// Its row now shows what the registry and the transcript tell.
|
||||
this.service?.ownSessionChanged(sessionId);
|
||||
}
|
||||
|
||||
/** A session this connection runs changed its state, turn end or cost:
|
||||
* the list subscriptions send it at once. */
|
||||
onOwnSessionChanged(sessionId: string): void {
|
||||
this.service?.ownSessionChanged(sessionId);
|
||||
}
|
||||
|
||||
/** `_session/list/subscribe`. */
|
||||
subscribeList(params: ListSubscribeRequest): Promise<ListSubscribeResponse> {
|
||||
return this.requireService(LIST_SUBSCRIBE_METHOD).subscribeList(params.cwd);
|
||||
}
|
||||
|
||||
/** `_session/list/unsubscribe`. Idempotent. */
|
||||
unsubscribeList(params: ListUnsubscribeRequest): EmptyResponse {
|
||||
this.requireService(LIST_UNSUBSCRIBE_METHOD).unsubscribeList(params.subscriptionId);
|
||||
return {};
|
||||
}
|
||||
|
||||
/** `session/list` of a `sessionIndex` client; undefined for another one. */
|
||||
list(params: ListSessionsRequest): Promise<ListSessionsResponse> | undefined {
|
||||
return this.service?.list(params, (sessionId) => this.ownSessionState(sessionId));
|
||||
}
|
||||
|
||||
/** An AIR client archives with session/delete (see {@link deleteSession}):
|
||||
* its archived sessions (an archived custom title) stay hidden from the
|
||||
* old list, as when the delete removed them. */
|
||||
hideArchived<T extends { customTitle?: string }>(sessions: T[]): T[] {
|
||||
if (!this.host.isAirClient()) return sessions;
|
||||
return sessions.filter((session) => !isArchivedTitle(session.customTitle));
|
||||
}
|
||||
|
||||
/** `_session/rename`: names a session; no generated title replaces it.
|
||||
* An archived session stays archived. */
|
||||
async rename(request: RenameSessionRequest): Promise<EmptyResponse> {
|
||||
const index = this.requireService(SESSION_RENAME_METHOD);
|
||||
const { sessionId } = request;
|
||||
// As the CLI keeps it, and never with the archive prefix: a rename does
|
||||
// not change the archive state.
|
||||
const title = storedTitle(request.title, false, sessionId);
|
||||
// The title as stored: an archived one is cut with its prefix.
|
||||
const shown = (stored: string | undefined) =>
|
||||
stored === undefined ? title : visibleTitle(stored);
|
||||
await this.retitle(index, sessionId, renameTo(title), shown, {
|
||||
mayBeUnwritten: true,
|
||||
sidecar: "always",
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
/** `_session/archive`: hides a session from the default list and stops
|
||||
* it. Idempotent; the session need not be loaded. A session loaded here
|
||||
* is interrupted, retitled through its CLI while that still runs, then
|
||||
* closed as `session/close` closes it. */
|
||||
async archive(params: SessionIdRequest): Promise<EmptyResponse> {
|
||||
const index = this.requireService(SESSION_ARCHIVE_METHOD);
|
||||
const { sessionId } = params;
|
||||
const loaded = this.host.agent.sessions[sessionId] !== undefined;
|
||||
// No turn goes on after the archive: the interrupt goes to the CLI ahead
|
||||
// of the rename.
|
||||
if (loaded) await this.host.interruptSession(sessionId);
|
||||
await this.retitle(index, sessionId, archiveTo(true), undefined, {
|
||||
mayBeUnwritten: this.isUnwrittenSession(sessionId),
|
||||
sidecar: "existing",
|
||||
});
|
||||
if (loaded) {
|
||||
await this.host.teardownSession(sessionId);
|
||||
// The closed CLI may have lost or overwritten the agent name it was
|
||||
// still to write: the transcripts are archived without it once more,
|
||||
// as a best effort, since the CLI took the archived title.
|
||||
try {
|
||||
await index.retitle(sessionId, archiveTo(true), {
|
||||
mayBeUnwritten: true,
|
||||
sidecar: "existing",
|
||||
});
|
||||
} catch (error) {
|
||||
this.host.agent.logger.error(
|
||||
`[session-index] archiving ${sessionId} after its close failed:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.reportArchived(sessionId, true, loaded);
|
||||
return {};
|
||||
}
|
||||
|
||||
/** `_session/unarchive`. Idempotent; it does not load the session. */
|
||||
async unarchive(params: SessionIdRequest): Promise<EmptyResponse> {
|
||||
const index = this.requireService(SESSION_UNARCHIVE_METHOD);
|
||||
const { sessionId } = params;
|
||||
await this.retitle(index, sessionId, archiveTo(false), undefined, {
|
||||
mayBeUnwritten: this.isUnwrittenSession(sessionId),
|
||||
sidecar: "existing",
|
||||
});
|
||||
await this.reportArchived(sessionId, false, this.host.agent.sessions[sessionId] !== undefined);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retitles a session (see {@link SessionIndexService.retitle}). A session
|
||||
* this connection runs is titled through its CLI (`rename_session`). A
|
||||
* loaded session's title state takes the change as a client title, so no
|
||||
* generated title replaces it; `publish` is the title it then publishes.
|
||||
*/
|
||||
private async retitle(
|
||||
index: SessionIndexService,
|
||||
sessionId: string,
|
||||
change: TitleChange,
|
||||
publish: Parameters<Session["titles"]["setExplicitTitle"]>[0],
|
||||
options: Pick<RetitleOptions, "mayBeUnwritten" | "sidecar">,
|
||||
): Promise<void> {
|
||||
const session = this.host.agent.sessions[sessionId];
|
||||
// Decided once a title generation in flight has ended: the query may
|
||||
// have closed meanwhile.
|
||||
const persist = () => {
|
||||
const query = session?.query as
|
||||
| (Query & { renameSession?: (title: string, sessionId?: string) => Promise<void> })
|
||||
| undefined;
|
||||
// A closed session's CLI is gone here: only a running query titles it.
|
||||
const running = session !== undefined && !session.queryClosed;
|
||||
if (running && typeof query?.renameSession !== "function") {
|
||||
// Its CLI holds the title and would write it back over ours.
|
||||
throw RequestError.invalidRequest(
|
||||
{ sessionId },
|
||||
"The Claude Code CLI of this session cannot change its title while it runs.",
|
||||
);
|
||||
}
|
||||
const live = running
|
||||
? {
|
||||
cwd: session.cwd,
|
||||
rename: (title: string) => query!.renameSession!(title, sessionId),
|
||||
stored: () => session.titles.storedTitle,
|
||||
remember: (title: string) => session.titles.rememberStoredTitle(title),
|
||||
shown: session.titles.shownTitle,
|
||||
}
|
||||
: undefined;
|
||||
return index.retitle(sessionId, change, {
|
||||
...options,
|
||||
// Without its CLI, a session needs its transcript.
|
||||
mayBeUnwritten: live !== undefined && options.mayBeUnwritten,
|
||||
live,
|
||||
});
|
||||
};
|
||||
if (session) {
|
||||
await session.titles.setExplicitTitle(publish, persist);
|
||||
} else {
|
||||
await persist();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/delete`:
|
||||
* - A `sessionIndex` client deletes for real: every transcript, whoever
|
||||
* else has the session open, as the CLI does.
|
||||
* - Another AIR client uses delete to mark a session done, and may reopen
|
||||
* it later: the adapter archives it instead (as `_session/archive` does,
|
||||
* after the CLI closed), so the transcript survives.
|
||||
* - Every other client: the SDK delete, as before.
|
||||
*/
|
||||
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
||||
const loaded = this.host.agent.sessions[params.sessionId] !== undefined;
|
||||
// Tear down any active in-memory state first so the on-disk file isn't
|
||||
// recreated by an outstanding query writing to it.
|
||||
if (loaded) {
|
||||
await this.host.teardownSession(params.sessionId);
|
||||
}
|
||||
if (this.service) {
|
||||
await this.service.delete(params.sessionId, loaded);
|
||||
} else if (this.host.isAirClient()) {
|
||||
await archiveInsteadOfDelete(params.sessionId, this.airArchiver());
|
||||
} else {
|
||||
await sdkDeleteSession(params.sessionId);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private airArchiver(): SessionIndexService {
|
||||
this.archiver ??= new SessionIndexService({
|
||||
logError: (message, error) =>
|
||||
this.host.agent.logger.error(`[session-index] ${message}:`, error),
|
||||
});
|
||||
return this.archiver;
|
||||
}
|
||||
|
||||
private requireService(method: string): SessionIndexService {
|
||||
if (!this.service) throw RequestError.methodNotFound(method);
|
||||
return this.service;
|
||||
}
|
||||
|
||||
/** What the session index reports for a session this connection runs. */
|
||||
private ownSessionState(sessionId: string): OwnSessionState | undefined {
|
||||
const session = this.host.agent.sessions[sessionId];
|
||||
if (!session || session.queryClosed) {
|
||||
const failedAt = this.failedQueries.get(sessionId);
|
||||
return failedAt === undefined ? undefined : { queryFailedAt: failedAt };
|
||||
}
|
||||
return {
|
||||
state: session.lastSessionState,
|
||||
lastTurnEndedAt: session.lastTurnEndedAt,
|
||||
// A session loaded again after its query failed here shows the failure
|
||||
// until a turn of it starts.
|
||||
lastTurnFailed: session.lastTurnFailed || this.failedQueries.has(sessionId),
|
||||
costUsd: session.lastTotalCostUsd,
|
||||
};
|
||||
}
|
||||
|
||||
/** A new session this connection runs that may have no transcript yet: it
|
||||
* resumed no stored conversation and no turn of it has ended. A session
|
||||
* whose query ended, or that has history, needs its transcript. */
|
||||
private isUnwrittenSession(sessionId: string): boolean {
|
||||
const session = this.host.agent.sessions[sessionId];
|
||||
return (
|
||||
session !== undefined &&
|
||||
!session.queryClosed &&
|
||||
!session.resumedFromHistory &&
|
||||
session.lastTurnEndedAt === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Tells the client the archive state of a session that was loaded on
|
||||
* this connection (`session_info_update` with
|
||||
* `_meta.jetbrains.air.archived`, RFD #2161's `archived` field), also
|
||||
* once the archive closed it. */
|
||||
private async reportArchived(
|
||||
sessionId: string,
|
||||
archived: boolean,
|
||||
loaded: boolean,
|
||||
): Promise<void> {
|
||||
if (!loaded) return;
|
||||
await this.host.agent.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: withAirMeta(undefined, "archived", archived),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The agent methods behind the session index extension methods. */
|
||||
type SessionIndexMethods = {
|
||||
subscribeSessionList(params: ListSubscribeRequest): Promise<ListSubscribeResponse>;
|
||||
unsubscribeSessionList(params: ListUnsubscribeRequest): Promise<EmptyResponse>;
|
||||
renameSessionTitle(params: RenameSessionRequest): Promise<EmptyResponse>;
|
||||
archiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
|
||||
unarchiveSession(params: SessionIdRequest): Promise<EmptyResponse>;
|
||||
};
|
||||
|
||||
/** Routes `_session/list/subscribe`, `_session/list/unsubscribe`,
|
||||
* `_session/rename`, `_session/archive` and `_session/unarchive`. */
|
||||
export function onSessionIndexRequests(app: AgentApp, agent: () => SessionIndexMethods): AgentApp {
|
||||
return app
|
||||
.onRequest<ListSubscribeRequest, ListSubscribeResponse>(
|
||||
LIST_SUBSCRIBE_METHOD,
|
||||
{ parse: parseListSubscribeRequest },
|
||||
(ctx) => agent().subscribeSessionList(ctx.params),
|
||||
)
|
||||
.onRequest<ListUnsubscribeRequest, EmptyResponse>(
|
||||
LIST_UNSUBSCRIBE_METHOD,
|
||||
{ parse: parseListUnsubscribeRequest },
|
||||
(ctx) => agent().unsubscribeSessionList(ctx.params),
|
||||
)
|
||||
.onRequest<RenameSessionRequest, EmptyResponse>(
|
||||
SESSION_RENAME_METHOD,
|
||||
{ parse: parseRenameSessionRequest },
|
||||
(ctx) => agent().renameSessionTitle(ctx.params),
|
||||
)
|
||||
.onRequest<SessionIdRequest, EmptyResponse>(
|
||||
SESSION_ARCHIVE_METHOD,
|
||||
{ parse: parseSessionIdRequest },
|
||||
(ctx) => agent().archiveSession(ctx.params),
|
||||
)
|
||||
.onRequest<SessionIdRequest, EmptyResponse>(
|
||||
SESSION_UNARCHIVE_METHOD,
|
||||
{ parse: parseSessionIdRequest },
|
||||
(ctx) => agent().unarchiveSession(ctx.params),
|
||||
);
|
||||
}
|
||||
129
src/session-index/dir-listing.ts
Normal file
129
src/session-index/dir-listing.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* File system reads of the session index that stay cheap with thousands of
|
||||
* transcripts: bounded batches of stats, and directory listings that are
|
||||
* read again only when the directory changed.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
|
||||
/** Stats in flight at once, across all callers of {@link statFiles}. */
|
||||
const STAT_CONCURRENCY = 512;
|
||||
let statsInFlight = 0;
|
||||
/** Queued stats, a FIFO read from `waitingHead` (no `shift()`: the queue
|
||||
* can hold tens of thousands). */
|
||||
let waitingStats: (() => void)[] = [];
|
||||
let waitingHead = 0;
|
||||
|
||||
function nextWaitingStat(): (() => void) | undefined {
|
||||
if (waitingHead >= waitingStats.length) return undefined;
|
||||
const next = waitingStats[waitingHead++];
|
||||
if (waitingHead > 1024 && waitingHead * 2 > waitingStats.length) {
|
||||
waitingStats = waitingStats.slice(waitingHead);
|
||||
waitingHead = 0;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Runs `stat` once fewer than {@link STAT_CONCURRENCY} are in flight. */
|
||||
function statBounded(filePath: string, done: (stats: fs.Stats | undefined) => void): void {
|
||||
const run = () => {
|
||||
statsInFlight++;
|
||||
fs.stat(filePath, (error, stats) => {
|
||||
statsInFlight--;
|
||||
nextWaitingStat()?.();
|
||||
done(error ? undefined : stats);
|
||||
});
|
||||
};
|
||||
if (statsInFlight < STAT_CONCURRENCY) run();
|
||||
else waitingStats.push(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stats of `filePaths` (undefined for one that is gone). A project
|
||||
* directory holds thousands of transcripts: the callback API costs about
|
||||
* half of one promise per file, and a bounded number in flight, shared by
|
||||
* all callers, keeps each turn of the event loop short (dispatching
|
||||
* thousands at once holds it for 100 ms and more). Unlike synchronous
|
||||
* stats, a file system stall never holds the event loop.
|
||||
*/
|
||||
export function statFiles(filePaths: readonly string[]): Promise<(fs.Stats | undefined)[]> {
|
||||
const result: (fs.Stats | undefined)[] = new Array(filePaths.length);
|
||||
if (filePaths.length === 0) return Promise.resolve(result);
|
||||
return new Promise((resolve) => {
|
||||
let left = filePaths.length;
|
||||
filePaths.forEach((filePath, i) => {
|
||||
statBounded(filePath, (stats) => {
|
||||
result[i] = stats;
|
||||
if (--left === 0) resolve(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type Listing = {
|
||||
mtimeMs: number;
|
||||
/** A directory replaced by another one with the same mtime differs here. */
|
||||
ino: number;
|
||||
/** When the listing was read. */
|
||||
readAt: number;
|
||||
names: string[];
|
||||
};
|
||||
|
||||
/** A listing read this soon after the directory changed is not trusted:
|
||||
* some file systems keep the mtime in whole seconds, so another change in
|
||||
* the same second would leave it unchanged. */
|
||||
const MTIME_RESOLUTION_MS = 2000;
|
||||
|
||||
/**
|
||||
* Directory listings, read again only when the directory's mtime changed
|
||||
* (an entry was added, removed or renamed; an append to a file does not
|
||||
* change it). A project directory with thousands of transcripts is then one
|
||||
* stat instead of one `readdir` per list or mutation.
|
||||
*/
|
||||
export class DirListings {
|
||||
private readonly listings = new Map<string, Listing>();
|
||||
|
||||
/** The entry names of `dir`, empty when it cannot be read. */
|
||||
async names(dir: string): Promise<string[]> {
|
||||
return (await this.listing(dir, await statOrUndefined(dir))).names;
|
||||
}
|
||||
|
||||
/** The entry names of each of `dirs`, one bounded batch of stats. */
|
||||
async namesOfAll(dirs: readonly string[]): Promise<string[][]> {
|
||||
const stats = await statFiles(dirs);
|
||||
return Promise.all(dirs.map(async (dir, i) => (await this.listing(dir, stats[i])).names));
|
||||
}
|
||||
|
||||
private async listing(dir: string, stats: fs.Stats | undefined): Promise<Listing> {
|
||||
if (!stats?.isDirectory()) {
|
||||
this.listings.delete(dir);
|
||||
return { mtimeMs: 0, ino: 0, readAt: 0, names: [] };
|
||||
}
|
||||
const cached = this.listings.get(dir);
|
||||
if (
|
||||
cached &&
|
||||
cached.mtimeMs === stats.mtimeMs &&
|
||||
cached.ino === stats.ino &&
|
||||
cached.readAt - cached.mtimeMs > MTIME_RESOLUTION_MS
|
||||
) {
|
||||
return cached;
|
||||
}
|
||||
const readAt = Date.now();
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.promises.readdir(dir);
|
||||
} catch {
|
||||
return { mtimeMs: 0, ino: 0, readAt, names: [] };
|
||||
}
|
||||
const listing: Listing = { mtimeMs: stats.mtimeMs, ino: stats.ino, readAt, names };
|
||||
this.listings.set(dir, listing);
|
||||
return listing;
|
||||
}
|
||||
}
|
||||
|
||||
function statOrUndefined(target: string): Promise<fs.Stats | undefined> {
|
||||
return fs.promises.stat(target).then(
|
||||
(stats) => stats,
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
65
src/session-index/explicit-title.ts
Normal file
65
src/session-index/explicit-title.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The client-set title of a session (`_session/rename`), as `SessionTitles`
|
||||
* guards it against generated titles: once a client named the session, or
|
||||
* while a rename is in flight, no generated title is adopted, and a rename
|
||||
* waits for a generation in flight, whose persisted title would otherwise
|
||||
* land after it.
|
||||
*/
|
||||
export class ExplicitTitle {
|
||||
/** Set once a client named the session. Released by {@link reset}. */
|
||||
private named = false;
|
||||
/** Renames in flight. */
|
||||
private pending = 0;
|
||||
/** Renames started so far, to tell that one ran between two reads. */
|
||||
private started = 0;
|
||||
/** The title generation in flight. */
|
||||
private generation?: Promise<void>;
|
||||
|
||||
/** Whether a generated title must not be adopted. */
|
||||
get active(): boolean {
|
||||
return this.named || this.pending > 0;
|
||||
}
|
||||
|
||||
/** Whether a client named the session and no rename is in flight: a title
|
||||
* stored since by someone else (a `/rename`) may still be adopted. */
|
||||
get settledByClient(): boolean {
|
||||
return this.named && this.pending === 0;
|
||||
}
|
||||
|
||||
/** Changes with every rename that starts. */
|
||||
get renames(): number {
|
||||
return this.started;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.named = false;
|
||||
}
|
||||
|
||||
/** Tracks a title generation until it ends. */
|
||||
track(generation: Promise<void>): void {
|
||||
this.generation = generation;
|
||||
void generation.finally(() => {
|
||||
if (this.generation === generation) this.generation = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** Persists a client title after the generation in flight. `persist`
|
||||
* tells whether the session is named now. When it fails or names nothing
|
||||
* (an archive of an archived session), and no other rename named the
|
||||
* session, `restore` puts the title state back, so a later turn may still
|
||||
* generate a title. */
|
||||
async apply(persist: () => Promise<boolean>, restore: () => void): Promise<void> {
|
||||
this.pending++;
|
||||
this.started++;
|
||||
try {
|
||||
await this.generation;
|
||||
if (await persist()) this.named = true;
|
||||
else if (!this.named && this.pending === 1) restore();
|
||||
} catch (error) {
|
||||
if (!this.named && this.pending === 1) restore();
|
||||
throw error;
|
||||
} finally {
|
||||
this.pending--;
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/session-index/first-prompt.ts
Normal file
172
src/session-index/first-prompt.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* The first prompt of a transcript, extracted the way the SDK does for a
|
||||
* session title (its `rf`, `ti` and `jP` in `@anthropic-ai/claude-agent-sdk`).
|
||||
* Part of the SDK's title rule that the session index applies itself (see
|
||||
* `sdkTitles`).
|
||||
*
|
||||
* - Only main-chain user records count: no tool result, meta or compact
|
||||
* summary record.
|
||||
* - Each text (the string content, or every text block) is expanded from its
|
||||
* `<pasted_content>` blocks and flattened to one line.
|
||||
* - A slash command (`<command-name>/init</command-name>`) is only a fallback
|
||||
* while a real prompt may follow; `<bash-input>x</bash-input>` is `! x`;
|
||||
* a text that starts with another tag or with an interrupt marker is
|
||||
* skipped.
|
||||
* - A prompt longer than 200 characters is cut and gets an ellipsis.
|
||||
* - Without any text, an image or a document prompt is `Image` or `Document`.
|
||||
*/
|
||||
|
||||
const MAX_PROMPT_LENGTH = 200;
|
||||
const COMMAND_NAME = /<command-name>(.*?)<\/command-name>/;
|
||||
const BASH_INPUT = /<bash-input>([\s\S]*?)<\/bash-input>/;
|
||||
const SKIPPED_TEXT = /^(?:\s*<[a-z][\w-]*[\s>]|\[Request interrupted by user[^\]]*\])/;
|
||||
const PASTED_OPEN = '<pasted_content id="';
|
||||
|
||||
type Entry = Record<string, unknown>;
|
||||
|
||||
function isPastedId(value: string): boolean {
|
||||
return /^[0-9a-f]{4}$/.test(value);
|
||||
}
|
||||
|
||||
/** `text` with each `<pasted_content id="xxxx">` block replaced by its body. */
|
||||
function expandPastedContent(text: string): string {
|
||||
const parts: string[] = [];
|
||||
let done = 0;
|
||||
let from = 0;
|
||||
let expanded = false;
|
||||
for (;;) {
|
||||
const open = text.indexOf(PASTED_OPEN, from);
|
||||
if (open === -1) break;
|
||||
const idStart = open + PASTED_OPEN.length;
|
||||
const id = text.slice(idStart, idStart + 4);
|
||||
if (!isPastedId(id) || !text.startsWith('">\n', idStart + 4)) {
|
||||
from = idStart;
|
||||
continue;
|
||||
}
|
||||
const bodyStart = idStart + 4 + 3;
|
||||
const close = `</pasted_content id="${id}">`;
|
||||
const closeAt = text.indexOf(`\n${close}`, bodyStart - 1) + 1;
|
||||
if (closeAt === 0) break;
|
||||
let start = open;
|
||||
for (let i = 0; i < 2 && start > done && text[start - 1] === "\n"; i++) start--;
|
||||
if (start > done) parts.push(text.slice(done, start));
|
||||
done = closeAt + close.length;
|
||||
for (let i = 0; i < 2 && text[done] === "\n"; i++) done++;
|
||||
parts.push(text.slice(bodyStart, closeAt - 1));
|
||||
expanded = true;
|
||||
from = done;
|
||||
}
|
||||
if (!expanded) return text;
|
||||
if (done < text.length) parts.push(text.slice(done));
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/** At most `length` UTF-16 units, without a split surrogate pair. */
|
||||
function cut(text: string, length: number): string {
|
||||
if (text.length <= length) return text;
|
||||
const head = text.slice(0, length);
|
||||
const last = head.charCodeAt(length - 1);
|
||||
return last >= 0xd800 && last <= 0xdbff ? head.slice(0, -1) : head;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt of one user record, or undefined when it has none. A slash
|
||||
* command is recorded in `state.commandFallback` instead.
|
||||
*/
|
||||
export function promptOf(entry: Entry, state: { commandFallback: string }): string | undefined {
|
||||
if (entry.type !== "user") return undefined;
|
||||
if (entry.isMeta === true || entry.isCompactSummary === true) return undefined;
|
||||
const message = entry.message as { content?: unknown } | undefined;
|
||||
if (!message) return undefined;
|
||||
const content = message.content;
|
||||
const texts: string[] = [];
|
||||
if (typeof content === "string") {
|
||||
texts.push(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const { type, text } = block as { type?: unknown; text?: unknown };
|
||||
if (type === "tool_result") return undefined;
|
||||
if (type === "text" && typeof text === "string") texts.push(text);
|
||||
}
|
||||
}
|
||||
for (const raw of texts) {
|
||||
let text = expandPastedContent(raw).replaceAll("\n", " ").trim();
|
||||
if (!text) continue;
|
||||
const command = COMMAND_NAME.exec(text);
|
||||
if (command) {
|
||||
if (!state.commandFallback) state.commandFallback = command[1]!;
|
||||
continue;
|
||||
}
|
||||
const bash = BASH_INPUT.exec(text);
|
||||
if (bash) return `! ${bash[1]!.trim()}`;
|
||||
if (SKIPPED_TEXT.test(text)) continue;
|
||||
if (text.length > MAX_PROMPT_LENGTH) text = `${cut(text, MAX_PROMPT_LENGTH).trim()}…`;
|
||||
return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a user record is a prompt the user sent: one with prompt text
|
||||
* ({@link promptOf}), or one with an image or a document, which the SDK
|
||||
* titles `Image` or `Document` when it has no prompt text. A tool result, a meta or compact summary
|
||||
* record, an interrupt and a slash command are none.
|
||||
*/
|
||||
export function isUserPrompt(entry: Entry): boolean {
|
||||
if (promptOf(entry, { commandFallback: "" }) !== undefined) return true;
|
||||
if (entry.type !== "user" || entry.isMeta === true || entry.isCompactSummary === true) {
|
||||
return false;
|
||||
}
|
||||
const content = (entry.message as { content?: unknown } | undefined)?.content;
|
||||
if (!Array.isArray(content)) return false;
|
||||
let media = false;
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const { type } = block as { type?: unknown };
|
||||
if (type === "tool_result") return false;
|
||||
if (type === "image" || type === "document") media = true;
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
function isUserLine(line: string): boolean {
|
||||
return line.includes('"type":"user"') || line.includes('"type": "user"');
|
||||
}
|
||||
|
||||
/** The first prompt of the head, else the first slash command, else "". */
|
||||
export function firstPrompt(head: string): string {
|
||||
const state = { commandFallback: "" };
|
||||
for (const line of head.split("\n")) {
|
||||
if (!isUserLine(line)) continue;
|
||||
if (line.includes('"tool_result"')) continue;
|
||||
if (line.includes('"isMeta":true') || line.includes('"isMeta": true')) continue;
|
||||
if (line.includes('"isCompactSummary":true') || line.includes('"isCompactSummary": true')) {
|
||||
continue;
|
||||
}
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const prompt = promptOf(entry as Entry, state);
|
||||
if (prompt !== undefined) return prompt;
|
||||
}
|
||||
return state.commandFallback;
|
||||
}
|
||||
|
||||
/** `Image` or `Document` for a first user record that holds one, else "". */
|
||||
export function mediaPrompt(head: string): string {
|
||||
for (const line of head.split("\n")) {
|
||||
if (!isUserLine(line)) continue;
|
||||
if (line.includes('"tool_result"')) continue;
|
||||
if (line.includes('"isMeta":true') || line.includes('"isMeta": true')) continue;
|
||||
if (line.includes('"type":"image"') || line.includes('"type": "image"')) return "Image";
|
||||
if (line.includes('"type":"document"') || line.includes('"type": "document"')) {
|
||||
return "Document";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
1267
src/session-index/list-subscriptions.ts
Normal file
1267
src/session-index/list-subscriptions.ts
Normal file
File diff suppressed because it is too large
Load Diff
288
src/session-index/live-registry.ts
Normal file
288
src/session-index/live-registry.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* The live Claude Code processes on this machine, from the CLI's registry.
|
||||
*
|
||||
* Each running CLI writes `<config>/sessions/<pid>.json` with its session id,
|
||||
* kind, entrypoint and status. The format is not documented; the reader keeps
|
||||
* to the fields it needs and applies the liveness rules of Claude Desktop:
|
||||
*
|
||||
* - only `<pid>.json` files are read; `.key` files and other names are never
|
||||
* opened, and no file is ever written or deleted;
|
||||
* - a record whose JSON does not parse is read once more, then skipped;
|
||||
* - a record of another pid domain (another machine or pid namespace) is
|
||||
* skipped;
|
||||
* - the process must be alive: `kill(pid, 0)` succeeds or fails with `EPERM`;
|
||||
* - a recorded `procStart` must match the start time of the live process,
|
||||
* so a reused pid does not count;
|
||||
* - a record older than 24 hours counts only when its `procStart` matches.
|
||||
*
|
||||
* A snapshot is cached for {@link SNAPSHOT_TTL_MS}. The records tell only the
|
||||
* `state` of a session list row; no operation is refused for them.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { claudeConfigDir } from "../paths.js";
|
||||
import { errorCode } from "./project-dirs.js";
|
||||
|
||||
const SNAPSHOT_TTL_MS = 5_000;
|
||||
const STALE_RECORD_MS = 24 * 60 * 60 * 1000;
|
||||
const PS_TIMEOUT_MS = 1_000;
|
||||
const RECORD_FILE_PATTERN = /^(\d+)\.json$/;
|
||||
/** How long a process start time read with `ps` serves a reader that asks
|
||||
* for recent ones (see {@link LiveSessionRegistry.readFiles}). */
|
||||
const RECENT_START_MS = 10_000;
|
||||
|
||||
export type LiveRecord = {
|
||||
pid: number;
|
||||
sessionId: string;
|
||||
cwd?: string;
|
||||
kind?: string;
|
||||
entrypoint?: string;
|
||||
status?: string;
|
||||
statusUpdatedAt?: number;
|
||||
};
|
||||
|
||||
export type LiveSnapshot = ReadonlyMap<string, LiveRecord>;
|
||||
|
||||
export type LiveRegistryDeps = {
|
||||
/** The registry directory. Defaults to `<config>/sessions`. */
|
||||
dir?: () => string;
|
||||
now?: () => number;
|
||||
/** Whether the process is alive. Defaults to `kill(pid, 0)`. */
|
||||
isAlive?: (pid: number) => boolean;
|
||||
/** The `ps -o lstart=` start time of each live pid. */
|
||||
processStarts?: (pids: number[]) => Promise<Map<number, string>>;
|
||||
/** This machine's pid domain, as the CLI computes it. */
|
||||
pidDomain?: () => Promise<string>;
|
||||
};
|
||||
|
||||
/** `<config>/sessions`, where each CLI process registers itself. */
|
||||
export function liveRegistryDir(): string {
|
||||
return path.join(claudeConfigDir(), "sessions");
|
||||
}
|
||||
|
||||
function defaultIsAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return errorCode(error) === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
/** The start times of `pids`, from one `ps` call, in the format the CLI
|
||||
* records (`LC_ALL=C TZ=UTC ps -o lstart=`). */
|
||||
function defaultProcessStarts(pids: number[]): Promise<Map<number, string>> {
|
||||
const result = new Map<number, string>();
|
||||
if (pids.length === 0 || process.platform === "win32") return Promise.resolve(result);
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"ps",
|
||||
["-o", "pid=,lstart=", "-p", pids.join(",")],
|
||||
{ timeout: PS_TIMEOUT_MS, env: { ...process.env, LC_ALL: "C", TZ: "UTC" } },
|
||||
(_error, stdout) => {
|
||||
// `ps` exits 1 when one of the pids is gone; its output still holds
|
||||
// the live ones.
|
||||
for (const line of String(stdout ?? "").split("\n")) {
|
||||
const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
||||
if (match) result.set(Number(match[1]), normalizeStart(match[2]!));
|
||||
}
|
||||
resolve(result);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStart(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
let cachedPidDomain: Promise<string> | undefined;
|
||||
|
||||
/** The pid domain the CLI records: the platform on macOS, the machine id and
|
||||
* pid namespace on Linux, the host name on Windows. */
|
||||
function defaultPidDomain(): Promise<string> {
|
||||
cachedPidDomain ??= (async () => {
|
||||
if (process.platform === "win32") return `win32:${os.hostname().toLowerCase()}`;
|
||||
if (process.platform !== "linux") return process.platform;
|
||||
const [machineId, pidNamespace] = await Promise.all([
|
||||
fs.readFile("/etc/machine-id", "utf8").then(
|
||||
(value) => value.trim(),
|
||||
() => "",
|
||||
),
|
||||
fs.readlink("/proc/self/ns/pid").catch(() => ""),
|
||||
]);
|
||||
return `linux:${machineId}:${pidNamespace}`;
|
||||
})();
|
||||
return cachedPidDomain;
|
||||
}
|
||||
|
||||
type RawRecord = Record<string, unknown>;
|
||||
|
||||
async function readRecord(file: string): Promise<RawRecord | undefined> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const value: unknown = JSON.parse(text);
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as RawRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
// The CLI may be rewriting the file; read it once more.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export class LiveSessionRegistry {
|
||||
private cached?: { at: number; snapshot: Promise<LiveSnapshot> };
|
||||
private readonly dir: () => string;
|
||||
private readonly now: () => number;
|
||||
private readonly isAlive: (pid: number) => boolean;
|
||||
private readonly processStarts: (pids: number[]) => Promise<Map<number, string>>;
|
||||
private readonly pidDomain: () => Promise<string>;
|
||||
/** The start times `ps` gave, by pid, and when. */
|
||||
private readonly starts = new Map<number, { start: string; at: number }>();
|
||||
|
||||
constructor(deps: LiveRegistryDeps = {}) {
|
||||
this.dir = deps.dir ?? liveRegistryDir;
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.isAlive = deps.isAlive ?? defaultIsAlive;
|
||||
this.processStarts = deps.processStarts ?? defaultProcessStarts;
|
||||
this.pidDomain = deps.pidDomain ?? defaultPidDomain;
|
||||
}
|
||||
|
||||
/** The live records by session id. Cached for 5 s. */
|
||||
snapshot(): Promise<LiveSnapshot> {
|
||||
const now = this.now();
|
||||
if (this.cached && now - this.cached.at < SNAPSHOT_TTL_MS) {
|
||||
return this.cached.snapshot;
|
||||
}
|
||||
const snapshot = this.read()
|
||||
.then(bySession)
|
||||
.catch(() => new Map<string, LiveRecord>());
|
||||
this.cached = { at: now, snapshot };
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Every live record, several per session when several processes hold it. */
|
||||
private async read(): Promise<LiveRecord[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(this.dir());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return [...(await this.readFiles(names)).values()].filter(
|
||||
(record): record is LiveRecord => record !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live record of each of the registry files `names`, by name:
|
||||
* undefined for a file that is gone or holds no live record. A name that
|
||||
* is not `<pid>.json` (a `.key` file, a temporary file) is never opened and
|
||||
* not in the result. The rules of {@link read} apply. `recentStarts` takes
|
||||
* a start time that `ps` gave in the last 10 s when it matches the record,
|
||||
* so a record that changes often does not run `ps` each time.
|
||||
*/
|
||||
async readFiles(
|
||||
names: readonly string[],
|
||||
options: { recentStarts?: boolean } = {},
|
||||
): Promise<Map<string, LiveRecord | undefined>> {
|
||||
const dir = this.dir();
|
||||
const result = new Map<string, LiveRecord | undefined>();
|
||||
const domain = await this.pidDomain();
|
||||
const candidates: { name: string; pid: number; raw: RawRecord }[] = [];
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const match = RECORD_FILE_PATTERN.exec(name);
|
||||
if (!match) return;
|
||||
result.set(name, undefined);
|
||||
const pid = Number(match[1]);
|
||||
const raw = await readRecord(path.join(dir, name));
|
||||
if (!raw || raw.pid !== pid || typeof raw.sessionId !== "string") return;
|
||||
if (typeof raw.pidDomain === "string" && raw.pidDomain !== domain) return;
|
||||
if (!this.isAlive(pid)) return;
|
||||
candidates.push({ name, pid, raw });
|
||||
}),
|
||||
);
|
||||
const now = this.now();
|
||||
const starts = new Map<number, string>();
|
||||
const needStarts: number[] = [];
|
||||
for (const { pid, raw } of candidates) {
|
||||
if (typeof raw.procStart !== "string") continue;
|
||||
const recent = this.starts.get(pid);
|
||||
// A start that differs may be a new process under the same pid.
|
||||
if (
|
||||
options.recentStarts &&
|
||||
recent &&
|
||||
now - recent.at < RECENT_START_MS &&
|
||||
recent.start === normalizeStart(raw.procStart)
|
||||
) {
|
||||
starts.set(pid, recent.start);
|
||||
} else {
|
||||
needStarts.push(pid);
|
||||
}
|
||||
}
|
||||
if (needStarts.length > 0) {
|
||||
for (const [pid, start] of await this.processStarts(needStarts)) {
|
||||
starts.set(pid, start);
|
||||
this.starts.set(pid, { start, at: now });
|
||||
}
|
||||
}
|
||||
for (const [pid, { at }] of this.starts) {
|
||||
if (now - at >= RECENT_START_MS) this.starts.delete(pid);
|
||||
}
|
||||
for (const { name, pid, raw } of candidates) {
|
||||
const recorded =
|
||||
typeof raw.procStart === "string" ? normalizeStart(raw.procStart) : undefined;
|
||||
const actual = starts.get(pid);
|
||||
if (recorded !== undefined && actual !== undefined && recorded !== actual) continue;
|
||||
const procStartMatches = recorded !== undefined && actual === recorded;
|
||||
const lastSeen = Math.max(
|
||||
finiteNumber(raw.updatedAt) ?? 0,
|
||||
finiteNumber(raw.statusUpdatedAt) ?? 0,
|
||||
finiteNumber(raw.startedAt) ?? 0,
|
||||
);
|
||||
if (now - lastSeen > STALE_RECORD_MS && !procStartMatches) continue;
|
||||
result.set(name, {
|
||||
pid,
|
||||
sessionId: raw.sessionId as string,
|
||||
cwd: optionalString(raw.cwd),
|
||||
kind: optionalString(raw.kind),
|
||||
entrypoint: optionalString(raw.entrypoint),
|
||||
status: optionalString(raw.status),
|
||||
statusUpdatedAt: finiteNumber(raw.statusUpdatedAt),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** One record per session: the one with the newest status. */
|
||||
export function bySession(records: Iterable<LiveRecord>): LiveSnapshot {
|
||||
const result = new Map<string, LiveRecord>();
|
||||
for (const record of records) {
|
||||
const previous = result.get(record.sessionId);
|
||||
if (!previous || (record.statusUpdatedAt ?? 0) > (previous.statusUpdatedAt ?? 0)) {
|
||||
result.set(record.sessionId, record);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
133
src/session-index/project-dirs.ts
Normal file
133
src/session-index/project-dirs.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Where Claude Code keeps the transcripts of a working directory.
|
||||
*
|
||||
* The CLI stores the transcripts of `cwd` in `<config>/projects/<encoded cwd>/`.
|
||||
* The encoding replaces every character that is not ASCII alphanumeric with `-`.
|
||||
* A name longer than {@link MAX_SANITIZED_LENGTH} is cut and gets a hash
|
||||
* suffix; the CLI (Bun) and the SDK (Node) compute different hashes, so a long
|
||||
* name is matched by its prefix, as the SDK does.
|
||||
*
|
||||
* The encoding is lossy (`/a/b-c` and `/a/b/c` share a directory), so a
|
||||
* directory name is never decoded: a path belongs to a directory only when
|
||||
* the path encodes to the name.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { claudeConfigDir } from "../paths.js";
|
||||
|
||||
/** The SDK cuts an encoded path at this length and appends a hash. */
|
||||
const MAX_SANITIZED_LENGTH = 200;
|
||||
|
||||
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Whether `value` is a Claude session id (a UUID). Only such ids name a
|
||||
* transcript, and only such ids are safe to use as a file name. */
|
||||
export function isSessionId(value: unknown): value is string {
|
||||
return typeof value === "string" && SESSION_ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/** `<config>/projects`, normalized like the SDK does. */
|
||||
export function projectsRoot(): string {
|
||||
return path.join(claudeConfigDir(), "projects").normalize("NFC");
|
||||
}
|
||||
|
||||
function sanitize(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
/** The SDK's 32-bit string hash for long project names. */
|
||||
function hashCode(value: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/** The project directory name of `cwd`, exactly as the SDK computes it. */
|
||||
export function encodeProjectPath(cwd: string): string {
|
||||
const sanitized = sanitize(normalizePath(cwd));
|
||||
if (sanitized.length <= MAX_SANITIZED_LENGTH) return sanitized;
|
||||
return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${Math.abs(hashCode(normalizePath(cwd))).toString(36)}`;
|
||||
}
|
||||
|
||||
/** NFC on darwin, as the SDK normalizes paths there. */
|
||||
export function normalizePath(value: string): string {
|
||||
return process.platform === "darwin" ? value.normalize("NFC") : value;
|
||||
}
|
||||
|
||||
function sameName(a: string, b: string): boolean {
|
||||
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `dirName` is the project directory of `cwd`: the exact encoding, or,
|
||||
* for a long path, a directory with the same cut prefix and any hash suffix.
|
||||
*/
|
||||
export function projectDirMatches(dirName: string, cwd: string): boolean {
|
||||
if (sameName(dirName, encodeProjectPath(cwd))) return true;
|
||||
const sanitized = sanitize(normalizePath(cwd));
|
||||
if (sanitized.length <= MAX_SANITIZED_LENGTH) return false;
|
||||
const prefix = `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-`;
|
||||
const name = process.platform === "win32" ? dirName.toLowerCase() : dirName;
|
||||
return name.startsWith(process.platform === "win32" ? prefix.toLowerCase() : prefix);
|
||||
}
|
||||
|
||||
/** Whether a transcript's `cwd` belongs to `projectPath`, compared the way
|
||||
* the SDK does: the full sanitized forms, ignoring case where the file
|
||||
* system does. */
|
||||
export function sameProjectPath(cwd: string, projectPath: string): boolean {
|
||||
const a = sanitize(normalizePath(cwd));
|
||||
const b = sanitize(normalizePath(projectPath));
|
||||
return process.platform === "win32" || process.platform === "darwin"
|
||||
? a.toLowerCase() === b.toLowerCase()
|
||||
: a === b;
|
||||
}
|
||||
|
||||
/** Whether `dirName` is the exact project directory of `cwd`, as the SDK
|
||||
* finds it by `dir`: the exact encoding, on macOS in any case (the file
|
||||
* system ignores it). */
|
||||
export function isExactProjectDir(dirName: string, cwd: string): boolean {
|
||||
const exact = encodeProjectPath(cwd);
|
||||
if (sameName(dirName, exact)) return true;
|
||||
return process.platform === "darwin" && dirName.toLowerCase() === exact.toLowerCase();
|
||||
}
|
||||
|
||||
/** The real path of `cwd`, like the SDK resolves it before encoding. Falls
|
||||
* back to `cwd` when it does not exist. */
|
||||
export async function canonicalPath(cwd: string): Promise<string> {
|
||||
try {
|
||||
return normalizePath(await fs.realpath(cwd));
|
||||
} catch {
|
||||
return normalizePath(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
/** The names of the project directories of `cwd` among `rootEntries`. */
|
||||
export function projectDirsOf(cwd: string, rootEntries: readonly string[]): string[] {
|
||||
const exact = encodeProjectPath(cwd);
|
||||
const sanitizedLength = sanitize(normalizePath(cwd)).length;
|
||||
if (sanitizedLength <= MAX_SANITIZED_LENGTH) {
|
||||
return rootEntries.filter((name) => sameName(name, exact));
|
||||
}
|
||||
return rootEntries.filter((name) => projectDirMatches(name, cwd));
|
||||
}
|
||||
|
||||
/** `value` and each of its ancestors, nearest first. */
|
||||
export function pathAndAncestors(value: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = value;
|
||||
for (;;) {
|
||||
result.push(current);
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return result;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/** The `code` of a Node.js system error, if it has one. */
|
||||
export function errorCode(error: unknown): string | undefined {
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
710
src/session-index/service.ts
Normal file
710
src/session-index/service.ts
Normal file
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* The `sessionIndex` AIR extension: the session list, rename, archive and
|
||||
* delete of a client that declared the capability.
|
||||
*
|
||||
* Wire contract: docs/air-extensions.md, "Session index". Everything here is
|
||||
* reached only for a `sessionIndex` client, except the archive that an AIR
|
||||
* client without the capability gets in place of a delete (see
|
||||
* `ClaudeAcpAgent.deleteSession`).
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
RequestError,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { airExtensionMeta } from "../air-extension.js";
|
||||
import { sanitizeTitle } from "../session-titles.js";
|
||||
import type { OwnSessionState } from "./activity.js";
|
||||
import {
|
||||
effectiveTitle,
|
||||
isArchivedTitle,
|
||||
storedTitle,
|
||||
titleRecords,
|
||||
visibleTitle,
|
||||
} from "./archive-title.js";
|
||||
import {
|
||||
ListSubscriptions,
|
||||
type ListChanges,
|
||||
type ListSubscribeResponse,
|
||||
type ListSubscriptionDeps,
|
||||
} from "./list-subscriptions.js";
|
||||
import { LiveSessionRegistry } from "./live-registry.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
errorCode,
|
||||
isExactProjectDir,
|
||||
isSessionId,
|
||||
projectDirMatches,
|
||||
sameProjectPath,
|
||||
} from "./project-dirs.js";
|
||||
import {
|
||||
readHeadTail,
|
||||
readHeadTailOf,
|
||||
sdkTitles,
|
||||
tailCustomTitle,
|
||||
transcriptAgentName,
|
||||
transcriptProjectCwd,
|
||||
} from "./transcript-scan.js";
|
||||
import {
|
||||
DEFAULT_LIST_LIMIT,
|
||||
MAX_LIST_LIMIT,
|
||||
ARCHIVED_FILTERS,
|
||||
readSidecarTitle,
|
||||
SessionIndex,
|
||||
type ArchivedFilter,
|
||||
type ListCursor,
|
||||
} from "./session-index.js";
|
||||
import { sessionInfoOf } from "./session-info.js";
|
||||
|
||||
export const SESSION_RENAME_METHOD = "_session/rename";
|
||||
export const SESSION_ARCHIVE_METHOD = "_session/archive";
|
||||
export const SESSION_UNARCHIVE_METHOD = "_session/unarchive";
|
||||
|
||||
/** The JSON-RPC code of an unknown session (ACP `ResourceNotFound`). */
|
||||
const RESOURCE_NOT_FOUND = -32002;
|
||||
/** The cursor format. A cursor of another version is rejected. */
|
||||
const CURSOR_VERSION = 5;
|
||||
|
||||
export type SessionIdRequest = { sessionId: string };
|
||||
export type RenameSessionRequest = { sessionId: string; title: string };
|
||||
|
||||
export function sessionNotFound(sessionId: string): RequestError {
|
||||
return new RequestError(RESOURCE_NOT_FOUND, `Session not found: ${sessionId}`, { sessionId });
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function parseSessionIdRequest(value: unknown): SessionIdRequest {
|
||||
const sessionId = asRecord(value).sessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId.trim()) {
|
||||
throw RequestError.invalidParams(undefined, "params require a non-empty sessionId");
|
||||
}
|
||||
return { sessionId: sessionId.trim() };
|
||||
}
|
||||
|
||||
export function parseRenameSessionRequest(value: unknown): RenameSessionRequest {
|
||||
const { sessionId } = parseSessionIdRequest(value);
|
||||
const raw = asRecord(value).title;
|
||||
const title = typeof raw === "string" ? sanitizeTitle(raw) : "";
|
||||
if (!title) throw RequestError.invalidParams(undefined, "title must be a non-empty string");
|
||||
return { sessionId, title };
|
||||
}
|
||||
|
||||
type ListOptions = { limit: number; archived: ArchivedFilter; includeWorktrees: boolean };
|
||||
|
||||
/** What a cursor is bound to: the request values that select the rows. */
|
||||
export type ListScope = {
|
||||
cwd: string | null;
|
||||
archived: ArchivedFilter;
|
||||
includeWorktrees: boolean;
|
||||
};
|
||||
|
||||
function archivedFilter(list: Record<string, unknown>): ArchivedFilter {
|
||||
// Omitted or null is `unarchived`.
|
||||
const value = list.archived ?? "unarchived";
|
||||
if (!ARCHIVED_FILTERS.includes(value as ArchivedFilter)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ archived: value },
|
||||
'`_meta.jetbrains.air.list.archived` must be "unarchived", "archived" or "all"',
|
||||
);
|
||||
}
|
||||
return value as ArchivedFilter;
|
||||
}
|
||||
|
||||
function optionalBoolean(list: Record<string, unknown>, key: string): boolean {
|
||||
// Omitted or null is false.
|
||||
const value = list[key] ?? false;
|
||||
if (typeof value !== "boolean") {
|
||||
throw RequestError.invalidParams(
|
||||
{ [key]: value },
|
||||
`\`_meta.jetbrains.air.list.${key}\` must be a boolean`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `_meta.jetbrains.air.list` of a list request. */
|
||||
export function parseListOptions(meta: unknown): ListOptions {
|
||||
const list = asRecord(airExtensionMeta(meta)?.list);
|
||||
// Omitted or null is the default; an integer of at least 1 is clamped;
|
||||
// anything else is invalid.
|
||||
const raw = list.limit ?? DEFAULT_LIST_LIMIT;
|
||||
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
|
||||
throw RequestError.invalidParams(
|
||||
{ limit: raw },
|
||||
"`_meta.jetbrains.air.list.limit` must be an integer of at least 1",
|
||||
);
|
||||
}
|
||||
return {
|
||||
limit: Math.min(MAX_LIST_LIMIT, raw),
|
||||
archived: archivedFilter(list),
|
||||
includeWorktrees: optionalBoolean(list, "includeWorktrees"),
|
||||
};
|
||||
}
|
||||
|
||||
type CursorPayload = {
|
||||
v: number;
|
||||
u: number;
|
||||
id: string;
|
||||
cwd: string | null;
|
||||
archived: ArchivedFilter;
|
||||
worktrees: boolean;
|
||||
};
|
||||
|
||||
export function encodeListCursor(cursor: ListCursor, scope: ListScope): string {
|
||||
const payload: CursorPayload = {
|
||||
v: CURSOR_VERSION,
|
||||
u: cursor.orderAtMs,
|
||||
id: cursor.sessionId,
|
||||
cwd: scope.cwd,
|
||||
archived: scope.archived,
|
||||
worktrees: scope.includeWorktrees,
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
}
|
||||
|
||||
/** The position a cursor names. A cursor of another cwd or filter, or one
|
||||
* this adapter did not issue, is rejected. */
|
||||
export function decodeListCursor(cursor: string, scope: ListScope): ListCursor {
|
||||
let payload: Partial<CursorPayload> | undefined;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as CursorPayload;
|
||||
} catch {
|
||||
payload = undefined;
|
||||
}
|
||||
if (
|
||||
!payload ||
|
||||
payload.v !== CURSOR_VERSION ||
|
||||
typeof payload.u !== "number" ||
|
||||
typeof payload.id !== "string"
|
||||
) {
|
||||
throw RequestError.invalidParams(undefined, `Unknown session/list cursor: ${cursor}`);
|
||||
}
|
||||
if (
|
||||
payload.cwd !== scope.cwd ||
|
||||
payload.archived !== scope.archived ||
|
||||
payload.worktrees !== scope.includeWorktrees
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"The session/list cursor belongs to another cwd or filter",
|
||||
);
|
||||
}
|
||||
return { orderAtMs: payload.u, sessionId: payload.id };
|
||||
}
|
||||
|
||||
/** `<projectDir>/<sessionId>/custom-title.json` of a transcript. */
|
||||
function sidecarPath(transcriptPath: string): string {
|
||||
const sessionId = path.basename(transcriptPath, ".jsonl");
|
||||
return path.join(path.dirname(transcriptPath), sessionId, "custom-title.json");
|
||||
}
|
||||
|
||||
/** Writes `<projectDir>/<sessionId>/custom-title.json` the way the CLI's
|
||||
* `/rename` does: file 0600 in a 0700 directory, replaced atomically. The
|
||||
* temporary file has a random name of the CLI's `custom-title.json.tmp.*`
|
||||
* pattern, and only a temporary file this call created is removed. */
|
||||
export async function writeCustomTitleSidecar(transcriptPath: string, title: string) {
|
||||
const target = sidecarPath(transcriptPath);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${target}.tmp.${randomBytes(8).toString("hex")}`;
|
||||
const handle = await fs.open(temporary, "wx", 0o600);
|
||||
try {
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify({ customTitle: title }));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fs.rename(temporary, target);
|
||||
} catch (error) {
|
||||
await fs.rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The last two non-empty lines of a file, from its last 64 KB, and
|
||||
* whether the file ends with a newline. */
|
||||
async function lastLines(
|
||||
filePath: string,
|
||||
): Promise<{ lines: string[]; last: string; endsWithNewline: boolean }> {
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
const length = Math.min(size, 64 * 1024);
|
||||
const buffer = Buffer.alloc(length);
|
||||
await handle.read(buffer, 0, length, size - length);
|
||||
const text = buffer.toString("utf8");
|
||||
const lines = text.split("\n").filter((line) => line.trim());
|
||||
return {
|
||||
lines: lines.slice(-2),
|
||||
last: lines.at(-1) ?? "",
|
||||
endsWithNewline: text.endsWith("\n"),
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends the title records of `title` (see archive-title.ts) to a
|
||||
* transcript whose last two records are not those already, whoever else
|
||||
* has the session open, as the CLI's `/rename` does. A torn last line is
|
||||
* closed first. */
|
||||
async function ensureTitleRecords(filePath: string, sessionId: string, title: string) {
|
||||
const records = titleRecords(sessionId, title);
|
||||
const { lines, last, endsWithNewline } = await lastLines(filePath);
|
||||
if (lines.join("\n") + "\n" === records) return;
|
||||
const complete = endsWithNewline || last === "";
|
||||
await fs.appendFile(filePath, `${complete ? "" : "\n"}${records}`);
|
||||
}
|
||||
|
||||
/** Appends the `agent-name` record of `title` when the copy's agent name
|
||||
* is another one, so its archive state follows `title`. Reads and appends
|
||||
* through one handle. */
|
||||
async function ensureAgentName(filePath: string, sessionId: string, title: string) {
|
||||
const handle = await fs.open(filePath, "a+");
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
const headTail = await readHeadTailOf(handle, size);
|
||||
const agentName = transcriptAgentName(headTail);
|
||||
if (agentName === undefined || agentName === title) return;
|
||||
const complete = size === 0 || headTail.tail.endsWith("\n");
|
||||
const record = JSON.stringify({ type: "agent-name", agentName: title, sessionId });
|
||||
await handle.appendFile(`${complete ? "" : "\n"}${record}\n`);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The title of one transcript copy as the list reads it (see
|
||||
* {@link effectiveTitle}): its agent name, else the title the SDK reports.
|
||||
* It carries the archive prefix only when the copy is archived: a generated
|
||||
* title never archives a session. */
|
||||
async function copyTitle(filePath: string, sessionId: string): Promise<string | undefined> {
|
||||
const { size } = await fs.stat(filePath);
|
||||
const headTail = await readHeadTail(filePath, size);
|
||||
const sidecar =
|
||||
tailCustomTitle(headTail) === undefined
|
||||
? await readSidecarTitle(filePath, sessionId)
|
||||
: undefined;
|
||||
const { title, archived } = effectiveTitle(
|
||||
transcriptAgentName(headTail),
|
||||
sdkTitles(headTail, sidecar),
|
||||
);
|
||||
return title !== undefined && !archived && isArchivedTitle(title) ? visibleTitle(title) : title;
|
||||
}
|
||||
|
||||
/** Rewrites the sidecar of a copy whose title is `current` when the
|
||||
* sidecar says the other archive state (a sidecar write that failed after
|
||||
* the records were appended). */
|
||||
async function alignSidecar(
|
||||
transcript: string,
|
||||
current: string | undefined,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (current === undefined) return;
|
||||
const sidecar = await readSidecarTitle(transcript, sessionId);
|
||||
if (sidecar === undefined || isArchivedTitle(sidecar) === isArchivedTitle(current)) return;
|
||||
await writeCustomTitleSidecar(
|
||||
transcript,
|
||||
storedTitle(current, isArchivedTitle(current), sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
/** The title a change stores for a copy whose title is `current`
|
||||
* (undefined: none yet), or undefined to leave the copy as it is. */
|
||||
export type TitleChange = (current: string | undefined, sessionId: string) => string | undefined;
|
||||
|
||||
/** `_session/rename`: the new title, with the archive prefix on a copy
|
||||
* that is archived, so a rename keeps the archive state. */
|
||||
export function renameTo(title: string): TitleChange {
|
||||
return (current, sessionId) =>
|
||||
isArchivedTitle(current) ? storedTitle(title, true, sessionId) : title;
|
||||
}
|
||||
|
||||
/** `_session/archive` and `_session/unarchive`: the current title with or
|
||||
* without the archive prefix; a copy already in that state is left alone. */
|
||||
export function archiveTo(archived: boolean): TitleChange {
|
||||
return (current, sessionId) =>
|
||||
isArchivedTitle(current) === archived
|
||||
? undefined
|
||||
: storedTitle(current ?? "", archived, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `transcript` lies in the project directory of one of `paths`, the
|
||||
* one the CLI of that cwd writes: the exact encoding, or, for a long path
|
||||
* whose name the CLI hashes differently, a directory with the cut prefix
|
||||
* whose transcript belongs to the path. Another long path that shares the
|
||||
* prefix does not count.
|
||||
*/
|
||||
async function isTranscriptOf(transcript: string, paths: readonly string[]): Promise<boolean> {
|
||||
const dirName = path.basename(path.dirname(transcript));
|
||||
if (paths.some((cwd) => isExactProjectDir(dirName, cwd))) return true;
|
||||
if (!paths.some((cwd) => projectDirMatches(dirName, cwd))) return false;
|
||||
try {
|
||||
const { size } = await fs.stat(transcript);
|
||||
const cwd = transcriptProjectCwd(await readHeadTail(transcript, size));
|
||||
return cwd !== undefined && paths.some((projectPath) => sameProjectPath(cwd, projectPath));
|
||||
} catch {
|
||||
// Unreadable: not known to be the CLI's own copy.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSdkNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && /^Session \S+ not found in /.test(error.message);
|
||||
}
|
||||
|
||||
async function exists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The error the SDK `deleteSession` gives for a session id it rejects or
|
||||
* does not find, which a client without `sessionIndex` got before. */
|
||||
function sdkDeleteError(sessionId: string): Error {
|
||||
return new Error(
|
||||
isSessionId(sessionId)
|
||||
? `Session ${sessionId} not found in any project directory`
|
||||
: `Invalid sessionId: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/delete` of an AIR client without `sessionIndex`, which uses delete
|
||||
* to mark a session done: archives it instead, so the transcript survives. A
|
||||
* session without a transcript fails as the SDK delete did.
|
||||
*/
|
||||
export async function archiveInsteadOfDelete(
|
||||
sessionId: string,
|
||||
service: SessionIndexService,
|
||||
): Promise<void> {
|
||||
if (!isSessionId(sessionId)) throw sdkDeleteError(sessionId);
|
||||
// The SDK delete skips empty transcripts, so they do not count.
|
||||
const found = await service.index.findTranscripts(sessionId);
|
||||
if (found.length === 0) throw sdkDeleteError(sessionId);
|
||||
await service.retitle(sessionId, archiveTo(true), { sidecar: "existing" });
|
||||
}
|
||||
|
||||
/** How {@link SessionIndexService.retitle} reaches the session. */
|
||||
export type RetitleOptions = {
|
||||
/** The CLI of the session runs here: it stores the title of its own
|
||||
* transcript (`rename_session`). `stored`: the title last stored through
|
||||
* it, which it holds; `shown`: the title last shown for a session that has
|
||||
* neither that nor a transcript. */
|
||||
live?: {
|
||||
cwd: string;
|
||||
rename: (title: string) => Promise<void>;
|
||||
/** Read once the session's earlier title changes are done. */
|
||||
stored: () => string | undefined;
|
||||
/** Records a title the CLI took, before the next change starts. */
|
||||
remember: (title: string) => void;
|
||||
shown?: string;
|
||||
};
|
||||
/** A session without a transcript is not unknown: a new session this
|
||||
* connection runs, or a live one being renamed. */
|
||||
mayBeUnwritten?: boolean;
|
||||
/** `always` writes the CLI title sidecar next to every titled copy;
|
||||
* `existing` rewrites only one that is there already. */
|
||||
sidecar: "always" | "existing";
|
||||
};
|
||||
|
||||
export type SessionIndexDeps = {
|
||||
deleteSession?: (sessionId: string) => Promise<void>;
|
||||
registry?: LiveSessionRegistry;
|
||||
now?: () => number;
|
||||
/** What this connection knows of a session it runs. */
|
||||
ownSessionState?: (sessionId: string) => OwnSessionState | undefined;
|
||||
/** Sends `_session/list/changes`. */
|
||||
notifyListChanges?: (changes: ListChanges) => Promise<void>;
|
||||
/** The timing of the list subscriptions, for tests. */
|
||||
listSubscriptionTiming?: Pick<
|
||||
ListSubscriptionDeps,
|
||||
"debounceMs" | "maxWaitMs" | "rescanMs" | "minSessionIntervalMs"
|
||||
>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
export class SessionIndexService {
|
||||
readonly index: SessionIndex;
|
||||
readonly registry: LiveSessionRegistry;
|
||||
private subscriptions?: ListSubscriptions;
|
||||
/** Set for good by {@link dispose}: no subscription is made after it. */
|
||||
private disposed = false;
|
||||
/** The mutation in flight per session, which the next one waits for. */
|
||||
private readonly mutations = new Map<string, Promise<unknown>>();
|
||||
private readonly now: () => number;
|
||||
private readonly deleteSession: (sessionId: string) => Promise<void>;
|
||||
|
||||
constructor(private readonly deps: SessionIndexDeps) {
|
||||
this.index = new SessionIndex();
|
||||
this.registry = deps.registry ?? new LiveSessionRegistry();
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.deleteSession = deps.deleteSession ?? ((id) => sdkDeleteSession(id));
|
||||
}
|
||||
|
||||
/** `session/list` of a `sessionIndex` client. `own` reports the sessions
|
||||
* that this connection runs. */
|
||||
async list(
|
||||
params: ListSessionsRequest,
|
||||
own: (sessionId: string) => OwnSessionState | undefined,
|
||||
): Promise<ListSessionsResponse> {
|
||||
const { limit, archived, includeWorktrees } = parseListOptions(params._meta);
|
||||
const cwd = params.cwd ?? null;
|
||||
const scope: ListScope = { cwd, archived, includeWorktrees };
|
||||
const after =
|
||||
params.cursor === null || params.cursor === undefined
|
||||
? undefined
|
||||
: decodeListCursor(params.cursor, scope);
|
||||
// The live registry is read while the transcripts are.
|
||||
const livePromise = this.registry.snapshot();
|
||||
const { rows, hasMore } = await this.index.list({
|
||||
cwd,
|
||||
includeWorktrees,
|
||||
limit,
|
||||
archived,
|
||||
after,
|
||||
});
|
||||
const live = await livePromise;
|
||||
const now = this.now();
|
||||
const sessions = rows.map((row) =>
|
||||
sessionInfoOf(row, own(row.sessionId), live.get(row.sessionId), now),
|
||||
);
|
||||
const last = rows[rows.length - 1];
|
||||
return hasMore && last ? { sessions, nextCursor: encodeListCursor(last, scope) } : { sessions };
|
||||
}
|
||||
|
||||
/** Runs `mutation` after the previous mutation of the session ended. */
|
||||
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(mutation);
|
||||
const settled = next.catch(() => undefined);
|
||||
this.mutations.set(sessionId, settled);
|
||||
void settled.then(() => {
|
||||
if (this.mutations.get(sessionId) === settled) this.mutations.delete(sessionId);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retitles every transcript of a session with `change`: rename, archive
|
||||
* and unarchive. Returns the title stored through the CLI that runs here,
|
||||
* else the title of the first copy that got one; undefined when nothing
|
||||
* was stored.
|
||||
*
|
||||
* - The CLI runs here (`live`): it titles its own transcript and sidecar
|
||||
* (`rename_session`), the only writer that cannot be overtaken by the
|
||||
* title the CLI holds. The other copies get the records and the sidecar
|
||||
* here, as a best effort once the CLI has the title: a failure is
|
||||
* logged, not returned.
|
||||
* - Otherwise every transcript gets the records (and the sidecar, see
|
||||
* {@link RetitleOptions.sidecar}).
|
||||
*
|
||||
* As the CLI's `/rename`, the records are written whether or not another
|
||||
* process has the session open.
|
||||
*
|
||||
* A session without a transcript is unknown (`-32002`), unless
|
||||
* {@link RetitleOptions.mayBeUnwritten}.
|
||||
*/
|
||||
async retitle(
|
||||
sessionId: string,
|
||||
change: TitleChange,
|
||||
options: RetitleOptions,
|
||||
): Promise<string | undefined> {
|
||||
const { live } = options;
|
||||
if (!isSessionId(sessionId)) {
|
||||
if (!live || !options.mayBeUnwritten) throw sessionNotFound(sessionId);
|
||||
return this.exclusive(sessionId, async () => {
|
||||
const title = change(live.stored() ?? live.shown, sessionId);
|
||||
if (title !== undefined) {
|
||||
await live.rename(title);
|
||||
live.remember(title);
|
||||
}
|
||||
return title;
|
||||
});
|
||||
}
|
||||
return this.exclusive(sessionId, async () => {
|
||||
const transcripts = await this.index.findTranscripts(sessionId);
|
||||
if (transcripts.length === 0 && !options.mayBeUnwritten) throw sessionNotFound(sessionId);
|
||||
let stored: string | undefined;
|
||||
// The cache is left as it is: the appended records change the size,
|
||||
// and the next read keeps what the earlier scans found (a last prompt
|
||||
// beyond the tail search).
|
||||
if (live) {
|
||||
const canonical = await canonicalPath(live.cwd);
|
||||
const paths = [...new Set([live.cwd, canonical])];
|
||||
const own: string[] = [];
|
||||
for (const transcript of transcripts) {
|
||||
if (await isTranscriptOf(transcript, paths)) own.push(transcript);
|
||||
}
|
||||
// The CLI writes the transcript of the resolved cwd.
|
||||
const cli =
|
||||
own.find((transcript) =>
|
||||
isExactProjectDir(path.basename(path.dirname(transcript)), canonical),
|
||||
) ?? own[0];
|
||||
const others = transcripts.filter((transcript) => transcript !== cli);
|
||||
// The transcript holds the current title, also one changed otherwise
|
||||
// (a `/rename`); without one, the title last given to the CLI.
|
||||
const primary = cli ?? others[0];
|
||||
const read = primary ? await copyTitle(primary, sessionId) : undefined;
|
||||
const current = read ?? live.stored() ?? live.shown;
|
||||
stored = change(current, sessionId);
|
||||
if (stored !== undefined) {
|
||||
await live.rename(stored);
|
||||
live.remember(stored);
|
||||
// The CLI writes the custom title before it answers, the agent name
|
||||
// only later. An older agent name decides the archive state first,
|
||||
// so the copy gets the new one now.
|
||||
// A best effort: the CLI took the title, and writes the agent name
|
||||
// itself too.
|
||||
if (cli) {
|
||||
try {
|
||||
await ensureAgentName(cli, sessionId, stored);
|
||||
} catch (error) {
|
||||
this.deps.logError(`writing the agent name of ${sessionId} failed`, error);
|
||||
}
|
||||
}
|
||||
} else if (cli) {
|
||||
await alignSidecar(cli, current, sessionId);
|
||||
}
|
||||
try {
|
||||
await this.titleCopies(sessionId, others, change, options.sidecar);
|
||||
} catch (error) {
|
||||
this.deps.logError(`titling the other transcripts of ${sessionId} failed`, error);
|
||||
}
|
||||
} else {
|
||||
stored = await this.titleCopies(sessionId, transcripts, change, options.sidecar);
|
||||
}
|
||||
return stored;
|
||||
}).finally(() => {
|
||||
// Whatever was written, also by a change that failed part way.
|
||||
this.subscriptions?.sessionWritten(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends the title records `change` gives each copy, then its sidecar.
|
||||
* A copy already in the requested state gets nothing, but its sidecar is
|
||||
* brought in line with it. Returns the title the first titled copy got. */
|
||||
private async titleCopies(
|
||||
sessionId: string,
|
||||
transcripts: readonly string[],
|
||||
change: TitleChange,
|
||||
sidecar: RetitleOptions["sidecar"],
|
||||
): Promise<string | undefined> {
|
||||
let first: string | undefined;
|
||||
for (const transcript of transcripts) {
|
||||
const current = await copyTitle(transcript, sessionId);
|
||||
const title = change(current, sessionId);
|
||||
if (title === undefined) {
|
||||
await alignSidecar(transcript, current, sessionId);
|
||||
continue;
|
||||
}
|
||||
await ensureTitleRecords(transcript, sessionId, title);
|
||||
first ??= title;
|
||||
if (sidecar === "always" || (await exists(sidecarPath(transcript)))) {
|
||||
await writeCustomTitleSidecar(transcript, title);
|
||||
}
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** Deletes every transcript of the session, whoever else has it open, as
|
||||
* the CLI does. `known`: the session was loaded here (and is torn down
|
||||
* already), so a missing transcript is no error.
|
||||
*
|
||||
* The SDK deletes the first non-empty transcript it finds and its
|
||||
* `<sessionId>/` directory, one copy per call; empty transcripts, which it
|
||||
* skips, are removed here. */
|
||||
async delete(sessionId: string, known: boolean): Promise<void> {
|
||||
if (!isSessionId(sessionId)) throw sessionNotFound(sessionId);
|
||||
await this.exclusive(sessionId, async () => {
|
||||
// A session directory can outlive its transcript: a delete that removed
|
||||
// the transcript and then failed on the directory.
|
||||
const found = await this.index.scanSession(sessionId);
|
||||
const all = found.transcripts.map(({ filePath }) => filePath);
|
||||
if (all.length === 0 && found.sessionDirs.length === 0) {
|
||||
// Unknown, unless it was loaded here.
|
||||
if (known) return;
|
||||
throw sessionNotFound(sessionId);
|
||||
}
|
||||
try {
|
||||
// The SDK deletes one non-empty copy per call.
|
||||
for (const { filePath } of found.transcripts.filter(({ size }) => size > 0)) {
|
||||
try {
|
||||
await this.deleteSession(sessionId);
|
||||
} catch (error) {
|
||||
// Only "not found" with that copy gone is a copy removed meanwhile.
|
||||
if (isSdkNotFound(error) && !(await exists(filePath))) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Empty transcripts, which the SDK skips, and session directories
|
||||
// without a transcript.
|
||||
for (const { filePath, size } of found.transcripts) {
|
||||
if (size === 0) await fs.rm(filePath, { force: true });
|
||||
}
|
||||
for (const sessionDir of found.sessionDirs) {
|
||||
if (await exists(`${sessionDir}.jsonl`)) continue;
|
||||
await fs.rm(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
const left = await this.index.scanSession(sessionId);
|
||||
const remaining = [
|
||||
...left.transcripts.map(({ filePath }) => filePath),
|
||||
...left.sessionDirs,
|
||||
];
|
||||
if (remaining.length > 0) {
|
||||
throw new Error(`Session ${sessionId} was not deleted: ${remaining.join(", ")} remain`);
|
||||
}
|
||||
} finally {
|
||||
this.index.invalidate(all);
|
||||
this.subscriptions?.sessionWritten(sessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** `_session/list/subscribe`: pushes the changed rows of `cwd` (an
|
||||
* absolute path) until {@link unsubscribeList} or {@link dispose}. */
|
||||
subscribeList(cwd: string): Promise<ListSubscribeResponse> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(RequestError.internalError(undefined, "The connection is closed"));
|
||||
}
|
||||
this.subscriptions ??= new ListSubscriptions({
|
||||
index: this.index,
|
||||
registry: this.registry,
|
||||
own: (sessionId) => this.deps.ownSessionState?.(sessionId),
|
||||
notify: (changes) => this.deps.notifyListChanges?.(changes) ?? Promise.resolve(),
|
||||
logError: this.deps.logError,
|
||||
...this.deps.listSubscriptionTiming,
|
||||
});
|
||||
return this.subscriptions.subscribe(cwd);
|
||||
}
|
||||
|
||||
/** `_session/list/unsubscribe`. Idempotent. */
|
||||
unsubscribeList(subscriptionId: string): void {
|
||||
this.subscriptions?.unsubscribe(subscriptionId);
|
||||
}
|
||||
|
||||
/** A session this connection runs changed what its row shows. */
|
||||
ownSessionChanged(sessionId: string): void {
|
||||
this.subscriptions?.ownSessionChanged(sessionId);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.subscriptions?.dispose();
|
||||
this.subscriptions = undefined;
|
||||
}
|
||||
}
|
||||
1051
src/session-index/session-index.ts
Normal file
1051
src/session-index/session-index.ts
Normal file
File diff suppressed because it is too large
Load Diff
62
src/session-index/session-info.ts
Normal file
62
src/session-index/session-info.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* A session index row as the client sees it: the `SessionInfo` of
|
||||
* `session/list` and of `_session/list/changes`, with the flat row fields of
|
||||
* the RFDs in `_meta.jetbrains.air`.
|
||||
*/
|
||||
|
||||
import type { SessionInfo } from "@agentclientprotocol/sdk";
|
||||
import { withAirMeta } from "../air-extension.js";
|
||||
import { deriveActivity, selectCost, type OwnSessionState } from "./activity.js";
|
||||
import type { LiveRecord } from "./live-registry.js";
|
||||
import type { IndexRow } from "./session-index.js";
|
||||
|
||||
function iso(ms: number | undefined): string | undefined {
|
||||
return ms === undefined || !Number.isFinite(ms) ? undefined : new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
/** The `SessionInfo` of `row`. `own` is what this connection knows of a
|
||||
* session it runs, `live` the registry record of the session. */
|
||||
export function sessionInfoOf(
|
||||
row: IndexRow,
|
||||
own: OwnSessionState | undefined,
|
||||
live: LiveRecord | undefined,
|
||||
now: number,
|
||||
): SessionInfo {
|
||||
const activity = deriveActivity({
|
||||
own,
|
||||
live,
|
||||
facts: row.facts,
|
||||
transcriptMtimeMs: row.mtimeMs,
|
||||
now,
|
||||
});
|
||||
const cost = selectCost(own);
|
||||
const { facts } = row;
|
||||
// The row fields of the session list extensions RFD and RFD #2161, flat;
|
||||
// each is omitted when unknown, except `archived`.
|
||||
const fields: Record<string, unknown> = {
|
||||
archived: row.archived,
|
||||
lastPromptAt: iso(facts.lastPromptAt),
|
||||
model: facts.model,
|
||||
forkedFrom: facts.forkedFrom,
|
||||
state: activity?.state,
|
||||
lastTurnEndedAt: activity?.lastTurnEndedAt,
|
||||
cost: cost === undefined ? undefined : { amount: cost, currency: "USD" },
|
||||
};
|
||||
let meta: Record<string, unknown> | undefined;
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value !== undefined) meta = withAirMeta(meta, key, value);
|
||||
}
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
cwd: row.cwd,
|
||||
title: row.title,
|
||||
updatedAt: new Date(row.updatedAtMs).toISOString(),
|
||||
_meta: meta,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a change of the row is compared by: everything but `updatedAt`,
|
||||
* which alone is no change. */
|
||||
export function changeSignature(info: SessionInfo): string {
|
||||
return JSON.stringify([info.sessionId, info.cwd, info.title, info._meta ?? null]);
|
||||
}
|
||||
631
src/session-index/transcript-scan.ts
Normal file
631
src/session-index/transcript-scan.ts
Normal file
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* What the session list needs from a transcript beyond the SDK metadata.
|
||||
*
|
||||
* One open reads the first and the last {@link CHUNK_SIZE} bytes, the same
|
||||
* window that the SDK reads. The tail gives the time of the last message, the
|
||||
* end of the last turn. The head and the tail give the `cwd` candidates.
|
||||
*
|
||||
* A last message longer than the tail window leaves no message in it: then
|
||||
* the tail window grows, up to {@link MAX_TAIL_SIZE}, until it holds one.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import { firstPrompt, isUserPrompt, mediaPrompt, promptOf } from "./first-prompt.js";
|
||||
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
/** The largest tail window read to find the last message. */
|
||||
const MAX_TAIL_SIZE = 4 * 1024 * 1024;
|
||||
const TAIL_GROWTH = 4;
|
||||
const FULL_SEARCH_CACHE_SIZE = 512;
|
||||
|
||||
/** By `path\0size`, the facts of the transcripts whose last
|
||||
* {@link MAX_TAIL_SIZE} bytes lack the last message or prompt: a file of
|
||||
* the same size is not searched again. */
|
||||
const fullySearched = new Map<string, TranscriptFacts>();
|
||||
|
||||
export type TurnState = "finished" | "unfinished";
|
||||
|
||||
export type TranscriptFacts = {
|
||||
/** Time of the last user or assistant message in the tail, epoch ms. */
|
||||
lastMessageAt?: number;
|
||||
/** Whether the last turn in the tail ended. Undefined when the tail shows
|
||||
* no turn at all. */
|
||||
turnState?: TurnState;
|
||||
/** Time the last ended turn ended, epoch ms. */
|
||||
lastTurnEndedAt?: number;
|
||||
/** The last turn ended with an API error (an `isApiErrorMessage`
|
||||
* assistant record), not a user interrupt, and nothing came after it. */
|
||||
lastTurnError?: boolean;
|
||||
/** The first `cwd` of the head. */
|
||||
headCwd?: string;
|
||||
/** The last `cwd` of the tail. */
|
||||
tailCwd?: string;
|
||||
/** Time of the last real user prompt in the tail (not a tool result, a
|
||||
* meta record or a slash command), epoch ms. */
|
||||
lastPromptAt?: number;
|
||||
/** Whether `lastPromptAt` is final: the prompt was found, or the tail
|
||||
* window searched up to {@link MAX_TAIL_SIZE} holds none. */
|
||||
promptSearched?: boolean;
|
||||
/** The model of the last assistant message in the tail. */
|
||||
model?: string;
|
||||
/** The session this one was forked from: the `forkedFrom.sessionId` that
|
||||
* the SDK and CLI fork write on every copied record. */
|
||||
forkedFrom?: string;
|
||||
/** Whether the head or the tail holds a user or an assistant message. A
|
||||
* transcript without one is a metadata-only stub. */
|
||||
hasMessages: boolean;
|
||||
};
|
||||
|
||||
export type HeadTail = {
|
||||
head: string;
|
||||
/** The last 64 KB from their first whole line on; the whole file when it
|
||||
* fits in the head. */
|
||||
tail: string;
|
||||
/** The last 64 KB as the SDK searches them, with the cut line before
|
||||
* `tail`; absent when that is `tail`. */
|
||||
sdkTail?: string;
|
||||
};
|
||||
|
||||
/** Facts that an earlier scan may have found before the tail window. */
|
||||
const INHERITED_FACTS = ["model", "tailCwd", "lastTurnEndedAt"] as const;
|
||||
|
||||
/** What an earlier scan of the same file found, at `size`. */
|
||||
export type PreviousScan = { size: number } & Pick<
|
||||
TranscriptFacts,
|
||||
"lastPromptAt" | "promptSearched" | (typeof INHERITED_FACTS)[number]
|
||||
>;
|
||||
|
||||
/** The last `window` bytes of a file of `size` bytes, without the first,
|
||||
* cut line. The whole file when it fits. */
|
||||
async function readTail(
|
||||
handle: fs.FileHandle,
|
||||
size: number,
|
||||
window: number,
|
||||
): Promise<{ raw: string; tail: string }> {
|
||||
const length = Math.min(window, size);
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
||||
const raw = buffer.toString("utf8", 0, bytesRead);
|
||||
if (length === size) return { raw, tail: raw };
|
||||
const newline = raw.indexOf("\n");
|
||||
return { raw, tail: newline >= 0 ? raw.slice(newline + 1) : "" };
|
||||
}
|
||||
|
||||
/** The first and the last 64 KB of the first `size` bytes of `filePath`:
|
||||
* what was appended after the file was stat'ed is not read, so the result
|
||||
* matches the size it is cached by. The tail is the head for a file that
|
||||
* fits in one chunk. */
|
||||
export async function readHeadTail(filePath: string, size: number): Promise<HeadTail> {
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
return await readHeadTailOf(handle, size);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link readHeadTail} of a file that is open already. */
|
||||
export async function readHeadTailOf(handle: fs.FileHandle, size: number): Promise<HeadTail> {
|
||||
const buffer = Buffer.allocUnsafe(CHUNK_SIZE);
|
||||
const first = await handle.read(buffer, 0, Math.min(CHUNK_SIZE, size), 0);
|
||||
const head = buffer.toString("utf8", 0, first.bytesRead);
|
||||
if (size <= CHUNK_SIZE) return { head, tail: head };
|
||||
const { raw, tail } = await readTail(handle, size, CHUNK_SIZE);
|
||||
return raw === tail ? { head, tail } : { head, tail, sdkTail: raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* The facts of `filePath`, from its head and tail. When the transcript has
|
||||
* messages but the tail window lacks the last message or the last user
|
||||
* prompt (a long answer or tool output follows it), the window grows,
|
||||
* reading only the new range each step, until it holds both or reaches
|
||||
* {@link MAX_TAIL_SIZE}. The result of a full search is kept per
|
||||
* `(path, size)`.
|
||||
*
|
||||
* `previous` is what an earlier scan of the same file found: when the file
|
||||
* only grew by less than the tail window, and the tail holds no prompt, the
|
||||
* appended bytes hold none either, so the earlier `lastPromptAt` stands.
|
||||
*/
|
||||
export async function scanTranscriptFile(
|
||||
filePath: string,
|
||||
size: number,
|
||||
headTail?: HeadTail,
|
||||
previous?: PreviousScan,
|
||||
/** Tells a file replaced by another of the same size apart (its inode). */
|
||||
identity?: number,
|
||||
): Promise<TranscriptFacts> {
|
||||
const read = headTail ?? (await readHeadTail(filePath, size));
|
||||
const facts = scanTranscript(read);
|
||||
// The file only grew by bytes that the tail covers, and the tail holds no
|
||||
// prompt: the appended bytes hold none either, so the earlier search
|
||||
// stands (its prompt, or that the last 4 MB held none).
|
||||
const grownWithinTail =
|
||||
previous !== undefined &&
|
||||
size >= previous.size &&
|
||||
size - Buffer.byteLength(read.tail) <= previous.size;
|
||||
if (grownWithinTail) {
|
||||
// What the earlier, possibly wider, scan found before the tail stands
|
||||
// when the tail has nothing newer.
|
||||
for (const key of INHERITED_FACTS) {
|
||||
if (facts[key] === undefined && previous[key] !== undefined) {
|
||||
(facts as Record<string, unknown>)[key] = previous[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (facts.lastPromptAt !== undefined || size <= CHUNK_SIZE) {
|
||||
facts.promptSearched = true;
|
||||
} else if (grownWithinTail && (previous.lastPromptAt !== undefined || previous.promptSearched)) {
|
||||
facts.lastPromptAt = previous.lastPromptAt;
|
||||
facts.promptSearched = true;
|
||||
}
|
||||
const complete = (found: TranscriptFacts) =>
|
||||
found.lastMessageAt !== undefined && found.promptSearched === true;
|
||||
if (!facts.hasMessages || complete(facts) || size <= CHUNK_SIZE) return facts;
|
||||
const key = `${filePath}\0${size}\0${identity ?? ""}`;
|
||||
const searched = fullySearched.get(key);
|
||||
if (searched) return searched;
|
||||
let result = facts;
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
// The bytes from `start` to the end, grown by reading the new range only.
|
||||
let start = Math.max(0, size - CHUNK_SIZE);
|
||||
const first = Buffer.allocUnsafe(size - start);
|
||||
const { bytesRead: firstRead } = await handle.read(first, 0, first.length, start);
|
||||
let bytes = first.subarray(0, firstRead);
|
||||
for (let window = CHUNK_SIZE * TAIL_GROWTH; start > 0; window *= TAIL_GROWTH) {
|
||||
const nextStart = Math.max(0, size - Math.min(window, MAX_TAIL_SIZE));
|
||||
if (nextStart >= start) break;
|
||||
const range = Buffer.allocUnsafe(start - nextStart);
|
||||
const { bytesRead } = await handle.read(range, 0, range.length, nextStart);
|
||||
bytes = Buffer.concat([range.subarray(0, bytesRead), bytes]);
|
||||
start = nextStart;
|
||||
let tail = bytes.toString("utf8");
|
||||
if (start > 0) {
|
||||
// The first line of the window is cut; drop it.
|
||||
const newline = tail.indexOf("\n");
|
||||
tail = newline >= 0 ? tail.slice(newline + 1) : "";
|
||||
}
|
||||
// A wider tail ends with the same records: what the narrow one found
|
||||
// stays, and the wider one adds what lay before it.
|
||||
const wider = scanTranscript(
|
||||
{ head: read.head, tail },
|
||||
{
|
||||
wide: true,
|
||||
known: { model: facts.model, tailCwd: facts.tailCwd },
|
||||
},
|
||||
);
|
||||
const lastPromptAt = wider.lastPromptAt ?? facts.lastPromptAt;
|
||||
result = {
|
||||
...wider,
|
||||
...(facts.model !== undefined && { model: facts.model }),
|
||||
...(facts.tailCwd !== undefined && { tailCwd: facts.tailCwd }),
|
||||
// A turn end the wider tail lacks may lie before it: the
|
||||
// narrow scan inherited it from an earlier scan of this file.
|
||||
lastTurnEndedAt: wider.lastTurnEndedAt ?? facts.lastTurnEndedAt,
|
||||
hasMessages: facts.hasMessages,
|
||||
lastPromptAt,
|
||||
// Searched to the start of the file, or found.
|
||||
promptSearched: facts.promptSearched || lastPromptAt !== undefined || start === 0,
|
||||
};
|
||||
if (complete(result)) return result;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// The whole window is searched: no prompt in it either.
|
||||
result = { ...result, promptSearched: true };
|
||||
fullySearched.set(key, result);
|
||||
if (fullySearched.size > FULL_SEARCH_CACHE_SIZE) {
|
||||
fullySearched.delete(fullySearched.keys().next().value as string);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const RELOCATED_MARKER = '"relocated"';
|
||||
|
||||
/** The cwd of the last `relocated` record of the tail: where the session
|
||||
* was moved, whatever cwd its earlier messages carry. */
|
||||
export function relocatedCwd(tail: string): string | undefined {
|
||||
const lines = tail.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
if (!line.includes(RELOCATED_MARKER)) continue;
|
||||
const entry = parseLine(line);
|
||||
if (entry?.type === "relocated" && typeof entry.relocatedCwd === "string") {
|
||||
return entry.relocatedCwd;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The cwd a transcript belongs to, as the SDK reads it: the last
|
||||
* `relocated` record of the tail, else the first cwd of the head. */
|
||||
export function transcriptProjectCwd({ head, tail }: HeadTail): string | undefined {
|
||||
const relocated = relocatedCwd(tail);
|
||||
if (relocated !== undefined) return relocated;
|
||||
const headCwd = CWD_PATTERN.exec(head)?.[1];
|
||||
return headCwd === undefined ? undefined : decodeJsonString(headCwd);
|
||||
}
|
||||
|
||||
const MESSAGE_MARKERS = [
|
||||
'"type":"user"',
|
||||
'"type":"assistant"',
|
||||
'"type": "user"',
|
||||
'"type": "assistant"',
|
||||
];
|
||||
|
||||
/** System records that the CLI writes when a turn ends. */
|
||||
const TURN_END_SYSTEM_SUBTYPES = new Set(["stop_hook_summary", "turn_duration"]);
|
||||
|
||||
const INTERRUPT_PREFIX = "[Request interrupted by user";
|
||||
|
||||
type Entry = Record<string, unknown>;
|
||||
|
||||
function parseLine(line: string): Entry | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(line);
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Entry)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function timestampOf(entry: Entry): number | undefined {
|
||||
if (typeof entry.timestamp !== "string") return undefined;
|
||||
const value = Date.parse(entry.timestamp);
|
||||
return Number.isNaN(value) ? undefined : value;
|
||||
}
|
||||
|
||||
function firstText(entry: Entry): string | undefined {
|
||||
const message = entry.message as { content?: unknown } | undefined;
|
||||
const content = message?.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && (block as { type?: unknown }).type === "text") {
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a main-chain record bears on the end of the turn: `finished` for a
|
||||
* record that ends a turn (an assistant `end_turn`, an API error, a user
|
||||
* interrupt, the CLI's turn-end system records), `unfinished` for a record of
|
||||
* a turn in progress, and undefined for a record that says nothing.
|
||||
*/
|
||||
export function turnEffect(entry: Entry): TurnState | undefined {
|
||||
if (entry.isSidechain === true) return undefined;
|
||||
switch (entry.type) {
|
||||
case "assistant": {
|
||||
if (entry.isApiErrorMessage === true) return "finished";
|
||||
const stopReason = (entry.message as { stop_reason?: unknown } | undefined)?.stop_reason;
|
||||
return stopReason === "end_turn" ? "finished" : "unfinished";
|
||||
}
|
||||
case "user": {
|
||||
if (entry.isMeta === true) return undefined;
|
||||
return firstText(entry)?.startsWith(INTERRUPT_PREFIX) ? "finished" : "unfinished";
|
||||
}
|
||||
case "system":
|
||||
return TURN_END_SYSTEM_SUBTYPES.has(entry.subtype as string) ? "finished" : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const FORKED_FROM_PATTERN =
|
||||
/"forkedFrom":\s?\{"sessionId":\s?"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"/;
|
||||
|
||||
const CWD_PATTERN = /"cwd":\s?"((?:[^"\\]|\\.)*)"/;
|
||||
|
||||
function decodeJsonString(raw: string): string | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(`"${raw}"`);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The facts of a transcript, from its head and its tail. */
|
||||
export function scanTranscript(
|
||||
{ head, tail }: HeadTail,
|
||||
options: { wide?: boolean; known?: { model?: string; tailCwd?: string } } = {},
|
||||
): TranscriptFacts {
|
||||
const facts: TranscriptFacts = {
|
||||
hasMessages: MESSAGE_MARKERS.some((marker) => head.includes(marker) || tail.includes(marker)),
|
||||
};
|
||||
const headCwd = CWD_PATTERN.exec(head)?.[1];
|
||||
if (headCwd !== undefined) facts.headCwd = decodeJsonString(headCwd);
|
||||
const forkedFrom = FORKED_FROM_PATTERN.exec(head)?.[1];
|
||||
if (forkedFrom !== undefined) facts.forkedFrom = forkedFrom;
|
||||
|
||||
const lines = tail.split("\n");
|
||||
let turnEndFound = false;
|
||||
let messageFound = false;
|
||||
let promptFound = false;
|
||||
/** The last turn ended: is its end an API error? */
|
||||
let checkingTurnError = false;
|
||||
// A wide tail (see scanTranscriptFile) is searched for what the narrow
|
||||
// one lacked: the last message, prompt and turn end, and the model and
|
||||
// the cwd only when the narrow tail had none (its records can all be cut,
|
||||
// e.g. one large tool result), so a long answer is not parsed line by line.
|
||||
const wide = options.wide === true;
|
||||
let modelFound = wide && options.known?.model !== undefined;
|
||||
const searchCwd = !wide || options.known?.tailCwd === undefined;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
if (!line) continue;
|
||||
const isTurnRecord =
|
||||
line.includes('"user"') || line.includes('"assistant"') || line.includes('"system"');
|
||||
const hasCwd = searchCwd && facts.tailCwd === undefined && line.includes('"cwd"');
|
||||
if (!isTurnRecord && !hasCwd) continue;
|
||||
// Everything has been found: stop parsing.
|
||||
if (
|
||||
turnEndFound &&
|
||||
messageFound &&
|
||||
promptFound &&
|
||||
modelFound &&
|
||||
!checkingTurnError &&
|
||||
!hasCwd
|
||||
) {
|
||||
break;
|
||||
}
|
||||
// Once the last message and the turn state are known, only a line that
|
||||
// can still tell something is parsed: lines are often large (tool
|
||||
// results, long answers), and a long tail has thousands of them.
|
||||
if (
|
||||
messageFound &&
|
||||
facts.turnState !== undefined &&
|
||||
!hasCwd &&
|
||||
!(!promptFound && mayBePrompt(line)) &&
|
||||
!(!modelFound && hasType(line, "assistant")) &&
|
||||
!(!turnEndFound && mayEndTurn(line)) &&
|
||||
!(checkingTurnError && isTurnRecord)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const entry = parseLine(line);
|
||||
if (!entry) continue;
|
||||
if (hasCwd && typeof entry.cwd === "string" && entry.cwd) facts.tailCwd = entry.cwd;
|
||||
if (entry.isSidechain === true) continue;
|
||||
if (!messageFound && (entry.type === "user" || entry.type === "assistant")) {
|
||||
messageFound = true;
|
||||
facts.lastMessageAt = timestampOf(entry);
|
||||
}
|
||||
if (!promptFound && entry.type === "user" && isUserPrompt(entry)) {
|
||||
promptFound = true;
|
||||
facts.lastPromptAt = timestampOf(entry);
|
||||
}
|
||||
if (!modelFound && entry.type === "assistant") {
|
||||
const model = (entry.message as { model?: unknown } | undefined)?.model;
|
||||
// The CLI marks the messages it makes up (API errors) `<synthetic>`.
|
||||
if (typeof model === "string" && model && model !== "<synthetic>") {
|
||||
modelFound = true;
|
||||
facts.model = model;
|
||||
}
|
||||
}
|
||||
const effect = turnEffect(entry);
|
||||
if (effect === undefined) continue;
|
||||
if (facts.turnState === undefined) {
|
||||
facts.turnState = effect;
|
||||
checkingTurnError = effect === "finished";
|
||||
}
|
||||
if (checkingTurnError) {
|
||||
// The record that ended the last turn, behind the CLI's turn-end
|
||||
// system records: an API error is an error.
|
||||
if (entry.type === "assistant" && entry.isApiErrorMessage === true) {
|
||||
facts.lastTurnError = true;
|
||||
checkingTurnError = false;
|
||||
} else if (entry.type !== "system") {
|
||||
checkingTurnError = false;
|
||||
}
|
||||
}
|
||||
if (effect === "finished" && !turnEndFound) {
|
||||
turnEndFound = true;
|
||||
facts.lastTurnEndedAt = timestampOf(entry);
|
||||
}
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
function hasType(line: string, type: string): boolean {
|
||||
return line.includes(`"type":"${type}"`) || line.includes(`"type": "${type}"`);
|
||||
}
|
||||
|
||||
/** Whether a line may be a user prompt: a user record that is no tool
|
||||
* result, or one that carries an image or a document. */
|
||||
function mayBePrompt(line: string): boolean {
|
||||
if (!hasType(line, "user")) return false;
|
||||
return !hasType(line, "tool_result") || hasType(line, "image") || hasType(line, "document");
|
||||
}
|
||||
|
||||
/** Whether a line may end a turn (see {@link turnEffect}). */
|
||||
function mayEndTurn(line: string): boolean {
|
||||
return (
|
||||
line.includes('"end_turn"') ||
|
||||
line.includes('"isApiErrorMessage"') ||
|
||||
line.includes(INTERRUPT_PREFIX) ||
|
||||
[...TURN_END_SYSTEM_SUBTYPES].some((subtype) => line.includes(`"${subtype}"`))
|
||||
);
|
||||
}
|
||||
|
||||
/** Every `"key":"value"` string of `text`, in order, decoded. */
|
||||
function stringFields(text: string, key: string): string[] {
|
||||
const pattern = new RegExp(`"${key}":\\s?"((?:[^"\\\\]|\\\\.)*)"`, "g");
|
||||
const values: string[] = [];
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const value = decodeJsonString(match[1]!);
|
||||
if (value !== undefined) values.push(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
const lastField = (text: string, key: string) => stringFields(text, key).at(-1) || undefined;
|
||||
|
||||
/** Whether the first record of the transcript is a sidechain one, which the
|
||||
* SDK never lists. */
|
||||
export function isSidechainTranscript(head: string): boolean {
|
||||
const newline = head.indexOf("\n");
|
||||
const first = newline >= 0 ? head.slice(0, newline) : head;
|
||||
return first.includes('"isSidechain":true') || first.includes('"isSidechain": true');
|
||||
}
|
||||
|
||||
/** The last `customTitle` of the tail, as the SDK reads it. */
|
||||
export function tailCustomTitle({ tail, sdkTail }: HeadTail): string | undefined {
|
||||
return lastField(sdkTail ?? tail, "customTitle");
|
||||
}
|
||||
|
||||
/** The last non-blank top-level `agentName` of the records of `text`, the
|
||||
* name `/rename`, a `rename_session` and AIR write with the custom title. */
|
||||
function lastAgentName(text: string): string | undefined {
|
||||
if (!text.includes('"agentName"')) return undefined;
|
||||
const lines = text.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
if (!line.includes('"agentName"')) continue;
|
||||
const value = parseLine(line)?.agentName;
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The last agent name of a transcript, as AIR's native Claude provider
|
||||
* reads it: the top-level `agentName` of any record, from the tail, else
|
||||
* from the head. */
|
||||
export function transcriptAgentName({ head, tail }: HeadTail): string | undefined {
|
||||
return lastAgentName(tail) ?? lastAgentName(head);
|
||||
}
|
||||
|
||||
/** What the SDK reports as the titles of a session. */
|
||||
export type SdkTitles = {
|
||||
/** `SDKSessionInfo.customTitle`: the title the CLI or a client set. */
|
||||
customTitle?: string;
|
||||
/** `SDKSessionInfo.summary`: the custom title, else a generated one. */
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The titles the SDK gives a transcript, from its own head and tail, in the
|
||||
* SDK's order: custom title (tail, the sidecar, head), AI title, then
|
||||
* {@link generatedTitle}: what the SDK `getSessionInfo` reports for this
|
||||
* file, without its lookup of the file. The sidecar title counts only when
|
||||
* the tail has no custom title.
|
||||
*/
|
||||
export function sdkTitles(
|
||||
{ head, tail: wholeLines, sdkTail }: HeadTail,
|
||||
sidecarTitle?: string,
|
||||
): SdkTitles {
|
||||
const tail = sdkTail ?? wholeLines;
|
||||
const customTitle =
|
||||
lastField(tail, "customTitle") ??
|
||||
sidecarTitle ??
|
||||
lastField(head, "customTitle") ??
|
||||
lastField(tail, "aiTitle") ??
|
||||
lastField(head, "aiTitle");
|
||||
return { customTitle, summary: customTitle ?? generatedTitle({ head, tail }) };
|
||||
}
|
||||
|
||||
/** The title of a transcript without a custom title, in the SDK's order: AI
|
||||
* title, last prompt, summary, first prompt. */
|
||||
export function generatedTitle({ head, tail }: HeadTail): string | undefined {
|
||||
return (
|
||||
lastField(tail, "aiTitle") ??
|
||||
lastField(head, "aiTitle") ??
|
||||
lastField(tail, "lastPrompt") ??
|
||||
lastField(tail, "summary") ??
|
||||
(firstPrompt(head) || mediaPrompt(head) || undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/** The time of the last `"timestamp"` in the last 64 KB of a file, cut
|
||||
* line included, epoch ms: a record longer than that still ends with it. */
|
||||
export async function lastTimestamp(filePath: string, size: number): Promise<number | undefined> {
|
||||
const handle = await fs.open(filePath, "r");
|
||||
let text: string;
|
||||
try {
|
||||
const length = Math.min(size, CHUNK_SIZE);
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
||||
text = buffer.toString("utf8", 0, bytesRead);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
for (const raw of stringFields(text, "timestamp").reverse()) {
|
||||
const value = Date.parse(raw);
|
||||
if (!Number.isNaN(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const CONTINUED_IN_MARKER = '"type":"continued-in"';
|
||||
|
||||
/**
|
||||
* The session this transcript was continued in, as the SDK list reads it: the
|
||||
* last `continued-in` record of the tail, unless a completed turn follows it
|
||||
* (the session was resumed here again).
|
||||
*/
|
||||
export function continuedInSessionId(tail: string): string | undefined {
|
||||
if (!tail.includes(CONTINUED_IN_MARKER)) return undefined;
|
||||
const lines = tail.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]!;
|
||||
const isContinuation = line.includes(CONTINUED_IN_MARKER);
|
||||
const isMessage = line.includes('"type":"user"') || line.includes('"type":"assistant"');
|
||||
if (!isContinuation && !isMessage) continue;
|
||||
const entry = parseLine(line);
|
||||
if (!entry) continue;
|
||||
if (isContinuation && entry.type === "continued-in") {
|
||||
const id = entry.continuedInSessionId;
|
||||
return typeof id === "string" && SESSION_ID.test(id) ? id : undefined;
|
||||
}
|
||||
if (entry.type === "assistant") {
|
||||
const stopReason = (entry.message as { stop_reason?: unknown } | undefined)?.stop_reason;
|
||||
if (entry.isApiErrorMessage !== true && typeof stopReason === "string") return undefined;
|
||||
} else if (promptOf(entry, { commandFallback: "" }) !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const HISTORY_MARKER = '"parentUuid":';
|
||||
const HISTORY_SCAN_LIMIT = 16 * 1024 * 1024;
|
||||
const HISTORY_SCAN_CHUNK = 1024 * 1024;
|
||||
|
||||
/** Whether the transcript at `filePath` holds conversation history (a record
|
||||
* with a `parentUuid`), as the SDK checks a continuation's successor. */
|
||||
export async function hasHistory(filePath: string): Promise<boolean> {
|
||||
let handle: fs.FileHandle;
|
||||
try {
|
||||
handle = await fs.open(filePath, "r");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { size } = await handle.stat();
|
||||
const buffer = Buffer.allocUnsafe(HISTORY_SCAN_CHUNK + HISTORY_MARKER.length);
|
||||
let carry = 0;
|
||||
for (let offset = 0; offset < Math.min(size, HISTORY_SCAN_LIMIT);) {
|
||||
const { bytesRead } = await handle.read(buffer, carry, HISTORY_SCAN_CHUNK, offset);
|
||||
if (bytesRead === 0) return false;
|
||||
const end = carry + bytesRead;
|
||||
if (buffer.subarray(0, end).includes(HISTORY_MARKER)) return true;
|
||||
carry = Math.min(HISTORY_MARKER.length, end);
|
||||
buffer.copyWithin(0, end - carry, end);
|
||||
offset += bytesRead;
|
||||
}
|
||||
// Past the scan limit the SDK assumes history.
|
||||
return size > HISTORY_SCAN_LIMIT;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
135
src/session-index/worktrees.ts
Normal file
135
src/session-index/worktrees.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The git worktrees of a working directory, read from the repository files.
|
||||
*
|
||||
* The SDK `listSessions({ dir })` includes the sessions of every worktree of
|
||||
* the repository and runs `git worktree list` for that on each call. This
|
||||
* module reads the same list without spawning git, the way the Codex TUI does:
|
||||
* the main worktree is the parent of the common `.git` directory, and each
|
||||
* linked worktree is named by `<common-dir>/worktrees/<name>/gitdir`.
|
||||
* A worktree whose directory no longer exists is dropped. Nothing is cached:
|
||||
* `git worktree move` rewrites a `gitdir` file without touching the
|
||||
* `worktrees` directory, so a cache keyed on it would miss the move.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { normalizePath } from "./project-dirs.js";
|
||||
|
||||
async function statOrUndefined(target: string) {
|
||||
try {
|
||||
return await fs.stat(target);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The git dir of the repository that contains `cwd`, or undefined. */
|
||||
async function findGitDir(cwd: string): Promise<string | undefined> {
|
||||
let current = path.resolve(cwd);
|
||||
for (;;) {
|
||||
const candidate = path.join(current, ".git");
|
||||
const stats = await statOrUndefined(candidate);
|
||||
if (stats?.isDirectory()) return candidate;
|
||||
if (stats?.isFile()) {
|
||||
try {
|
||||
const text = await fs.readFile(candidate, "utf8");
|
||||
const match = /^gitdir:\s*(.+)$/m.exec(text);
|
||||
if (match) return path.resolve(current, match[1]!.trim());
|
||||
} catch {
|
||||
// An unreadable `.git` file is not a repository.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
async function commonDirOf(gitDir: string): Promise<string> {
|
||||
try {
|
||||
const text = await fs.readFile(path.join(gitDir, "commondir"), "utf8");
|
||||
const value = text.trim();
|
||||
if (value) return path.resolve(gitDir, value);
|
||||
} catch {
|
||||
// No `commondir`: the git dir is the common dir.
|
||||
}
|
||||
return gitDir;
|
||||
}
|
||||
|
||||
async function linkedWorktrees(commonDir: string): Promise<string[]> {
|
||||
const worktreesDir = path.join(commonDir, "worktrees");
|
||||
const stats = await statOrUndefined(worktreesDir);
|
||||
if (!stats?.isDirectory()) return [];
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(worktreesDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
try {
|
||||
const text = await fs.readFile(path.join(worktreesDir, name, "gitdir"), "utf8");
|
||||
const gitFile = text.trim();
|
||||
return gitFile ? path.dirname(path.resolve(worktreesDir, name, gitFile)) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((value): value is string => value !== undefined);
|
||||
}
|
||||
|
||||
/** The root of the worktree that contains `cwd`: the directory of its
|
||||
* `.git` (a directory in the main worktree, a file in a linked one). */
|
||||
async function worktreeRootOf(cwd: string): Promise<string | undefined> {
|
||||
let current = path.resolve(cwd);
|
||||
for (;;) {
|
||||
if (await statOrUndefined(path.join(current, ".git"))) return current;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same subdirectory as `cwd` in every existing worktree of its
|
||||
* repository (the main one and the linked ones), as the Codex TUI expands a
|
||||
* cwd: for `/repo/packages/a`, `/wt1/packages/a` if it exists. For a cwd at a
|
||||
* worktree root, the worktree roots. Empty outside a repository.
|
||||
*/
|
||||
export async function worktreeCounterparts(cwd: string): Promise<string[]> {
|
||||
const root = await worktreeRootOf(cwd);
|
||||
if (!root) return [];
|
||||
const relative = path.relative(root, path.resolve(cwd));
|
||||
const candidates = (await repositoryWorktrees(cwd)).map((worktree) =>
|
||||
relative ? path.join(worktree, relative) : worktree,
|
||||
);
|
||||
const existing = await Promise.all(
|
||||
candidates.map(async (candidate) =>
|
||||
(await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined,
|
||||
),
|
||||
);
|
||||
return [...new Set(existing.filter((value): value is string => value !== undefined))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every existing worktree of the repository that contains `cwd`: the main
|
||||
* worktree and the linked ones. Empty outside a repository.
|
||||
*/
|
||||
export async function repositoryWorktrees(cwd: string): Promise<string[]> {
|
||||
const gitDir = await findGitDir(cwd);
|
||||
if (!gitDir) return [];
|
||||
const commonDir = await commonDirOf(gitDir);
|
||||
const candidates: string[] = [];
|
||||
if (path.basename(commonDir) === ".git") candidates.push(path.dirname(commonDir));
|
||||
candidates.push(...(await linkedWorktrees(commonDir)));
|
||||
const existing = await Promise.all(
|
||||
candidates.map(async (candidate) =>
|
||||
(await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined,
|
||||
),
|
||||
);
|
||||
return [...new Set(existing.filter((value): value is string => value !== undefined))];
|
||||
}
|
||||
@@ -12,12 +12,27 @@ import type {
|
||||
PermissionResult,
|
||||
Query,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_KIND_KEY, airOnlyMeta } from "./air-extension.js";
|
||||
import { resolvePermissionMode } from "./permissions/modes.js";
|
||||
import {
|
||||
noticeOrTranscriptUpdate,
|
||||
noticeTranscriptText,
|
||||
sentenceCase,
|
||||
type SessionNotice,
|
||||
} from "./session-notices.js";
|
||||
|
||||
export const MODE_CONFIG_ID = "mode";
|
||||
export const AUTO_MODE_FALLBACK: PermissionMode = "acceptEdits";
|
||||
|
||||
const AUTO_MODE_FALLBACK_NOTICE =
|
||||
"**Auto mode unavailable:** the selected model does not support Auto mode; using Accept edits instead.";
|
||||
/** Reads on after the bold label in the transcript line; capitalized when it
|
||||
* stands alone as a notice description. */
|
||||
const AUTO_MODE_FALLBACK_REASON =
|
||||
"the selected model does not support Auto mode; using Accept edits instead.";
|
||||
const AUTO_MODE_FALLBACK_NOTICE: SessionNotice = {
|
||||
severity: "warning",
|
||||
title: "Auto mode unavailable",
|
||||
description: sentenceCase(AUTO_MODE_FALLBACK_REASON),
|
||||
};
|
||||
|
||||
export type SessionMode = {
|
||||
query: Pick<Query, "setPermissionMode">;
|
||||
@@ -29,6 +44,8 @@ export type SessionMode = {
|
||||
autoModeFallbackWarningShown?: boolean;
|
||||
/** Initial mode fallback is reported after session/new, on the first prompt. */
|
||||
autoModeFallbackWarningPending?: boolean;
|
||||
/** The mode the session left when it entered plan mode, if it is in plan. */
|
||||
prePlanMode?: string;
|
||||
};
|
||||
|
||||
export type SessionModeManagerOptions<S extends SessionMode> = {
|
||||
@@ -36,7 +53,13 @@ export type SessionModeManagerOptions<S extends SessionMode> = {
|
||||
sessionEndedMessage: string;
|
||||
updateConfigOption(sessionId: string, configId: string, value: string): Promise<void>;
|
||||
sessionUpdate(params: SessionNotification): Promise<void>;
|
||||
/** Whether the client can present `notice` updates; the fallback warning
|
||||
* is a transcript line otherwise. Read per call: capabilities are only
|
||||
* known after `initialize`. */
|
||||
supportsNotices?(): boolean;
|
||||
logError(...args: unknown[]): void;
|
||||
/** Whether the client is AIR. Only AIR gets the mode kind, under `_meta.jetbrains.air`. */
|
||||
airClient?(): boolean;
|
||||
};
|
||||
|
||||
type ModeConfigSession = SessionMode & {
|
||||
@@ -52,6 +75,37 @@ type InitializeSessionModeParams = {
|
||||
allowBypass: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission mode a new Claude query starts in.
|
||||
*
|
||||
* An explicit mode wins. A resume or a load continues in the mode of the last
|
||||
* user record of the transcript. If that mode is unknown or not available now,
|
||||
* for example bypass, the session starts in `defaultMode` of the settings.
|
||||
*/
|
||||
export function resolveInitialPermissionMode(
|
||||
sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown },
|
||||
logger: { error: (...args: unknown[]) => void },
|
||||
allowBypass: boolean,
|
||||
): PermissionMode {
|
||||
if (sources.explicit !== undefined) {
|
||||
return resolvePermissionMode(sources.explicit, logger, allowBypass);
|
||||
}
|
||||
if (sources.resumed !== undefined) {
|
||||
let rejected = false;
|
||||
const mode = resolvePermissionMode(
|
||||
sources.resumed,
|
||||
{ error: () => (rejected = true) },
|
||||
allowBypass,
|
||||
);
|
||||
if (!rejected) return mode;
|
||||
logger.error(
|
||||
`Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` +
|
||||
`not available in this session.`,
|
||||
);
|
||||
}
|
||||
return resolvePermissionMode(sources.defaultMode, logger, allowBypass);
|
||||
}
|
||||
|
||||
/** Owns session-mode policy and the ACP/SDK synchronization it requires. */
|
||||
export class SessionModeManager<S extends SessionMode> {
|
||||
constructor(private readonly options: SessionModeManagerOptions<S>) {}
|
||||
@@ -115,6 +169,12 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
|
||||
syncConfig(session: ModeConfigSession, mode: string): void {
|
||||
const previousMode = session.modes.currentModeId;
|
||||
if (mode !== "plan") {
|
||||
session.prePlanMode = undefined;
|
||||
} else if (previousMode !== "plan") {
|
||||
session.prePlanMode = previousMode;
|
||||
}
|
||||
session.modes = { ...session.modes, currentModeId: mode };
|
||||
session.configOptions = session.configOptions.map((option) =>
|
||||
option.id === MODE_CONFIG_ID && typeof option.currentValue === "string"
|
||||
@@ -231,10 +291,14 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
try {
|
||||
await this.options.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: AUTO_MODE_FALLBACK_NOTICE },
|
||||
},
|
||||
update: noticeOrTranscriptUpdate(
|
||||
AUTO_MODE_FALLBACK_NOTICE,
|
||||
this.options.supportsNotices?.() ?? false,
|
||||
noticeTranscriptText({
|
||||
...AUTO_MODE_FALLBACK_NOTICE,
|
||||
description: AUTO_MODE_FALLBACK_REASON,
|
||||
}),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
// The fallback has already been applied; a failed advisory must not turn
|
||||
@@ -284,30 +348,36 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
|
||||
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 (allowBypass) {
|
||||
@@ -315,7 +385,7 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
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 };
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk";
|
||||
import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent, Session } from "./acp-agent.js";
|
||||
import { ExplicitTitle } from "./session-index/explicit-title.js";
|
||||
|
||||
const MAX_TITLE_LENGTH = 256;
|
||||
|
||||
@@ -42,7 +43,22 @@ type TitleCapableQuery = Query & {
|
||||
* unavailable or yields nothing. */
|
||||
type TitleFallback = { title: string; lastModified: number };
|
||||
|
||||
/** How much of a long text {@link sanitizeTitle} reads first. Collapsed, it
|
||||
* is longer than a title unless it is mostly whitespace. */
|
||||
const TITLE_SCAN_LENGTH = 4096;
|
||||
|
||||
export function sanitizeTitle(text: string): string {
|
||||
if (text.length > TITLE_SCAN_LENGTH) {
|
||||
// A stored summary can be a whole first prompt of megabytes. The title
|
||||
// depends only on the start of the text: collapsing the whitespace of a
|
||||
// prefix gives a prefix of the collapsed text. Two more characters than a
|
||||
// title hold at least one that is not a space (spaces are collapsed), so
|
||||
// the full text is longer than a title and gets the same cut.
|
||||
const head = text.slice(0, TITLE_SCAN_LENGTH).replace(/\s+/g, " ").trimStart();
|
||||
if (head.length >= MAX_TITLE_LENGTH + 2) {
|
||||
return head.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
||||
}
|
||||
}
|
||||
// Replace newlines and collapse whitespace
|
||||
const sanitized = text
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
@@ -84,6 +100,27 @@ export class SessionTitles {
|
||||
* one. Released by {@link reset}, and when generation yields nothing. */
|
||||
private settled = false;
|
||||
|
||||
/** The title a client set, which no generated title replaces. */
|
||||
private readonly explicit = new ExplicitTitle();
|
||||
|
||||
/** The title last stored through {@link setExplicitTitle}, as stored
|
||||
* (with the archive prefix of an archived session): the title the CLI
|
||||
* holds. */
|
||||
private persistedTitle?: string;
|
||||
|
||||
/** Client title changes in flight, and the title state before the first. */
|
||||
private explicitChanges = 0;
|
||||
private beforeExplicit?: { settled: boolean; context: string | undefined };
|
||||
|
||||
get storedTitle(): string | undefined {
|
||||
return this.persistedTitle;
|
||||
}
|
||||
|
||||
/** The title last published, as the client shows it. */
|
||||
get shownTitle(): string | undefined {
|
||||
return this.lastTitle;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly agent: ClaudeAcpAgent,
|
||||
private readonly sessionId: string,
|
||||
@@ -115,8 +152,10 @@ export class SessionTitles {
|
||||
* `conversation_reset`, which mounts a fresh transcript. */
|
||||
reset(): void {
|
||||
this.settled = false;
|
||||
this.explicit.reset();
|
||||
this.context = undefined;
|
||||
this.lastTitle = undefined;
|
||||
this.persistedTitle = undefined;
|
||||
}
|
||||
|
||||
/** Turn-end title handling. `idle` is the SDK's turn-over signal, so it is
|
||||
@@ -131,7 +170,24 @@ export class SessionTitles {
|
||||
* not possible do we fall back to `summary`, which for an SDK-driven session
|
||||
* is just the raw first prompt. */
|
||||
async onTurnEnd(session: Session): Promise<void> {
|
||||
if (this.explicit.settledByClient) {
|
||||
// No title is generated over the one the client set, but a custom title
|
||||
// stored since by someone else (a `/rename`) is adopted, as before.
|
||||
const renames = this.explicit.renames;
|
||||
const info = await this.readSessionInfo(session);
|
||||
// No client change ran during the read, so the stored title is current;
|
||||
// an unchanged one is not published again.
|
||||
const stored = info?.customTitle;
|
||||
if (stored && this.explicit.settledByClient && this.explicit.renames === renames) {
|
||||
await this.publish(stored, info.lastModified);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// While a client title change is in flight, the session info read below
|
||||
// could still hold the previous title.
|
||||
if (this.explicit.active) return;
|
||||
const info = await this.readSessionInfo(session);
|
||||
if (this.explicit.active) return;
|
||||
|
||||
if (info?.customTitle) {
|
||||
this.settled = true;
|
||||
@@ -147,9 +203,13 @@ export class SessionTitles {
|
||||
if (this.canRequest(session)) {
|
||||
this.settled = true;
|
||||
|
||||
void this.requestGenerateTitle(session, fallback).catch((error) => {
|
||||
this.agent.logger.error(`Session ${this.sessionId}: session title update failed: ${error}`);
|
||||
});
|
||||
this.explicit.track(
|
||||
this.requestGenerateTitle(session, fallback).catch((error) => {
|
||||
this.agent.logger.error(
|
||||
`Session ${this.sessionId}: session title update failed: ${error}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -161,6 +221,54 @@ export class SessionTitles {
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a title that the client chose (a rename, an archive or an
|
||||
* unarchive). Settles the title for good, waits for a generation in flight
|
||||
* so that its persisted title cannot land after this one, persists the
|
||||
* title with `persist`, which returns what it stored, if anything, and
|
||||
* publishes `title` unless it is undefined. A change that stored and
|
||||
* publishes nothing leaves the title state as it was. */
|
||||
async setExplicitTitle(
|
||||
publish: string | ((stored: string | undefined) => string) | undefined,
|
||||
persist: () => Promise<string | undefined | void>,
|
||||
): Promise<void> {
|
||||
// The state before the first of overlapping changes, which a change that
|
||||
// fails or names nothing puts back.
|
||||
if (this.explicitChanges++ === 0) {
|
||||
this.beforeExplicit = { settled: this.settled, context: this.context };
|
||||
}
|
||||
const previous = this.beforeExplicit!;
|
||||
this.settled = true;
|
||||
this.context = undefined;
|
||||
let stored: string | undefined;
|
||||
try {
|
||||
await this.explicit.apply(
|
||||
async () => {
|
||||
stored = (await persist()) ?? undefined;
|
||||
if (stored !== undefined) this.persistedTitle = stored;
|
||||
return stored !== undefined || publish !== undefined;
|
||||
},
|
||||
() => {
|
||||
this.settled = previous.settled;
|
||||
this.context ??= previous.context;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (--this.explicitChanges === 0) this.beforeExplicit = undefined;
|
||||
}
|
||||
const title = typeof publish === "function" ? publish(stored) : publish;
|
||||
if (title === undefined) return;
|
||||
this.lastTitle = title;
|
||||
await this.agent.client.sessionUpdate({
|
||||
sessionId: this.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", title },
|
||||
});
|
||||
}
|
||||
|
||||
/** Records a title the CLI took, as soon as it took it. */
|
||||
rememberStoredTitle(title: string): void {
|
||||
this.persistedTitle = title;
|
||||
}
|
||||
|
||||
/** Read the SDK's stored info for this session. A missing session file or read
|
||||
* error is non-fatal: the title is best-effort and another turn will retry. */
|
||||
private async readSessionInfo(session: Session): Promise<SDKSessionInfo | undefined> {
|
||||
@@ -174,7 +282,7 @@ export class SessionTitles {
|
||||
|
||||
/** Notify the client of a title, unless it is the one we last sent. */
|
||||
private async publish(rawTitle: string, lastModified: number): Promise<void> {
|
||||
const title = sanitizeTitle(rawTitle);
|
||||
const title = sanitizeTitle(this.agent.clientTitle?.(rawTitle) ?? rawTitle);
|
||||
if (title === this.lastTitle) {
|
||||
return;
|
||||
}
|
||||
@@ -226,8 +334,8 @@ export class SessionTitles {
|
||||
}
|
||||
|
||||
// A session torn down or replaced while the title was in flight must not
|
||||
// adopt it.
|
||||
if (this.agent.sessions[this.sessionId] !== session) {
|
||||
// adopt it, and a title the client set meanwhile wins.
|
||||
if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
@@ -293,6 +338,48 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -312,6 +399,142 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
describe("on resume", () => {
|
||||
const sessionId = "resumed-mode-session";
|
||||
|
||||
async function setup(records: unknown[]) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "plan" } }),
|
||||
);
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
const transcriptDir = path.join(tempDir, "projects", "-project");
|
||||
await fs.promises.mkdir(transcriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(transcriptDir, `${sessionId}.jsonl`),
|
||||
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
return { agent, projectDir, getCapturedOptions };
|
||||
}
|
||||
|
||||
async function resume(records: unknown[], meta: object = {}) {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup(records);
|
||||
const response = await (agent as any).createSession(
|
||||
{ cwd: projectDir, mcpServers: [], _meta: { disableBuiltInTools: true, ...meta } },
|
||||
{ resume: sessionId },
|
||||
);
|
||||
return { permissionMode: getCapturedOptions().permissionMode, response };
|
||||
}
|
||||
|
||||
let previousUuid: string | null = null;
|
||||
const user = (permissionMode?: string) => {
|
||||
const uuid = crypto.randomUUID();
|
||||
const record = {
|
||||
type: "user",
|
||||
uuid,
|
||||
parentUuid: previousUuid,
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
isSidechain: false,
|
||||
origin: { kind: "human" },
|
||||
message: { role: "user", content: "hello" },
|
||||
...(permissionMode ? { permissionMode } : {}),
|
||||
};
|
||||
previousUuid = uuid;
|
||||
return record;
|
||||
};
|
||||
|
||||
it("continues in the last permission mode of the transcript", async () => {
|
||||
const { permissionMode, response } = await resume([user("plan"), user("acceptEdits")]);
|
||||
|
||||
expect(permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when the transcript has no mode", async () => {
|
||||
const { permissionMode, response } = await resume([user()]);
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("uses permissions.defaultMode when bypass is not available now", async () => {
|
||||
const { permissionMode, response } = await resume([user("bypassPermissions")], {
|
||||
claudeCode: { options: { allowDangerouslySkipPermissions: false } },
|
||||
});
|
||||
|
||||
expect(permissionMode).toBe("plan");
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("continues in the last permission mode on session/load", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([
|
||||
user("plan"),
|
||||
user("acceptEdits"),
|
||||
]);
|
||||
|
||||
const response = await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("acceptEdits");
|
||||
expect(response.modes?.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("keeps the live mode when it recreates the session", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]);
|
||||
const params = { sessionId, cwd: projectDir, _meta: { disableBuiltInTools: true } };
|
||||
await agent.resumeSession({ ...params, mcpServers: [] });
|
||||
await agent.setSessionMode({ sessionId, modeId: "default" });
|
||||
|
||||
// Other MCP servers change the fingerprint, so the agent recreates the query.
|
||||
const response = await agent.resumeSession({
|
||||
...params,
|
||||
mcpServers: [{ name: "other", command: "true", args: [], env: [] }],
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes?.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("keeps the live mode when a sign-out respawn starts a fresh conversation", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]);
|
||||
await agent.resumeSession({
|
||||
sessionId,
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
await agent.setSessionMode({ sessionId, modeId: "default" });
|
||||
const { RequestError } = await import("@agentclientprotocol/sdk");
|
||||
const createSession = (agent as any).createSession.bind(agent);
|
||||
const creations: object[] = [];
|
||||
(agent as any).createSession = async (params: object, options: { resume?: string }) => {
|
||||
creations.push(options);
|
||||
// The CLI never wrote the conversation, so the resume fails.
|
||||
if (options.resume) throw RequestError.resourceNotFound(sessionId);
|
||||
return createSession(params, options);
|
||||
};
|
||||
|
||||
await (agent as any).recreateSignedOutQuery(sessionId, (agent as any).sessions[sessionId]);
|
||||
|
||||
expect(creations).toEqual([
|
||||
{ resume: sessionId, permissionMode: "default" },
|
||||
{ reuseSessionId: sessionId, permissionMode: "default" },
|
||||
]);
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'default' when permissions.defaultMode is invalid", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
@@ -446,30 +669,27 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
|
||||
// A client that is not AIR gets no mode kind.
|
||||
expect(response.modes.availableModes.slice(0, 4)).toEqual([
|
||||
{
|
||||
id: "default",
|
||||
name: "Manual",
|
||||
description: "Always ask before making changes",
|
||||
_meta: { kind: "standard" },
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept edits",
|
||||
description: "Automatically accept all file edits",
|
||||
_meta: { kind: "standard" },
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan",
|
||||
description: "Create a plan before making changes",
|
||||
_meta: { kind: "plan" },
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Claude handles permission decisions",
|
||||
_meta: { kind: "auto_review" },
|
||||
},
|
||||
]);
|
||||
const bypass = response.modes.availableModes[4];
|
||||
@@ -478,7 +698,6 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass permissions",
|
||||
description: "Accepts all permissions",
|
||||
_meta: { kind: "full_access" },
|
||||
});
|
||||
}
|
||||
expect(modeIds).not.toContain("dontAsk");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
98
src/tests/acp-scenarios-compare.test.ts
Normal file
98
src/tests/acp-scenarios-compare.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareWithBaseline } from "./acp-scenarios/compare.js";
|
||||
import type { Recorded } from "./acp-scenarios/harness.js";
|
||||
|
||||
function update(fields: Record<string, unknown>): Recorded {
|
||||
return { kind: "sessionUpdate", payload: { sessionId: "s", update: fields } };
|
||||
}
|
||||
|
||||
const toolCall = update({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "t",
|
||||
title: "Read",
|
||||
status: "pending",
|
||||
_meta: { claudeCode: { toolName: "Read" } },
|
||||
});
|
||||
|
||||
const result = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "t",
|
||||
title: "Read",
|
||||
status: "completed",
|
||||
rawOutput: "text",
|
||||
_meta: { claudeCode: { toolName: "Read" } },
|
||||
};
|
||||
|
||||
const baseline = [toolCall, update(result)];
|
||||
|
||||
/** A copy of `value` without `key`. */
|
||||
function omit(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const copy = { ...value };
|
||||
delete copy[key];
|
||||
return copy;
|
||||
}
|
||||
|
||||
describe("compareWithBaseline", () => {
|
||||
it("accepts the baseline itself", () => {
|
||||
expect(compareWithBaseline(baseline, baseline)).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts an update without a field that did not change", () => {
|
||||
expect(compareWithBaseline(baseline, [toolCall, update(omit(result, "title"))])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a dropped _meta key", () => {
|
||||
expect(compareWithBaseline(baseline, [toolCall, update(omit(result, "_meta"))])).toContainEqual(
|
||||
expect.stringMatching(/^origin\/main sent .*"toolName":"Read"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a changed rawOutput", () => {
|
||||
const changed = update({ ...result, rawOutput: "other" });
|
||||
expect(compareWithBaseline(baseline, [toolCall, changed])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"rawOutput":"other"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an extra update", () => {
|
||||
const extra = update({ sessionUpdate: "tool_call_update", toolCallId: "t", status: "failed" });
|
||||
expect(compareWithBaseline(baseline, [...baseline, extra])).toEqual([
|
||||
expect.stringMatching(/^origin\/main did not send /u),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("available_commands_update", () => {
|
||||
const commands = (...names: string[]) =>
|
||||
update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: names.map((name) => ({ name, description: name })),
|
||||
});
|
||||
|
||||
it("accepts the mcp command of the adapter", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("compact", "mcp")])).toEqual([]);
|
||||
expect(compareWithBaseline([commands()], [commands("mcp")])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports any other extra command", () => {
|
||||
expect(compareWithBaseline([commands()], [commands("mcp", "other")])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"name":"other"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a dropped command", () => {
|
||||
expect(compareWithBaseline([commands("compact")], [commands("mcp")])).toContainEqual(
|
||||
expect.stringMatching(/^origin\/main sent .*"name":"compact"/u),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a changed mcp command that origin/main listed", () => {
|
||||
const changed = update({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "mcp", description: "other" }],
|
||||
});
|
||||
expect(compareWithBaseline([commands("mcp")], [changed])).toContainEqual(
|
||||
expect.stringMatching(/but the adapter sent .*"description":"other"/u),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/tests/acp-scenarios-v2.test.ts
Normal file
171
src/tests/acp-scenarios-v2.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2
|
||||
* surface: through the protocol router, with the SDK's v2 client.
|
||||
*
|
||||
* - Status: each scenario passes, every prompt turn ending with a stop reason,
|
||||
* unless {@link V2_STOPS} names why it stops. Every scenario passes today;
|
||||
* a scenario that the v2 surface cannot run yet goes in the table.
|
||||
* - Schema: every message the agent sends, also in a scenario that stops, is
|
||||
* valid against the draft v2 schema of the SDK.
|
||||
* - Tool calls: the first report of each tool call has a title. v2 has no
|
||||
* `tool_call`, so a report of a tool call that the client has not seen
|
||||
* creates it, where v1 drops it.
|
||||
* - Golden files: `acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
|
||||
* passing scenario. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u`
|
||||
* to update them.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import {
|
||||
resetIds,
|
||||
runScenarioV2,
|
||||
type ScenarioRunV2,
|
||||
type WireRecorded,
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import { canonical } from "./acp-scenarios/compare.js";
|
||||
import { validateV2Message } from "./acp-scenarios/schema.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// The recordings replace only the ids that the run generated, so the harness
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
const id = actual.randomUUID(...args);
|
||||
harness.noteGeneratedId(id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PASSES = "passes";
|
||||
|
||||
/**
|
||||
* The scenarios that stop on the v2 surface, each with the reason, which names
|
||||
* the step of `docs/acp-v2.md` that removes it. Every other scenario passes.
|
||||
*/
|
||||
const V2_STOPS: Record<string, string> = {};
|
||||
|
||||
const statusOf = (scenario: string) => V2_STOPS[scenario] ?? PASSES;
|
||||
|
||||
const runs = new Map<string, ScenarioRunV2>();
|
||||
let configDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// A run must not depend on the machine: no remote login, no Claude CLI, no
|
||||
// user settings, and the bypass mode also for root.
|
||||
for (const name of ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER"]) {
|
||||
vi.stubEnv(name, "");
|
||||
}
|
||||
vi.stubEnv("CLAUDE_CODE_REMOTE", "");
|
||||
vi.stubEnv("ANTHROPIC_MODEL", "");
|
||||
vi.stubEnv("IS_SANDBOX", "1");
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { acpProtocolRouter } = await import("../serve.js");
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
const quiet = { log: () => {}, error: () => {} };
|
||||
runs.set(
|
||||
scenario.name,
|
||||
await runScenarioV2(
|
||||
(stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream),
|
||||
scenario,
|
||||
),
|
||||
);
|
||||
await Promise.all(agents.map((agent) => agent.dispose()));
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(scenario: string): ScenarioRunV2 {
|
||||
const recorded = runs.get(scenario);
|
||||
if (!recorded) throw new Error(`no v2 run of ${scenario}`);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
function toJsonLines(recorded: WireRecorded[]): string {
|
||||
return recorded.map((record) => `${canonical(record)}\n`).join("");
|
||||
}
|
||||
|
||||
describe("ACP v2 scenarios", () => {
|
||||
it("pass, or stop as the table says", () => {
|
||||
const table = (status: (scenario: string) => string) =>
|
||||
Object.fromEntries(SCENARIOS.map((scenario) => [scenario.name, status(scenario.name)]));
|
||||
expect(table((scenario) => run(scenario).status)).toEqual(table(statusOf));
|
||||
});
|
||||
|
||||
describe("schema check", () => {
|
||||
const chunk = (update: Record<string, unknown>): WireRecorded => ({
|
||||
kind: "notification",
|
||||
method: "session/update",
|
||||
payload: { sessionId: "s", update: { sessionUpdate: "agent_message_chunk", ...update } },
|
||||
});
|
||||
|
||||
it("rejects a message that breaks the v2 schema", () => {
|
||||
const content = { type: "text", text: "hi" };
|
||||
expect(validateV2Message(chunk({ messageId: "m", content }))).toEqual([]);
|
||||
// v2 requires the id of the message a chunk belongs to.
|
||||
expect(validateV2Message(chunk({ content }))).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
expect(run(scenario).raw.flatMap((message) => validateV2Message(message))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool calls are reported with a title first", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", (scenario) => {
|
||||
const reported = new Set<string>();
|
||||
const untitled: string[] = [];
|
||||
for (const message of run(scenario).raw) {
|
||||
if (message.kind !== "notification" || message.method !== "session/update") continue;
|
||||
const { sessionId, update } = message.payload as {
|
||||
sessionId: string;
|
||||
update: { sessionUpdate: string; toolCallId?: string; title?: unknown };
|
||||
};
|
||||
if (update.sessionUpdate !== "tool_call_update") continue;
|
||||
const key = `${sessionId} ${update.toolCallId}`;
|
||||
if (!reported.has(key) && typeof update.title !== "string") untitled.push(key);
|
||||
reported.add(key);
|
||||
}
|
||||
expect(untitled).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v2 golden files", () => {
|
||||
const passing = SCENARIOS.map((scenario) => scenario.name).filter(
|
||||
(scenario) => statusOf(scenario) === PASSES,
|
||||
);
|
||||
it.each(passing)("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
994
src/tests/acp-scenarios.test.ts
Normal file
994
src/tests/acp-scenarios.test.ts
Normal file
@@ -0,0 +1,994 @@
|
||||
/**
|
||||
* The outbound ACP traffic of every scenario in `acp-scenarios/scenarios.ts`,
|
||||
* for three client profiles: a plain ACP client, Zed, and JetBrains AIR.
|
||||
*
|
||||
* - Recordings are JSON Lines: one outbound message on each line, with sorted
|
||||
* keys. `normalize` in `harness.ts` replaces only exact run-specific values:
|
||||
* the generated ids, the paths, and the argv and version of this process.
|
||||
* - AIR golden files: `acp-scenarios/__snapshots__/air/<scenario>.jsonl`.
|
||||
* Run `npx vitest run src/tests/acp-scenarios.test.ts -u` to update them.
|
||||
* - Schema: every message is valid against the ACP schema of the SDK.
|
||||
* - Plain and Zed: the same information as origin/main
|
||||
* (`acp-scenarios/origin-main/<profile>/<scenario>.jsonl`). `compare.ts`
|
||||
* allows only the documented differences. These profiles have no golden
|
||||
* files of their own. `origin-main/zed/` holds a scenario only when its
|
||||
* recording differs from the plain client.
|
||||
* - Zed: the Zed conventions and the upstream `_meta` keys.
|
||||
* - AIR: the AIR extensions of `docs/air-extensions.md`, each fact once.
|
||||
*
|
||||
* To record the origin/main baseline again, copy `src/tests/acp-scenarios/`
|
||||
* and this file into a checkout of origin/main, and run this file there with
|
||||
* `ACP_SCENARIO_BASELINE_DIR=<dir>`. Then copy `<dir>/plain` and `<dir>/zed`
|
||||
* to `acp-scenarios/origin-main/`.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
AIR_CAPABILITY_NAMES,
|
||||
assistantTurn,
|
||||
normalize,
|
||||
PROFILES,
|
||||
type Profile,
|
||||
type Recorded,
|
||||
resetIds,
|
||||
result,
|
||||
runScenario,
|
||||
type Scenario,
|
||||
type ScenarioRun,
|
||||
streamMessage,
|
||||
toolCall,
|
||||
toolResult,
|
||||
} from "./acp-scenarios/harness.js";
|
||||
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
|
||||
import {
|
||||
AIR_ONLY_CLAUDE_CODE_KEYS,
|
||||
AIR_ONLY_META_KEYS,
|
||||
canonical,
|
||||
compareWithBaseline,
|
||||
} from "./acp-scenarios/compare.js";
|
||||
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
|
||||
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
query: harness.mockedQuery,
|
||||
getSessionMessages: harness.mockedSessionMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// The recordings replace only the ids that the run generated, so the harness
|
||||
// learns each id that `randomUUID` returns.
|
||||
vi.mock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
const harness = await import("./acp-scenarios/harness.js");
|
||||
return {
|
||||
...actual,
|
||||
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
|
||||
const id = actual.randomUUID(...args);
|
||||
harness.noteGeneratedId(id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const baselineDir = process.env.ACP_SCENARIO_BASELINE_DIR;
|
||||
const profiles = Object.values(PROFILES);
|
||||
const runs = new Map<string, ScenarioRun>();
|
||||
const key = (profile: Profile["name"], scenario: string) => `${profile}/${scenario}`;
|
||||
let configDir: string;
|
||||
let Agent: Parameters<typeof runScenario>[0];
|
||||
|
||||
beforeAll(async () => {
|
||||
// A run must not depend on the machine: no remote login, no Claude CLI, no
|
||||
// user settings, and the bypass mode also for root.
|
||||
for (const name of ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "NO_BROWSER"]) {
|
||||
vi.stubEnv(name, "");
|
||||
}
|
||||
vi.stubEnv("CLAUDE_CODE_REMOTE", "");
|
||||
vi.stubEnv("ANTHROPIC_MODEL", "");
|
||||
vi.stubEnv("IS_SANDBOX", "1");
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
Agent = ClaudeAcpAgent;
|
||||
for (const profile of profiles) {
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
runs.set(
|
||||
key(profile.name, scenario.name),
|
||||
await runScenario(ClaudeAcpAgent, profile, scenario),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A recording as JSON Lines: one message on each line, with sorted keys. */
|
||||
function toJsonLines(recorded: Recorded[]): string {
|
||||
return recorded.map((record) => `${canonical(record)}\n`).join("");
|
||||
}
|
||||
|
||||
function fromJsonLines(text: string): Recorded[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as Recorded);
|
||||
}
|
||||
|
||||
function run(profile: Profile["name"], scenario: string): ScenarioRun {
|
||||
const recorded = runs.get(key(profile, scenario));
|
||||
if (!recorded) throw new Error(`no run of ${scenario} for ${profile}`);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/** Runs one more scenario for AIR without some AIR capabilities. */
|
||||
async function runAir(scenario: Scenario, without: string[] = []): Promise<Recorded[]> {
|
||||
resetIds();
|
||||
const profile: Profile = {
|
||||
name: "air",
|
||||
capabilities: {
|
||||
...PROFILES.air.capabilities,
|
||||
_meta: {
|
||||
...PROFILES.air.capabilities._meta,
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: AIR_CAPABILITY_NAMES.filter((name) => !without.includes(name)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return (await runScenario(Agent, profile, scenario)).raw;
|
||||
}
|
||||
|
||||
function updates(recorded: Recorded[]): Record<string, any>[] {
|
||||
return recorded
|
||||
.filter((record) => record.kind === "sessionUpdate")
|
||||
.map((record) => (record.payload as { update: Record<string, any> }).update);
|
||||
}
|
||||
|
||||
function toolCallReports(recorded: Recorded[], toolCallId?: string): Record<string, any>[] {
|
||||
return updates(recorded).filter(
|
||||
(update) =>
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
|
||||
(toolCallId === undefined || update.toolCallId === toolCallId),
|
||||
);
|
||||
}
|
||||
|
||||
function permissionRequests(recorded: Recorded[]): Record<string, any>[] {
|
||||
return recorded
|
||||
.filter((record) => record.kind === "requestPermission")
|
||||
.map((record) => record.payload as Record<string, any>);
|
||||
}
|
||||
|
||||
/** Every `_meta` object in a message, with the path to it. */
|
||||
function metaObjects(value: unknown, at = "$"): { at: string; meta: Record<string, unknown> }[] {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
if (Array.isArray(value)) return value.flatMap((item, i) => metaObjects(item, `${at}[${i}]`));
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([k, item]) => [
|
||||
...(k === "_meta" && item && typeof item === "object" && !Array.isArray(item)
|
||||
? [{ at: `${at}._meta`, meta: item as Record<string, unknown> }]
|
||||
: []),
|
||||
...metaObjects(item, `${at}.${k}`),
|
||||
]);
|
||||
}
|
||||
|
||||
/** The origin/main recording of a scenario. Zed has a file only where it differs from the plain client. */
|
||||
function readBaseline(profile: "plain" | "zed", scenario: string): string {
|
||||
const file = (name: string) =>
|
||||
path.join(here, "acp-scenarios", "origin-main", name, `${scenario}.jsonl`);
|
||||
return fs.readFileSync(
|
||||
profile === "zed" && !fs.existsSync(file("zed")) ? file("plain") : file(profile),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe.runIf(baselineDir)("the origin/main baseline", () => {
|
||||
it("writes the recordings of the plain and Zed profiles", () => {
|
||||
for (const profile of ["plain", "zed"] as const) {
|
||||
fs.mkdirSync(path.join(baselineDir!, profile), { recursive: true });
|
||||
for (const scenario of SCENARIOS) {
|
||||
const text = toJsonLines(run(profile, scenario.name).normalized);
|
||||
if (profile === "zed" && text === toJsonLines(run("plain", scenario.name).normalized)) {
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(path.join(baselineDir!, profile, `${scenario.name}.jsonl`), text);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
describe("normalize", () => {
|
||||
const cwd = "/tmp/acp-scenario-x";
|
||||
const session = "aaaaaaaa-0000-4000-8000-000000000001";
|
||||
const first = "bbbbbbbb-0000-4000-8000-000000000002";
|
||||
const second = "cccccccc-0000-4000-8000-000000000003";
|
||||
const scripted = "00000000-0000-4000-8000-00000000c001";
|
||||
const version = JSON.parse(
|
||||
fs.readFileSync(path.join(here, "..", "..", "package.json"), "utf8"),
|
||||
).version;
|
||||
const argv = process.argv.slice(1);
|
||||
|
||||
it("keeps a UUID that the run did not generate", () => {
|
||||
expect(normalize({ toolCallId: scripted }, cwd, session, new Set([first]))).toEqual({
|
||||
toolCallId: scripted,
|
||||
});
|
||||
});
|
||||
|
||||
it("names the generated ids in the order of their first appearance", () => {
|
||||
const generated = new Set([first, second]);
|
||||
const reports = (ids: string[]) =>
|
||||
normalize(
|
||||
ids.map((id) => ({ sessionId: session, toolCallId: id })),
|
||||
cwd,
|
||||
session,
|
||||
generated,
|
||||
);
|
||||
expect(reports([first, first])).toEqual([
|
||||
{ sessionId: "<session>", toolCallId: "<id-1>" },
|
||||
{ sessionId: "<session>", toolCallId: "<id-1>" },
|
||||
]);
|
||||
// A report that names another generated id, or the session id, still differs.
|
||||
expect(reports([first, second])).not.toEqual(reports([first, first]));
|
||||
expect(reports([first, session])).not.toEqual(reports([first, first]));
|
||||
});
|
||||
|
||||
it("replaces only the exact argv prefix of this process", () => {
|
||||
expect(argv.length).toBeGreaterThan(0);
|
||||
expect(normalize({ args: [...argv, "--cli", "login"] }, cwd, session)).toEqual({
|
||||
args: ["<argv>", "--cli", "login"],
|
||||
});
|
||||
// An extra argument stays in the recording, before or after the prefix.
|
||||
expect(normalize({ args: [...argv, "--inspect", "--cli"] }, cwd, session)).toEqual({
|
||||
args: ["<argv>", "--inspect", "--cli"],
|
||||
});
|
||||
const before = ["--inspect", ...argv, "--cli"];
|
||||
const { args } = normalize({ args: before }, cwd, session) as { args: string[] };
|
||||
expect(args).toHaveLength(before.length);
|
||||
expect(args).not.toContain("<argv>");
|
||||
expect(normalize({ args: ["other.js", "--cli"] }, cwd, session)).toEqual({
|
||||
args: ["other.js", "--cli"],
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces only the version of this adapter and the path of this Node binary", () => {
|
||||
expect(normalize({ version, command: process.execPath }, cwd, session)).toEqual({
|
||||
version: "<version>",
|
||||
command: "<executable>",
|
||||
});
|
||||
expect(normalize({ version: "9.9.9", command: "/usr/bin/node" }, cwd, session)).toEqual({
|
||||
version: "9.9.9",
|
||||
command: "/usr/bin/node",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps durations and other numbers", () => {
|
||||
expect(normalize({ durationMs: 7, totalDurationMs: 10 }, cwd, session)).toEqual({
|
||||
durationMs: 7,
|
||||
totalDurationMs: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema check", () => {
|
||||
const none = new Set<string>();
|
||||
const elicitation = (url: string): Recorded => ({
|
||||
kind: "createElicitation",
|
||||
payload: {
|
||||
sessionId: "s",
|
||||
message: "Sign in",
|
||||
mode: "url",
|
||||
elicitationId: "e",
|
||||
url,
|
||||
},
|
||||
});
|
||||
const location = (line: number): Recorded => ({
|
||||
kind: "sessionUpdate",
|
||||
payload: {
|
||||
sessionId: "s",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "t",
|
||||
locations: [{ path: "/a.ts", line }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("rejects a value that breaks a standard format", () => {
|
||||
expect(validateRecorded(elicitation("https://example.com/login"), none)).toEqual([]);
|
||||
expect(validateRecorded(elicitation("not a uri"), none)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a value that breaks an ACP integer format", () => {
|
||||
expect(validateRecorded(location(2 ** 32 - 1), none)).toEqual([]);
|
||||
expect(validateRecorded(location(2 ** 32), none)).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("air golden files", () => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run("air", scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "air", `${scenario}.jsonl`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(profiles.map((profile) => profile.name))("%s ACP schema", (profile) => {
|
||||
const extensions = profile === "air" ? EXTENSION_SESSION_UPDATES : new Set<string>();
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends only valid messages",
|
||||
(scenario) => {
|
||||
const errors = run(profile, scenario).raw.flatMap((record) =>
|
||||
validateRecorded(record, extensions),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(profiles.map((profile) => profile.name))("%s session ids", (profile) => {
|
||||
// Only AIR negotiates native subagent sessions. There a child session
|
||||
// gets updates after its parent announced it with `subagent_spawned`.
|
||||
const nativeSubagents = profile === "air";
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends every session/update to the ACP session",
|
||||
(scenario) => {
|
||||
const { sessionId, raw } = run(profile, scenario);
|
||||
const sessions = new Set([sessionId]);
|
||||
const wrong: string[] = [];
|
||||
for (const record of raw) {
|
||||
if (record.kind !== "sessionUpdate") continue;
|
||||
const payload = record.payload as { sessionId: string; update: Record<string, any> };
|
||||
if (!sessions.has(payload.sessionId)) {
|
||||
wrong.push(`${payload.sessionId}: ${payload.update.sessionUpdate}`);
|
||||
continue;
|
||||
}
|
||||
if (nativeSubagents && payload.update.sessionUpdate === "subagent_spawned") {
|
||||
sessions.add(payload.update.subagentSessionId);
|
||||
}
|
||||
}
|
||||
expect(wrong).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.each(["plain", "zed"] as const)("%s client", (profile) => {
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s carries the same information as origin/main",
|
||||
(scenario) => {
|
||||
const baseline = fromJsonLines(readBaseline(profile, scenario));
|
||||
expect(compareWithBaseline(baseline, run(profile, scenario).normalized)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SCENARIOS.map((scenario) => scenario.name))(
|
||||
"%s sends no key that exists only for AIR",
|
||||
(scenario) => {
|
||||
const found = metaObjects(run(profile, scenario).raw).flatMap(({ at, meta }) => [
|
||||
...Object.keys(meta)
|
||||
.filter((k) => AIR_ONLY_META_KEYS.has(k))
|
||||
.map((k) => `${at}.${k}`),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {})
|
||||
.filter((k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k))
|
||||
.map((k) => `${at}.claudeCode.${k}`),
|
||||
]);
|
||||
expect(found).toEqual([]);
|
||||
expect(
|
||||
updates(run(profile, scenario).raw).filter((update) =>
|
||||
EXTENSION_SESSION_UPDATES.has(update.sessionUpdate),
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("plain client", () => {
|
||||
it("gets the Bash output as a code block and the description as content", () => {
|
||||
const reports = toolCallReports(run("plain", "bash-foreground").raw, "toolu_bash");
|
||||
expect(reports[0]).not.toHaveProperty("_meta.terminal_info");
|
||||
expect(reports.some((r) => JSON.stringify(r.content ?? []).includes("List files"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reports.at(-2)).toMatchObject({
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "```console\na.ts\nb.ts\n```" } },
|
||||
],
|
||||
rawOutput: "a.ts\nb.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets the streamed partial input as rawInput, and the Write text in rawInput", () => {
|
||||
const reports = toolCallReports(run("plain", "write-new").raw, "toolu_write");
|
||||
expect(reports[0]).toMatchObject({ sessionUpdate: "tool_call", rawInput: {} });
|
||||
expect(reports.some((r) => r.rawInput?.file_path && !("content" in r.rawInput))).toBe(true);
|
||||
expect(reports.some((r) => r.rawInput?.content === "export const x = 1;\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("gets the whole tool call again in a permission request", () => {
|
||||
const [request] = permissionRequests(run("plain", "edit-with-permission").raw);
|
||||
expect(request.toolCall).toMatchObject({
|
||||
toolCallId: "toolu_edit",
|
||||
name: "Edit",
|
||||
status: "pending",
|
||||
kind: "edit",
|
||||
title: "Edit src/app.ts",
|
||||
rawInput: {
|
||||
old_string: "const value = 1;",
|
||||
new_string: "const value = 2;",
|
||||
},
|
||||
content: [{ type: "diff", oldText: "const value = 1;", newText: "const value = 2;" }],
|
||||
});
|
||||
expect(request).not.toHaveProperty("_meta");
|
||||
});
|
||||
|
||||
it("gets the question of a single AskUserQuestion as the title", () => {
|
||||
const reports = toolCallReports(run("plain", "ask-user-question").raw, "toolu_ask");
|
||||
expect(reports.map((r) => r.title)).toContain("Which database?");
|
||||
});
|
||||
|
||||
it("gets the upstream NotebookEdit rendering and the result text", () => {
|
||||
const reports = toolCallReports(run("plain", "notebook-edit").raw, "toolu_nb");
|
||||
expect(reports[0]).toMatchObject({ title: "NotebookEdit", kind: "other", content: [] });
|
||||
expect(reports.at(-1)).toMatchObject({
|
||||
status: "completed",
|
||||
rawOutput: "Updated cell cell-1 with print('hi')",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets every Task* plan, also a repeated one", () => {
|
||||
const plans = updates(run("plain", "task-create-update").raw).filter(
|
||||
(update) => update.sessionUpdate === "plan",
|
||||
);
|
||||
expect(plans.map((plan) => plan.entries[0]?.status)).toEqual([
|
||||
"pending",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zed", () => {
|
||||
const zed = (scenario: string) => run("zed", scenario).raw;
|
||||
|
||||
it("keeps terminal_info, terminal_output, and terminal_exit", () => {
|
||||
const reports = toolCallReports(zed("bash-foreground"), "toolu_bash");
|
||||
expect(reports[0]).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
content: [{ type: "terminal", terminalId: "toolu_bash" }],
|
||||
_meta: { terminal_info: { terminal_id: "toolu_bash" } },
|
||||
});
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_meta: { terminal_output: { terminal_id: "toolu_bash", data: "a.ts\nb.ts" } },
|
||||
}),
|
||||
);
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
_meta: expect.objectContaining({
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
for (const report of reports) {
|
||||
expect(report._meta ?? {}).not.toHaveProperty("terminal_output_delta");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the upstream claudeCode keys and the full PostToolUse toolResponse", () => {
|
||||
const reports = toolCallReports(zed("bash-foreground"), "toolu_bash");
|
||||
expect(reports[0]._meta.claudeCode).toEqual({ toolName: "Bash" });
|
||||
expect(reports.at(-1)?._meta.claudeCode).toEqual({
|
||||
toolName: "Bash",
|
||||
toolResponse: { stdout: "a.ts\nb.ts", stderr: "", interrupted: false, isImage: false },
|
||||
});
|
||||
const child = toolCallReports(zed("subagent-task-legacy"), "toolu_sub_read");
|
||||
expect(child[0]._meta.claudeCode).toEqual({
|
||||
toolName: "Read",
|
||||
parentToolUseId: "toolu_task",
|
||||
});
|
||||
// ACP does not merge `_meta` keys, so each update names the parent again.
|
||||
for (const report of child) {
|
||||
expect(report._meta.claudeCode).toMatchObject({
|
||||
toolName: "Read",
|
||||
parentToolUseId: "toolu_task",
|
||||
});
|
||||
}
|
||||
const [childRequest] = permissionRequests(zed("subagent-task-legacy"));
|
||||
expect(childRequest.toolCall._meta).toEqual({
|
||||
claudeCode: { toolName: "Bash", parentToolUseId: "toolu_task" },
|
||||
});
|
||||
const [mcpRequest] = permissionRequests(zed("mcp-tool"));
|
||||
expect(mcpRequest.toolCall._meta).toEqual({
|
||||
claudeCode: {
|
||||
toolName: "mcp__docs__search",
|
||||
mcpServer: { name: "docs", source: "project" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the full permission denial toolResponse", () => {
|
||||
const denial = toolCallReports(zed("permission-denied"), "toolu_denied").find(
|
||||
(report) => report.status === "failed",
|
||||
);
|
||||
expect(denial?._meta.claudeCode.toolResponse).toEqual({
|
||||
decisionReasonType: "rule",
|
||||
decisionReason: "Denied by rule Bash(rm:*)",
|
||||
message: "Permission to use Bash has been denied.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the _claude/* keys", () => {
|
||||
const [elicitation] = zed("ask-user-question")
|
||||
.filter((record) => record.kind === "createElicitation")
|
||||
.map((record) => record.payload as Record<string, any>);
|
||||
const options = elicitation.requestedSchema.properties.question_0.oneOf;
|
||||
expect(options[0]._meta).toHaveProperty(["_claude/askUserQuestionOption"]);
|
||||
const usage = updates(zed("rate-limit-and-origin")).filter(
|
||||
(update) => update.sessionUpdate === "usage_update",
|
||||
);
|
||||
expect(usage.some((update) => update._meta?.["_claude/rateLimit"])).toBe(true);
|
||||
expect(usage.some((update) => update._meta?.["_claude/origin"])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps promptQueueing and steering", () => {
|
||||
const initialize = zed("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
expect(initialize.agentCapabilities._meta.claudeCode).toEqual({ promptQueueing: true });
|
||||
expect(initialize._meta).toEqual({ steering: { supported: true } });
|
||||
});
|
||||
|
||||
it("sends terminal-auth commands that rerun this adapter with --cli", () => {
|
||||
// The raw traffic, because the recordings normalize the machine paths.
|
||||
const initialize = zed("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
const argv = process.argv.slice(1);
|
||||
expect(initialize.authMethods).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "claude-ai-login",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
_meta: {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...argv, "--cli", "auth", "login", "--claudeai"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "console-login",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
_meta: {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...argv, "--cli", "auth", "login", "--console"],
|
||||
label: "Anthropic Console Login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AIR", () => {
|
||||
const air = (scenario: string) => run("air", scenario).raw;
|
||||
const airMeta = (meta: Record<string, any> | undefined) => meta?.jetbrains?.air;
|
||||
|
||||
it("gets the AIR capabilities and the goal capability under jetbrains.air", () => {
|
||||
const initialize = air("session-setup").find((record) => record.kind === "initialize")!
|
||||
.payload as Record<string, any>;
|
||||
expect(initialize._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: expect.arrayContaining(
|
||||
AIR_CAPABILITY_NAMES.filter((name) => name !== "rawInputRendering"),
|
||||
),
|
||||
goal: expect.objectContaining({ version: 1 }),
|
||||
},
|
||||
},
|
||||
steering: { supported: true },
|
||||
});
|
||||
const session = air("session-setup").find((record) => record.kind === "newSession")!
|
||||
.payload as Record<string, any>;
|
||||
for (const mode of session.modes.availableModes) {
|
||||
expect(mode._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, kind: expect.any(String) } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("gets commandTitle, subagent, and skill under jetbrains.air, each once", async () => {
|
||||
const bash = toolCallReports(air("bash-foreground"), "toolu_bash");
|
||||
expect(bash.filter((r) => airMeta(r._meta)?.commandTitle === "List files")).toHaveLength(1);
|
||||
// A native subagent session replaces the tool call of the subagent, so
|
||||
// the marker shows without native subagent sessions.
|
||||
const [nested] = toolCallReports(
|
||||
await runAir(
|
||||
{
|
||||
name: "nested-agent",
|
||||
turns: [
|
||||
async function* () {
|
||||
yield* assistantTurn("msg_nested", [
|
||||
{ type: "tool_use", id: "toolu_nested", name: "Agent", input: { prompt: "Go" } },
|
||||
]);
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
},
|
||||
["nativeSubagentSessions"],
|
||||
),
|
||||
"toolu_nested",
|
||||
);
|
||||
expect(airMeta(nested?._meta)?.subagent).toBe(true);
|
||||
const skill = toolCallReports(air("skill"), "toolu_skill");
|
||||
expect(skill.filter((r) => airMeta(r._meta)?.skill)).toEqual([
|
||||
expect.objectContaining({
|
||||
_meta: expect.objectContaining({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
skill: { name: "commits", path: expect.stringMatching(/SKILL\.md$/) },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("gets the permission, customAnswer, goal, and contextCompaction keys", () => {
|
||||
const [request] = permissionRequests(air("bash-foreground"));
|
||||
expect(request._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, permission: { version: 1, title: "ls -la" } } },
|
||||
});
|
||||
const elicitation = air("ask-user-question").find(
|
||||
(record) => record.kind === "createElicitation",
|
||||
)!.payload as Record<string, any>;
|
||||
expect(elicitation.requestedSchema.properties.question_0_custom._meta).toEqual({
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { version: 1, customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
});
|
||||
const goal = updates(air("goal")).find((u) => u.sessionUpdate === "session_info_update");
|
||||
expect(goal?._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
goal: expect.objectContaining({ objective: "Ship the feature", status: "active" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const compaction = toolCallReports(air("compaction-legacy"));
|
||||
expect(compaction.at(-1)?._meta).toEqual({
|
||||
jetbrains: {
|
||||
air: {
|
||||
contextCompaction: expect.objectContaining({ version: 1, preTokens: 1000 }),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(compaction.at(-1)).not.toHaveProperty("rawOutput");
|
||||
});
|
||||
|
||||
it("sends no upstream copy of an AIR key", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const { at, meta } of metaObjects(air(scenario.name))) {
|
||||
const duplicates = [
|
||||
// Released AIR versions read only the legacy custom answer key, so AIR gets both.
|
||||
...Object.keys(meta).filter(
|
||||
(k) =>
|
||||
AIR_ONLY_META_KEYS.has(k) &&
|
||||
k !== "jetbrains" &&
|
||||
k !== LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {}).filter(
|
||||
(k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k),
|
||||
),
|
||||
];
|
||||
expect(duplicates, `${scenario.name} ${at}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("sends each tool fact in one field", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const report of toolCallReports(air(scenario.name))) {
|
||||
const where = `${scenario.name} ${report.toolCallId}`;
|
||||
// Output is never copied into rawOutput when content carries it.
|
||||
if (report.rawOutput !== undefined) {
|
||||
expect(report.content ?? [], where).toEqual([]);
|
||||
}
|
||||
// Terminal output goes to deltas, never to snapshots.
|
||||
expect(report._meta ?? {}, where).not.toHaveProperty("terminal_output");
|
||||
}
|
||||
}
|
||||
const write = toolCallReports(air("write-new"), "toolu_write");
|
||||
for (const report of write) expect(report.rawInput ?? {}).not.toHaveProperty("content");
|
||||
expect(write[0]).not.toHaveProperty("rawInput");
|
||||
});
|
||||
|
||||
describe("ExitPlanMode with a plan file", () => {
|
||||
const plan = "# Plan\n1. Do it";
|
||||
/**
|
||||
* The CLI streams the input that the model wrote, which has no plan.
|
||||
* The complete message and canUseTool get the text and the path of the
|
||||
* plan file. The structured result names the file again.
|
||||
*/
|
||||
const planFileScenario = (options: { file: boolean; reject?: boolean }): Scenario => ({
|
||||
name: "exit-plan-file",
|
||||
...(options.file ? { files: { "plans/plan.md": plan } } : {}),
|
||||
...(options.reject ? { permission: "reject_once" } : {}),
|
||||
turns: [
|
||||
async function* (ctx) {
|
||||
const planFilePath = path.join(ctx.cwd, "plans", "plan.md");
|
||||
const input = { plan, planFilePath };
|
||||
yield* streamMessage("msg_toolu_plan", [
|
||||
{ type: "tool_use", id: "toolu_plan", name: "ExitPlanMode", input: {} },
|
||||
]);
|
||||
yield* toolCall(
|
||||
ctx,
|
||||
{ id: "toolu_plan", name: "ExitPlanMode", input },
|
||||
options.reject
|
||||
? {
|
||||
ask: true,
|
||||
isError: true,
|
||||
content: "```\nThe user doesn't want to proceed with this tool use.\n```",
|
||||
}
|
||||
: {
|
||||
ask: true,
|
||||
content: `User has approved your plan.\n\n## Approved Plan:\n${plan}`,
|
||||
structured: { plan, isAgent: false, filePath: planFilePath },
|
||||
},
|
||||
);
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
});
|
||||
it("sends the path and no plan text in every report", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true }));
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
const [request] = permissionRequests(recorded);
|
||||
const withInput = [...reports, request.toolCall].filter((r) => "rawInput" in r);
|
||||
expect(withInput.length).toBeGreaterThan(0);
|
||||
for (const report of withInput) {
|
||||
expect(report.rawInput).toEqual({ planFilePath: expect.stringMatching(/plan\.md$/) });
|
||||
expect(path.isAbsolute(report.rawInput.planFilePath)).toBe(true);
|
||||
}
|
||||
expect(reports[0]).toMatchObject({ sessionUpdate: "tool_call" });
|
||||
expect(reports[0]).not.toHaveProperty("rawInput");
|
||||
expect(reports.at(-1)).toMatchObject({ status: "completed", title: "Exited Plan Mode" });
|
||||
expect(JSON.stringify([reports, request])).not.toContain("Do it");
|
||||
});
|
||||
|
||||
it("sends the path with a rejection", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true, reject: true }));
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ planFilePath: expect.any(String) });
|
||||
expect(reports.at(-1)).toMatchObject({ status: "failed" });
|
||||
expect(JSON.stringify([reports, request])).not.toContain("Do it");
|
||||
});
|
||||
|
||||
it("sends the plan text when the plan file does not exist", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: false }));
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
});
|
||||
|
||||
it("sends the plan text to an AIR client without planFile", async () => {
|
||||
const recorded = await runAir(planFileScenario({ file: true }), ["planFile"]);
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
});
|
||||
|
||||
it("sends the whole input to a client that is not AIR", async () => {
|
||||
for (const profile of [PROFILES.plain, PROFILES.zed]) {
|
||||
resetIds();
|
||||
const recorded = (await runScenario(Agent, profile, planFileScenario({ file: true })))
|
||||
.raw;
|
||||
const [request] = permissionRequests(recorded);
|
||||
expect(request.toolCall.rawInput).toEqual({ plan, planFilePath: expect.any(String) });
|
||||
expect(request.toolCall.content).toEqual([
|
||||
{ type: "content", content: { type: "text", text: plan } },
|
||||
]);
|
||||
const reports = toolCallReports(recorded, "toolu_plan");
|
||||
expect(reports.some((r) => r.rawInput?.plan === plan)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("drops every update of a tool call of a finished child session", () => {
|
||||
const recorded = air("subagent-late-child-update");
|
||||
const finished = recorded.findIndex(
|
||||
(record) =>
|
||||
record.kind === "sessionUpdate" &&
|
||||
(record.payload as { update: Record<string, any> }).update.sessionUpdate ===
|
||||
"subagent_state_update",
|
||||
);
|
||||
expect(finished).toBeGreaterThan(0);
|
||||
const late = recorded
|
||||
.slice(finished)
|
||||
.filter((record) => record.kind === "sessionUpdate")
|
||||
.map((record) => record.payload as { sessionId: string; update: Record<string, any> })
|
||||
.filter(({ update }) => update.toolCallId === "toolu_late_read");
|
||||
expect(late).toEqual([]);
|
||||
for (const record of recorded.filter((r) => r.kind === "sessionUpdate")) {
|
||||
const { sessionId, update } = record.payload as {
|
||||
sessionId: string;
|
||||
update: Record<string, any>;
|
||||
};
|
||||
if (update.toolCallId === "toolu_late_read") expect(sessionId).toBe("agent_late");
|
||||
}
|
||||
});
|
||||
|
||||
it("gets the Write patch, but no file text in the PostToolUse toolResponse", async () => {
|
||||
const scenario = writtenFileScenario();
|
||||
const hookReport = (recorded: Recorded[]) =>
|
||||
toolCallReports(recorded, "toolu_write").find(
|
||||
(report) =>
|
||||
report._meta?.claudeCode?.toolResponse !== undefined || report.content?.[0]?._meta,
|
||||
);
|
||||
const airReport = hookReport(await runAir(scenario));
|
||||
expect(airReport?.content?.[0]?._meta?.jetbrains?.air?.diffPatch?.text).toContain(
|
||||
"-export const x = 0;\n+export const x = 1;\n",
|
||||
);
|
||||
expect(airReport?._meta?.claudeCode?.toolResponse).toBeUndefined();
|
||||
expect(JSON.stringify(airReport)).not.toContain("originalFile");
|
||||
// A client that is not AIR keeps the full toolResponse of origin/main.
|
||||
resetIds();
|
||||
const plain = (await runScenario(Agent, PROFILES.plain, scenario)).raw;
|
||||
expect(hookReport(plain)?._meta?.claudeCode?.toolResponse).toMatchObject({
|
||||
type: "update",
|
||||
content: "export const x = 1;\n",
|
||||
originalFile: "export const x = 0;\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends no creation patch over a file and no Edit patch before the preview", async () => {
|
||||
const patches = (reports: Record<string, any>[]) =>
|
||||
reports.flatMap((report) =>
|
||||
(report.content ?? [])
|
||||
.map((block: any) => block._meta?.jetbrains?.air?.diffPatch?.text)
|
||||
.filter((text: unknown) => text !== undefined),
|
||||
);
|
||||
// Streamed: the Write of an existing file gets only the update patch of
|
||||
// its hook, and the Edit gets no patch before its approval.
|
||||
const writePatches = patches(toolCallReports(air("write-existing"), "toolu_write"));
|
||||
expect(writePatches).toHaveLength(1);
|
||||
expect(writePatches[0]).not.toContain("new file mode");
|
||||
expect(writePatches[0]).toContain("-export const x = 0;\n+export const x = 1;");
|
||||
const edit = air("edit-with-permission");
|
||||
const approval = edit.findIndex((record) => record.kind === "requestPermission");
|
||||
expect(approval).toBeGreaterThan(0);
|
||||
expect(patches(toolCallReports(edit.slice(0, approval), "toolu_edit"))).toEqual([]);
|
||||
// Replayed: session/load renders the same tool uses from the transcript.
|
||||
const replay = await runAir(replayedEditsScenario());
|
||||
expect(patches(toolCallReports(replay))).toEqual([]);
|
||||
// A replay does not read old.ts: the disk shows a later state than the history.
|
||||
expect(toolCallReports(replay, "toolu_r_write")[0].content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: expect.stringMatching(/old\.ts$/),
|
||||
oldText: null,
|
||||
newText: "export const x = 1;\n",
|
||||
},
|
||||
]);
|
||||
expect(toolCallReports(replay, "toolu_r_edit")[0].content).toEqual([
|
||||
{
|
||||
type: "diff",
|
||||
path: expect.stringMatching(/app\.ts$/),
|
||||
oldText: "const value = 1;",
|
||||
newText: "const value = 2;",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the Bash output as terminal deltas", () => {
|
||||
const reports = toolCallReports(air("bash-foreground"), "toolu_bash");
|
||||
expect(reports).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_meta: { terminal_output_delta: { terminal_id: "toolu_bash", data: "a.ts\nb.ts" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** A Write that changes the file on disk before its PostToolUse hook runs. */
|
||||
function writtenFileScenario(): Scenario {
|
||||
return {
|
||||
name: "write-on-disk",
|
||||
files: { "old.ts": "export const x = 0;\n" },
|
||||
turns: [
|
||||
async function* (ctx) {
|
||||
const file = path.join(ctx.cwd, "old.ts");
|
||||
const input = { file_path: file, content: "export const x = 1;\n" };
|
||||
yield* assistantTurn("msg_write", [
|
||||
{ type: "tool_use", id: "toolu_write", name: "Write", input },
|
||||
]);
|
||||
fs.writeFileSync(file, input.content);
|
||||
yield toolResult("toolu_write", `The file ${file} has been updated successfully.`);
|
||||
await ctx.postToolUse("toolu_write", "Write", input, {
|
||||
type: "update",
|
||||
filePath: file,
|
||||
content: input.content,
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 1,
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
lines: ["-export const x = 0;", "+export const x = 1;"],
|
||||
},
|
||||
],
|
||||
originalFile: "export const x = 0;\n",
|
||||
});
|
||||
yield result();
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A session/load of a Write over an existing file and of an Edit. */
|
||||
function replayedEditsScenario(): Scenario {
|
||||
return {
|
||||
name: "replayed-edits",
|
||||
files: { "old.ts": "export const x = 0;\n", "app.ts": "line 1\nconst value = 1;\n" },
|
||||
transcript: ({ cwd, sessionId }) => [
|
||||
{
|
||||
type: "user",
|
||||
uuid: "00000000-0000-4000-8000-00000000f001",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: { role: "user", content: "Change both files" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "00000000-0000-4000-8000-00000000f002",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
id: "msg_r_edits",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_r_write",
|
||||
name: "Write",
|
||||
input: { file_path: path.join(cwd, "old.ts"), content: "export const x = 1;\n" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_r_edit",
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: path.join(cwd, "app.ts"),
|
||||
old_string: "const value = 1;",
|
||||
new_string: "const value = 2;",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
turns: [],
|
||||
};
|
||||
}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"_askUserQuestionCustomAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_1","canStop":true,"description":"Build assets","name":"Build assets","outputFilePath":"/tmp/tasks/workflow_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
5
src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl
generated
Normal file
5
src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","canStop":true,"description":"Watch the logs","name":"Watch the logs","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow","toolCallId":"toolu_workflow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"first line","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_3","sessionUpdate":"async_task_progress","summary":"second line","usage":{"durationMs":6,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
3
src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl
generated
Normal file
3
src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"workflow_2","canStop":true,"description":"Build assets","name":"Build assets","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bg"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Wait","version":1}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","canStop":true,"description":"sleep 100","name":"sleep 100","outputFilePath":"/tmp/tasks/bash_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"shell","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"asyncTasks":{"backgrounded":true}}},"terminal_output_delta":{"data":"Command running in background with ID: bash_1. Output is being written to: /tmp/tasks/bash_1.output. You will be notified when it completes.","terminal_id":"toolu_bg"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_bg"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","sessionUpdate":"async_task_progress","summary":"still running","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"asyncTaskId":"bash_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Fail","version":1}}},"rawInput":{"command":"false","description":"Fail"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"Exit code 1\nboom","terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"List files","version":1}}},"rawInput":{"command":"ls -la","description":"List files"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"ls -la","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"command":"ls -la","description":"List files"},"title":"ls -la","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"a.ts\nb.ts","terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Do","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"ne.","type":"text"},"messageId":"msg_done","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"error":"Context too small","version":1}}}},"content":[{"content":{"text":"Compaction failed: Context too small","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1}}}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
7
src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl
generated
Normal file
7
src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":100}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"newText":"value = 2","oldText":"value = 1","path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit src/app.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/src/app.ts b<cwd>/src/app.ts\n--- a<cwd>/src/app.ts\n+++ b<cwd>/src/app.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"```\nThe user doesn't want to proceed with this tool use. The tool use was rejected.\n```","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"newText":"const value = 2;","oldText":"const value = 1;","path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit src/app.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a<cwd>/src/app.ts b<cwd>/src/app.ts\n--- a<cwd>/src/app.ts\n+++ b<cwd>/src/app.ts\n@@ -1,3 +1,3 @@\n line 1\n-const value = 1;\n+const value = 2;\n line 3\n","version":1},"version":1}}},"newText":"","oldText":null,"path":"<cwd>/src/app.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/src/app.ts"},"title":"Edit src/app.ts","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"newText":"line 1\nconst value = 2;\nline 3","oldText":"line 1\nconst value = 1;\nline 3","path":"<cwd>/src/app.ts","type":"diff"}],"locations":[{"line":1,"path":"<cwd>/src/app.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Ready to code?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"currentModeId":"default","sessionUpdate":"current_mode_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","title":"Exited Plan Mode","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"plan":"# Plan\n1. Do it"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Ready to code?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, manually approve edits","optionId":"exit-plan-default"},{"kind":"allow_always","name":"Yes, clear context (0% used) and use auto mode","optionId":"exit-plan-clear-auto"},{"kind":"allow_always","name":"Yes, and use auto mode","optionId":"exit-plan-auto"},{"kind":"allow_always","name":"Yes, and bypass permissions","optionId":"exit-plan-bypass"},{"kind":"reject_once","name":"No, keep planning","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"plan":"# Plan\n1. Do it"},"title":"Approve Plan","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The user doesn't want to proceed with this tool use.","sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
3
src/tests/acp-scenarios/__snapshots__/air/goal.jsonl
generated
Normal file
3
src/tests/acp-scenarios/__snapshots__/air/goal.jsonl
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"goal":{"controlMethod":"_session/goal","objective":"Ship the feature","status":"active"},"version":1}}},"sessionUpdate":"session_info_update"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"grep \"value\"","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"grep \"value\" src","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"-n":true,"output_mode":"content","path":"src","pattern":"value"},"sessionUpdate":"tool_call_update","title":"grep -n \"value\" src","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Glob"}},"content":[],"kind":"search","locations":[],"name":"Glob","sessionUpdate":"tool_call","status":"pending","title":"Find","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"pattern":"**/*.ts"},"sessionUpdate":"tool_call_update","title":"Find `**/*.ts`","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"src/app.ts\nsrc/b.ts","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"},"is_mcp_tool_call":true},"content":[],"kind":"other","name":"mcp__docs__search","sessionUpdate":"tool_call","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"q":"acp"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"mcp__docs__search","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"_meta":{"claudeCode":{"mcpServer":{"name":"docs","source":"project"}}},"rawInput":{"q":"acp"},"title":"mcp__docs__search","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Found 2 documents.","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_mcp"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"select"}}},"kind":"read","locations":[{"path":"/memory/a.md"},{"path":"/memory/b.md"}],"sessionUpdate":"tool_call","status":"completed","title":"Recalled 2 memories","toolCallId":"00000000-0000-4000-8000-000000000001"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"synthesize"}}},"content":[{"content":{"text":"The user prefers tabs.","type":"text"},"type":"content"}],"kind":"read","sessionUpdate":"tool_call","status":"completed","title":"Recalled synthesized memory","toolCallId":"00000000-0000-4000-8000-000000000002"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
4
src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl
generated
Normal file
4
src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"SandboxNetworkAccess"}},"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Reason: Sandbox blocks network","title":"example.com","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"host":"example.com"},"title":"example.com","toolCallId":"toolu_net"}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
11
src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl
generated
Normal file
11
src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"content":[],"kind":"edit","locations":[],"name":"NotebookEdit","sessionUpdate":"tool_call","status":"pending","title":"Edit notebook","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"path":"<cwd>/n.ipynb"}],"sessionUpdate":"tool_call_update","title":"Edit cell in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","title":"Edit cell cell-1 in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit cell cell-1 in n.ipynb","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"<session>","toolCall":{"rawInput":{"cell_id":"cell-1","edit_mode":"replace","new_source":"print('hi')","notebook_path":"<cwd>/n.ipynb"},"title":"Edit cell cell-1 in n.ipynb","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_nb"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_denied"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"rm -rf /"},"sessionUpdate":"tool_call_update","title":"rm -rf /","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"decisionReasonType":"rule","message":"Permission to use Bash has been denied."}}},"content":[{"content":{"text":"Permission denied: Denied by rule Bash(rm:*)","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"Permission to use Bash has been denied.","terminal_id":"toolu_denied"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_denied"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
8
src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Work","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"ing.","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6","_claude/rateLimit":{"resetsAt":1700000000,"status":"allowed_warning","utilization":0.9}},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6","_claude/origin":{"kind":"human"}},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
8
src/tests/acp-scenarios/__snapshots__/air/read.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/air/read.jsonl
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/a.ts"}],"rawInput":{"file_path":"<cwd>/a.ts"},"sessionUpdate":"tool_call_update","title":"Read a.ts","toolCallId":"toolu_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
15
src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl
generated
Normal file
15
src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"jetbrains":{"air":{"commandTitle":"List","version":1}},"terminal_info":{"terminal_id":"toolu_r_bash"}},"content":[{"terminalId":"toolu_r_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"ls","description":"List"},"sessionUpdate":"tool_call","status":"pending","title":"ls","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[{"newText":"b","oldText":"a","path":"<cwd>/src/app.ts","type":"diff"}],"kind":"edit","locations":[{"path":"<cwd>/src/app.ts"}],"name":"Edit","rawInput":{"file_path":"<cwd>/src/app.ts"},"sessionUpdate":"tool_call","status":"pending","title":"Edit src/app.ts","toolCallId":"toolu_r_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Explore","sessionUpdate":"subagent_spawned","subagentSessionId":"<session>:replay-subagent:toolu_r_task","task":"Look around"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"app.ts","terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_r_task"}},"content":{"text":"Subagent text.","type":"text"},"messageId":"msg_r_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"<session>:replay-subagent:toolu_r_task"}}}
|
||||
{"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
3
src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl
generated
Normal file
3
src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"newSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
8
src/tests/acp-scenarios/__snapshots__/air/skill.jsonl
generated
Normal file
8
src/tests/acp-scenarios/__snapshots__/air/skill.jsonl
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Skill"}},"content":[],"kind":"other","name":"Skill","sessionUpdate":"tool_call","status":"pending","title":"Load skill","toolCallId":"toolu_skill"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"jetbrains":{"air":{"skill":{"name":"commits","path":"<cwd>/.claude/skills/commits/SKILL.md"},"version":1}}},"rawInput":{"skill":"commits"},"sessionUpdate":"tool_call_update","title":"Load skill: commits","toolCallId":"toolu_skill"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"Launching skill: commits","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_skill"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
10
src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl
generated
Normal file
10
src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent","toolResponse":{"isAsync":true,"status":"async_launched"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Background review","prompt":"Review the diff","sessionUpdate":"subagent_spawned","subagentSessionId":"a2","task":"Review the diff"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"a2"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Check the logs","prompt":"Check logs","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_late","task":"Check logs"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_late","update":{"locations":[{"line":1,"path":"/var/log/app.log"}],"rawInput":{"file_path":"/var/log/app.log"},"sessionUpdate":"tool_call_update","title":"Read /var/log/app.log","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_late"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
19
src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl
generated
Normal file
19
src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Fix the build","prompt":"Make the build pass","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_n","task":"Make the build pass"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"Running t","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":{"text":"he build.","type":"text"},"messageId":"msg_native_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","title":"grep \"TODO\"","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"rawInput":{"path":"/src","pattern":"TODO"},"sessionUpdate":"tool_call_update","title":"grep \"TODO\" /src","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_native_grep"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_native_bash"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Build","version":1}}},"rawInput":{"command":"make","description":"Build"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"make","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_n","toolCall":{"rawInput":{"command":"make","description":"Build"},"title":"make","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"terminal_output_delta":{"data":"make: *** Error 2","terminal_id":"toolu_native_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_n","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"toolu_native_bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"failed","subagentSessionId":"agent_n"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Plan the work","prompt":"Plan it","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_outer","task":"Plan it"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"capabilities":{},"name":"Read the spec","prompt":"Read spec.md","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_inner","task":"Read spec.md"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"locations":[{"line":1,"path":"/spec.md"}],"rawInput":{"file_path":"/spec.md"},"sessionUpdate":"tool_call_update","title":"Read /spec.md","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_inner"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":"Plan:","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"content":{"text":" do X.","type":"text"},"messageId":"msg_outer_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_outer"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
27
src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl
generated
Normal file
27
src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Explore code","prompt":"Find the parser","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_1","task":"Find the parser"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Looking for","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":" the parser.","type":"text"},"messageId":"msg_sub_1","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"locations":[{"line":1,"path":"/x.ts"}],"rawInput":{"file_path":"/x.ts"},"sessionUpdate":"tool_call_update","title":"Read /x.ts","toolCallId":"toolu_sub_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_read"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_sub_bash"}},"content":[{"terminalId":"toolu_sub_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"tool_call_update","title":"rg parser","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"jetbrains":{"air":{"commandTitle":"Search","version":1}}},"rawInput":{"command":"rg parser","description":"Search"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"rg parser","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","toolCall":{"rawInput":{"command":"rg parser","description":"Search"},"title":"rg parser","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"terminal_output_delta":{"data":"x.ts","terminal_id":"toolu_sub_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_sub_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task","toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"locations":[{"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"content":[{"newText":"b","oldText":"a","path":"<cwd>/x.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/x.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit x.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","toolCall":{"rawInput":{"file_path":"<cwd>/x.ts"},"title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"rawOutput":"The file has been updated.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Foun","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"d it.","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task","toolResponse":{"status":"completed"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_1"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
14
src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl
generated
Normal file
14
src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Summarize","prompt":"Summarize the repo","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_t","task":"Summarize the repo"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"Look at t","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"he layout.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"The repo has ","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"content":{"text":"two packages.","type":"text"},"messageId":"msg_transcript_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript","toolName":"Glob"}},"content":[],"kind":"search","locations":[],"name":"Glob","sessionUpdate":"tool_call","status":"pending","title":"Find","toolCallId":"toolu_transcript_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"rawInput":{"pattern":"*"},"sessionUpdate":"tool_call_update","title":"Find `*`","toolCallId":"toolu_transcript_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"agent_t","update":{"content":[{"content":{"text":"a\nb","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_transcript_glob"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_t"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
16
src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl
generated
Normal file
16
src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Write tests","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Write tests","priority":"medium","status":"in_progress"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Write tests","priority":"medium","status":"completed"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"All ","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"done.","type":"text"},"messageId":"msg_follow","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
12
src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl
generated
Normal file
12
src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskOutput"}},"content":[],"kind":"other","name":"TaskOutput","sessionUpdate":"tool_call","status":"pending","title":"TaskOutput","toolCallId":"toolu_out"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"block":true,"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_out"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"<retrieval_status>success</retrieval_status>\n<output>done</output>","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_out"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"TaskStop"}},"content":[],"kind":"other","name":"TaskStop","sessionUpdate":"tool_call","status":"pending","title":"TaskStop","toolCallId":"toolu_stop"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"task_id":"bash_1"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_stop"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"Successfully stopped task: bash_1","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_stop"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
9
src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl
generated
Normal file
9
src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Let me thin","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"k about it.","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Here is t","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"he answer.","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
6
src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl
generated
Normal file
6
src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"entries":[{"content":"Writing tests","priority":"medium","status":"in_progress"},{"content":"Ship","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
12
src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl
generated
Normal file
12
src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_slow"}},"content":[{"terminalId":"toolu_slow","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"command":"make"},"sessionUpdate":"tool_call_update","title":"make","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":1}}},"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":2}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolResponse":{"elapsedTimeSeconds":3}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"built","terminal_id":"toolu_slow"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_slow"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_slow"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user