mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-08 02:11:38 +00:00
Compare commits
14 Commits
fix/add-di
...
v0.96.1-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e369479f93 | ||
|
|
c26e640915 | ||
|
|
f6958db1ee | ||
|
|
2f3c6bb7b3 | ||
|
|
9a8bf71d45 | ||
|
|
eb9d63e5fd | ||
|
|
c319e5e542 | ||
|
|
739dde3ae5 | ||
|
|
17a967b4da | ||
|
|
ebfbfbdaa9 | ||
|
|
6ab670baae | ||
|
|
f7d6030c04 | ||
|
|
28a09b1008 | ||
|
|
a1521b6196 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.94.0"
|
||||
".": "0.96.0"
|
||||
}
|
||||
|
||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## [0.96.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.95.0...v0.96.0) (2026-10-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* report SendMessage delegations as linked session messages ([#1424](https://github.com/agentclientprotocol/claude-agent-acp/issues/1424)) ([739dde3](https://github.com/agentclientprotocol/claude-agent-acp/commit/739dde3ae5bb848bdb90e2c9150e2cc4687d480c))
|
||||
* report the model of each native subagent ([#1432](https://github.com/agentclientprotocol/claude-agent-acp/issues/1432)) ([c319e5e](https://github.com/agentclientprotocol/claude-agent-acp/commit/c319e5e542aabe353bb2c5e177e4c5853392b8c4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* do not hold an AIR load replay until the CLI starts ([#1433](https://github.com/agentclientprotocol/claude-agent-acp/issues/1433)) ([eb9d63e](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb9d63e5fde9b8fd8fe692dfb3280faba6626a42))
|
||||
* refine RFD child models after a load, and retry a failed model update ([#1434](https://github.com/agentclientprotocol/claude-agent-acp/issues/1434)) ([9a8bf71](https://github.com/agentclientprotocol/claude-agent-acp/commit/9a8bf71d454fa85d03f55d4101a77e3ea7a149d0))
|
||||
* send subagents' current state after a load response ([#1423](https://github.com/agentclientprotocol/claude-agent-acp/issues/1423)) ([17a967b](https://github.com/agentclientprotocol/claude-agent-acp/commit/17a967b4dab78bbb9cf9fedae60e6821578dc5cb))
|
||||
* update to Claude Code 2.1.310 ([#1435](https://github.com/agentclientprotocol/claude-agent-acp/issues/1435)) ([2f3c6bb](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f3c6bb7b3451a00cd77d7bb31c09e690ca220b3))
|
||||
|
||||
## [0.95.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.94.0...v0.95.0) (2026-10-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* end a turn at its result for AIR clients with backgroundSubagents ([#1262](https://github.com/agentclientprotocol/claude-agent-acp/issues/1262)) ([9b3a5c8](https://github.com/agentclientprotocol/claude-agent-acp/commit/9b3a5c891c75e78b985adb31f4e4b59a16ad2ebc))
|
||||
* report read-only subagent configuration ([#1417](https://github.com/agentclientprotocol/claude-agent-acp/issues/1417)) ([6ab670b](https://github.com/agentclientprotocol/claude-agent-acp/commit/6ab670baaefb30aaac2606d8ee6524e08243e5b5))
|
||||
* report RFD delegations as linked session messages ([#1405](https://github.com/agentclientprotocol/claude-agent-acp/issues/1405)) ([59e292e](https://github.com/agentclientprotocol/claude-agent-acp/commit/59e292e29f773cc6f03c0999993cbe8a2d97a7e3))
|
||||
* serve experimental ACP v2 ([#1400](https://github.com/agentclientprotocol/claude-agent-acp/issues/1400)) ([4d378f9](https://github.com/agentclientprotocol/claude-agent-acp/commit/4d378f93f36d37ba5ef64af87b617706dd9c6e82))
|
||||
* show messages typed in other Remote Control clients ([#1371](https://github.com/agentclientprotocol/claude-agent-acp/issues/1371)) ([5b11f47](https://github.com/agentclientprotocol/claude-agent-acp/commit/5b11f470b1c05b114c876b3b6e5b4df9441b1087))
|
||||
* update to Claude Code 2.1.309 ([#1413](https://github.com/agentclientprotocol/claude-agent-acp/issues/1413)) ([a1521b6](https://github.com/agentclientprotocol/claude-agent-acp/commit/a1521b6196c58dbc5065f9c19fc9694273d3d435))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump fast-uri from 3.1.8 to 3.1.9 ([#1410](https://github.com/agentclientprotocol/claude-agent-acp/issues/1410)) ([0fe22ae](https://github.com/agentclientprotocol/claude-agent-acp/commit/0fe22ae5a172b228a574665bb7136b900746041d))
|
||||
* bump postcss from 8.5.30 to 8.5.31 in the minor group ([#1403](https://github.com/agentclientprotocol/claude-agent-acp/issues/1403)) ([ecaa2c9](https://github.com/agentclientprotocol/claude-agent-acp/commit/ecaa2c935b9f2b63c1846ca17c6c3398390fb0ef))
|
||||
* bump the minor group across 1 directory with 2 updates ([#1416](https://github.com/agentclientprotocol/claude-agent-acp/issues/1416)) ([f7d6030](https://github.com/agentclientprotocol/claude-agent-acp/commit/f7d6030c04af23171058772480d55f6d2aee5298))
|
||||
* IJAI-3744 keep a session running while its background tasks are open ([#1399](https://github.com/agentclientprotocol/claude-agent-acp/issues/1399)) ([e9f986b](https://github.com/agentclientprotocol/claude-agent-acp/commit/e9f986b8a941f5c1789078a39e474f247bd0db60))
|
||||
* publish the session title as soon as the CLI has it ([#1406](https://github.com/agentclientprotocol/claude-agent-acp/issues/1406)) ([50cd76e](https://github.com/agentclientprotocol/claude-agent-acp/commit/50cd76e78dd9bb1ab0e4850a8d95431175ab5947))
|
||||
* publish turns that Claude Code starts after end_turn as an async task in v1 ([#1390](https://github.com/agentclientprotocol/claude-agent-acp/issues/1390)) ([a1d0eee](https://github.com/agentclientprotocol/claude-agent-acp/commit/a1d0eeeaad3d67a0765f4b89e3d81c1af6ca0dbb))
|
||||
|
||||
## [0.94.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.93.0...v0.94.0) (2026-10-19)
|
||||
|
||||
|
||||
|
||||
400
package-lock.json
generated
400
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.94.0",
|
||||
"version": "0.96.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.94.0",
|
||||
"version": "0.96.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.0.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.303",
|
||||
"@agentclientprotocol/sdk": "2.0.2",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.310",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
@@ -18,19 +18,18 @@
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.135.0",
|
||||
"@anthropic-ai/sdk": "1.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.2",
|
||||
"@typescript-eslint/parser": "8.71.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.72.0",
|
||||
"@typescript-eslint/parser": "8.72.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.10",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.5"
|
||||
},
|
||||
@@ -39,31 +38,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.0.0.tgz",
|
||||
"integrity": "sha512-Za5gWOPQ44aBOdegDJZ+OjCAPn7POc0cw8BlFJQ1mmUY1OoUWxRi3+QZT4gdZnyEWgKXyfeGRb0Oo1ag9YkQ1w==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.0.2.tgz",
|
||||
"integrity": "sha512-jLywRB42hZMRMc4aUgt0uRjkY9FQnMdybv4XgLl+ddi75QuJn8Vbl0+CHcVHJDC0+WDfi1KQXRWtHaR5vp6gHw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.303",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.303.tgz",
|
||||
"integrity": "sha512-XBql04V40xr+fmGJh8AmIpqRZKFNrs99BzXzzSVlx+uE5NjFCzKmkNKTdMD0DQtDvFJlijOLoh1siaH+au9Psw==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.310.tgz",
|
||||
"integrity": "sha512-fo+sr+9Ks73P001f6XKRQlyzfIBAJMgXArLUkH5Tu98NikRDZeGtFQVj23d5/hj2ijpFgzffrr4fHywY9qUkoQ==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.303",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.303",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.303",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.303",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.303",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.303",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.303",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.303"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.310",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.310"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -72,9 +71,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.303",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.303.tgz",
|
||||
"integrity": "sha512-QAWV9Zd7i7FQDaUSwZNVXJirqCS9CSF4aPwbkGwLvLH83qcfFOmnyxWUg1H7dU6tJFLOCyDLQzc3xzUA4dakoA==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.310.tgz",
|
||||
"integrity": "sha512-gpzMjDZdcdA2Webuqm9j76exG8yNK/755ZYWbS3MM4vVBlAsm+enEO+hhNt0Mx1VMil28RWetvTO4ZJ3YODVMA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -85,9 +84,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.303",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.303.tgz",
|
||||
"integrity": "sha512-o931musaryuq04fMaEjceCEmgsBrLh39WvwsQWmv070h9jXS8EmWRFpdSILv1xrUFXiukRSq8yUDRVzg+FvTug==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.310.tgz",
|
||||
"integrity": "sha512-ZRQ4ZrzE2OOfR+Fx8h+CM4NKh0UiYqlh5TowP7HUhn5KUoYMjoSR/xIhix8ow42X2IDiBy0FjEVaA/Awhu2KAw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -98,12 +97,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.303",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.303.tgz",
|
||||
"integrity": "sha512-c04i9i1BU3j9G3D7XdVzXTdM3/sDpQH+1o8jNcMPnztOTYbNFmH/cacgeX9x3N112r+5VhCEHXGILoe5GxVxeg==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.310.tgz",
|
||||
"integrity": "sha512-l6SPgUmJknjx+6/Pb7OR353fm3xBNAWqodFoC/Cflg0yMcLfz9iwNIc3Drgos2DqfHnid+ODqDzSO93AgQ7RmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -111,12 +113,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.303",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.303.tgz",
|
||||
"integrity": "sha512-a/OsLTEx9ly9hC26cXidg9mtTMA00SQnEiTN9P4IPNj64uSp/7uBnlWl0VeUoVFbgKYYq6XrGRcD6F1ilQsYxg==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.310.tgz",
|
||||
"integrity": "sha512-viXuvvtli3pfHzTEI+oeJzk3nldkfHvj757Xh/uAeximUT9ANZIq4SMZw2cSqbhASjni0qcO6B1x4BGx2LuQpw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -124,12 +129,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.303",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.303.tgz",
|
||||
"integrity": "sha512-lsm1sX2+9Wos8glBbn86M8leFuK60LKB1cJdS0b2HiTSwiDCqpUKYRGJNUlW1JdUTwJB5nDv6S8/FOJExm1/tw==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.310.tgz",
|
||||
"integrity": "sha512-jx7awHPoofXuE6cRRQSzLUFyW6aUXbYD9AtnNIDUHmmPPrgJ9nSnqjPaqcNhOdaZH7onUUujEAq26N2tx29fYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -137,12 +145,15 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.303",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.303.tgz",
|
||||
"integrity": "sha512-yixAa3ZOZd1hiI4mpoukzT9TnuDCzSEHyh7pHcbYTRmdRE2y8bAOmI0eiMFJPivnG4iyGhS2C4XNAe6hAjl30w==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.310.tgz",
|
||||
"integrity": "sha512-/qFgN5LcofB8PayxBXoa+xDxkYPPQeo/eCyN0DsyyeI67ifH/l49pz9J+sH0+ctDe9und0vMCJPzng70bx2K+Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -150,9 +161,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.303",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.303.tgz",
|
||||
"integrity": "sha512-AnEseiw5E1JF3XRaGIZN880V/i2OxCaTOQJF9F1+oELleSdvLnCUxCE6/baGvaZjrgvffO9YbYChBpgUKj9j7A==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.310.tgz",
|
||||
"integrity": "sha512-tRmDMhZpoWAIrZxFBTh6vyZB8W5gldU5/Rfo+cyq/OvBKTxxbim+/0tIa9QlJQncoVLXVN9eySYeeB9KMjio7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -163,9 +174,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.303",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.303.tgz",
|
||||
"integrity": "sha512-j5kABFEQ6x6T5AB/glmn0r9VwZRbzcH+nSEOWglB69ODNfkZA2btjcz9r794+2BLqJ9QAyswiYWcOX4sDz/RmQ==",
|
||||
"version": "0.3.310",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.310.tgz",
|
||||
"integrity": "sha512-zvrIApxPccTJc6gCOtw5hj27QjWhhTi3z5x5iMmf9f0LZAKR+9mrgENVeFL7PP7zRGdYVhhX2SahbzxN83L/eA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -176,10 +187,10 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.135.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.135.0.tgz",
|
||||
"integrity": "sha512-WWrdL57JhU5KoynpTjt9GXK6k7ud9nPw2uw/Trn+oBXh6s0KpjhAccWZm2Qxw08ARYG1UDQj5iC+JO42j1rt/g==",
|
||||
"license": "MIT",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-1.3.0.tgz",
|
||||
"integrity": "sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
@@ -230,19 +241,6 @@
|
||||
"keyv": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
|
||||
@@ -454,17 +452,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@keyv/bigmap": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
|
||||
@@ -531,9 +518,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.10.0.tgz",
|
||||
"integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
@@ -834,34 +821,6 @@
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz",
|
||||
"integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node22": {
|
||||
"version": "22.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.6.tgz",
|
||||
@@ -919,17 +878,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz",
|
||||
"integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
|
||||
"integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/type-utils": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.0",
|
||||
"@typescript-eslint/type-utils": "8.72.0",
|
||||
"@typescript-eslint/utils": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -942,22 +901,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.71.2",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz",
|
||||
"integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz",
|
||||
"integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.0",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -973,14 +932,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz",
|
||||
"integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
|
||||
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.71.2",
|
||||
"@typescript-eslint/types": "^8.71.2",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.72.0",
|
||||
"@typescript-eslint/types": "^8.72.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -995,14 +954,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz",
|
||||
"integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
|
||||
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2"
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1013,9 +972,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz",
|
||||
"integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
|
||||
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1030,15 +989,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz",
|
||||
"integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz",
|
||||
"integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||
"@typescript-eslint/utils": "8.72.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -1055,9 +1014,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz",
|
||||
"integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
|
||||
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1069,16 +1028,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz",
|
||||
"integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
|
||||
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.71.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"@typescript-eslint/project-service": "8.72.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.72.0",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -1097,16 +1056,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz",
|
||||
"integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
|
||||
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2"
|
||||
"@typescript-eslint/scope-manager": "8.72.0",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1121,13 +1080,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz",
|
||||
"integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
|
||||
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1237,19 +1196,6 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
|
||||
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -1283,13 +1229,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1311,19 +1250,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^2.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
@@ -1489,13 +1428,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2448,9 +2380,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.12",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
|
||||
"version": "6.2.13",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz",
|
||||
"integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
@@ -2813,13 +2745,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -3632,60 +3557,6 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
"@tsconfig/node12": "^1.0.7",
|
||||
"@tsconfig/node14": "^1.0.0",
|
||||
"@tsconfig/node16": "^1.0.2",
|
||||
"acorn": "^8.4.1",
|
||||
"acorn-walk": "^8.1.1",
|
||||
"arg": "^4.1.0",
|
||||
"create-require": "^1.1.0",
|
||||
"diff": "^4.0.1",
|
||||
"make-error": "^1.1.1",
|
||||
"v8-compile-cache-lib": "^3.0.1",
|
||||
"yn": "3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"ts-node": "dist/bin.js",
|
||||
"ts-node-cwd": "dist/bin-cwd.js",
|
||||
"ts-node-esm": "dist/bin-esm.js",
|
||||
"ts-node-script": "dist/bin-script.js",
|
||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||
"ts-script": "dist/bin-script-deprecated.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/core": ">=1.2.50",
|
||||
"@swc/wasm": ">=1.2.50",
|
||||
"@types/node": "*",
|
||||
"typescript": ">=2.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/wasm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node/node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -3773,13 +3644,6 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -3997,16 +3861,6 @@
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
13
package.json
13
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.94.0",
|
||||
"version": "0.96.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -65,25 +65,24 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.0.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.303",
|
||||
"@agentclientprotocol/sdk": "2.0.2",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.310",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.135.0",
|
||||
"@anthropic-ai/sdk": "1.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.2",
|
||||
"@typescript-eslint/parser": "8.71.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.72.0",
|
||||
"@typescript-eslint/parser": "8.72.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.10",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
|
||||
399
src/acp-agent.ts
399
src/acp-agent.ts
@@ -116,14 +116,19 @@ import {
|
||||
type SubagentState,
|
||||
} from "./acp-subagents.js";
|
||||
import {
|
||||
childModelOption,
|
||||
isNativeSubagentControlTool,
|
||||
isNativeSubagentControlUpdate,
|
||||
NativeSubagent,
|
||||
NativeSubagentRuntime,
|
||||
nativeSubagentState,
|
||||
type ModelEntry,
|
||||
responseModelEntry,
|
||||
resumedNativeSubagentId,
|
||||
sameModelEntry,
|
||||
sendMessageResume,
|
||||
sendMessageResumePrompt,
|
||||
type SubagentModels,
|
||||
} from "./native-subagents.js";
|
||||
import {
|
||||
AIR_ASYNC_TASKS_CAPABILITY,
|
||||
@@ -203,6 +208,7 @@ import {
|
||||
import {
|
||||
type ReplayChild,
|
||||
RfdSubagentReplay,
|
||||
firstResponseModel,
|
||||
replaySubagentTerminalState,
|
||||
} from "./subagent-replay.js";
|
||||
import {
|
||||
@@ -338,6 +344,8 @@ import {
|
||||
buildModelConfigOption,
|
||||
getAvailableModels,
|
||||
MODEL_CONFIG_ID,
|
||||
modelDisplayName,
|
||||
modelEntryFor,
|
||||
narrowToGatewayModels,
|
||||
resolveModelPreference,
|
||||
type SessionModelState,
|
||||
@@ -353,6 +361,7 @@ import {
|
||||
buildEffortConfigOption,
|
||||
EFFORT_CONFIG_ID,
|
||||
effortFlagSettings,
|
||||
effortLevelName,
|
||||
mergeEffortSettings,
|
||||
settingsEffortForModel,
|
||||
} from "./session-effort.js";
|
||||
@@ -973,12 +982,7 @@ type Turn = {
|
||||
* while the CLI starts. The session record then takes the same objects. */
|
||||
type ReplayState = Pick<
|
||||
Session,
|
||||
| "cwd"
|
||||
| "additionalDirectories"
|
||||
| "taskState"
|
||||
| "forwardSubagentText"
|
||||
| "messageIdToUuid"
|
||||
| "sessionFailureState"
|
||||
"cwd" | "taskState" | "forwardSubagentText" | "messageIdToUuid" | "sessionFailureState"
|
||||
>;
|
||||
|
||||
/** A replay that runs before its session record exists. */
|
||||
@@ -1123,10 +1127,6 @@ export type Session = {
|
||||
* the command name. A `commands_changed` clears it, because the skill
|
||||
* files can have changed then. */
|
||||
skillPaths?: Map<string, string | undefined>;
|
||||
/** The `--add-dir` roots the SDK got: `_meta.claudeCode.options` ones, then
|
||||
* the ACP `additionalDirectories`. Skill commands of these roots resolve to
|
||||
* their SKILL.md for AIR clients. */
|
||||
additionalDirectories?: string[];
|
||||
/** Serialized `system`/init `plugin_errors` last logged, so the per-turn
|
||||
* init re-emit logs a plugin load failure once, not every turn. */
|
||||
loggedPluginErrors?: string;
|
||||
@@ -2723,6 +2723,20 @@ export class ClaudeAcpAgent {
|
||||
* express: see {@link ToolCallClientCapabilities.v2}.
|
||||
*/
|
||||
private readonly v2: boolean;
|
||||
/**
|
||||
* The subagent replay of each session's last load, until
|
||||
* {@link publishCurrentSubagentStates} sends its children's current states.
|
||||
*/
|
||||
private readonly replayedSubagents = new Map<string, RfdSubagentReplay>();
|
||||
/**
|
||||
* AIR's draft: the model that the last load of each session replayed to each
|
||||
* child session, as the model id and the entry that went out. See
|
||||
* {@link publishReplayedChildModels}.
|
||||
*/
|
||||
private readonly replayedChildModels = new Map<
|
||||
string,
|
||||
Map<string, { model: string; sent: ModelEntry }>
|
||||
>();
|
||||
/** See the `foreground` constructor option. */
|
||||
private readonly foregroundEvents?: (sessionId: string) => ForegroundEvents;
|
||||
/**
|
||||
@@ -2801,6 +2815,7 @@ export class ClaudeAcpAgent {
|
||||
clearHookCallbacks(id);
|
||||
session.nativeSubagentRuntime?.clear();
|
||||
session.asyncTaskRuntime?.clear();
|
||||
this.replayedChildModels.delete(id);
|
||||
if (this.sessions[id] === session) delete this.sessions[id];
|
||||
},
|
||||
settleCancelledTurn: (original, session, turn) => {
|
||||
@@ -3063,7 +3078,6 @@ export class ClaudeAcpAgent {
|
||||
const pending: PendingReplay = {
|
||||
state: {
|
||||
cwd: params.cwd,
|
||||
additionalDirectories: sessionAdditionalDirectories(params),
|
||||
taskState: new Map(),
|
||||
forwardSubagentText: this.forwardsSubagentText(params._meta),
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -3097,11 +3111,15 @@ export class ClaudeAcpAgent {
|
||||
} catch (error) {
|
||||
pending.stopped = true;
|
||||
await replay;
|
||||
this.replayedChildModels.delete(params.sessionId);
|
||||
throw error;
|
||||
}
|
||||
timing.phase("session-ready");
|
||||
const replayed = await replay;
|
||||
if (!replayed.ok) throw replayed.error;
|
||||
if (!replayed.ok) {
|
||||
this.replayedChildModels.delete(params.sessionId);
|
||||
throw replayed.error;
|
||||
}
|
||||
return {
|
||||
sessionId: response.sessionId,
|
||||
modes: response.modes,
|
||||
@@ -4553,6 +4571,7 @@ export class ClaudeAcpAgent {
|
||||
this.logger,
|
||||
(agentId) => subagentLineage(params.sessionId, agentId, this.logger),
|
||||
(launchId) => subagentLaunchedBy(params.sessionId, launchId),
|
||||
this.subagentModels(params.sessionId),
|
||||
));
|
||||
// Adapter-composed advisories (hook feedback, model fallbacks) are live
|
||||
// events, not something the model said. Clients on the notice contract get
|
||||
@@ -5402,7 +5421,6 @@ export class ClaudeAcpAgent {
|
||||
toolCallCapabilities: this.toolCallCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: session.cwd,
|
||||
additionalDirectories: session.additionalDirectories,
|
||||
taskState: session.taskState,
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
toolCallFields: toolCallFieldsOf(session),
|
||||
@@ -5887,9 +5905,28 @@ export class ClaudeAcpAgent {
|
||||
// turn activation and settlement stay driven by echoes/results/idle.
|
||||
// (Raw-mode emission above still forwards these frames.)
|
||||
if ((message as { type: string }).type === "command_lifecycle") {
|
||||
const frame = message as unknown as { command_uuid: string; state: string };
|
||||
const frame = message as unknown as {
|
||||
command_uuid: string;
|
||||
state: string;
|
||||
wakeup_source?: string;
|
||||
scheduled_for?: number;
|
||||
};
|
||||
switch (frame.state) {
|
||||
case "started": {
|
||||
// CLIs 2.1.307+ (capability lifecycle_wakeup_source_v1) mark a
|
||||
// command that a scheduler fire started. With no prompt of ours
|
||||
// open, it starts an autonomous turn: name the fire as its cause,
|
||||
// whether `session_state_changed` already opened the task or not.
|
||||
const cause = scheduledFireCause(frame);
|
||||
if (
|
||||
cause &&
|
||||
asyncTasks.enabled &&
|
||||
!session.cancelled &&
|
||||
!session.activeTurn &&
|
||||
!firstUnsettledQueuedTurn()
|
||||
) {
|
||||
await asyncTasks.autonomousTurnStarted(cause);
|
||||
}
|
||||
// Remember dispatch on the live turn so a cancel() that orphans
|
||||
// it seeds the right state (see Turn.commandStarted)...
|
||||
const queued = findUnsettledTurn(frame.command_uuid);
|
||||
@@ -6357,7 +6394,7 @@ export class ClaudeAcpAgent {
|
||||
availableCommands: getAvailableSlashCommands(
|
||||
message.commands,
|
||||
session.terminalSlashCommands,
|
||||
this.toolCallCapabilities.air.client ? session : undefined,
|
||||
this.toolCallCapabilities.air.client ? session.cwd : undefined,
|
||||
session.skillPaths,
|
||||
session.query,
|
||||
),
|
||||
@@ -7640,6 +7677,11 @@ export class ClaudeAcpAgent {
|
||||
// it) can all be tagged with the same, replay-stable id.
|
||||
if (message.event.type === "message_start") {
|
||||
currentStreamMessageId = message.event.message.id || undefined;
|
||||
await subagents.reportModel(
|
||||
message.parent_tool_use_id,
|
||||
message.event.message.model,
|
||||
sendUpdate,
|
||||
);
|
||||
// A new top-level message starts: clear any streamed-content
|
||||
// residue from a prior message that never reached its
|
||||
// consolidated reset — a cancelled turn breaks out before the
|
||||
@@ -8006,6 +8048,14 @@ export class ClaudeAcpAgent {
|
||||
break;
|
||||
}
|
||||
|
||||
if (message.type === "assistant") {
|
||||
await subagents.reportModel(
|
||||
message.parent_tool_use_id,
|
||||
message.message.model,
|
||||
sendUpdate,
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot the latest top-level assistant usage and model so the
|
||||
// next `result` can emit a usage_update tied to the right context
|
||||
// window. Subagent messages are excluded to keep the snapshot
|
||||
@@ -8150,6 +8200,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
let content: typeof message.message.content;
|
||||
let toolUseResult = message.type === "user" ? message.tool_use_result : undefined;
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
(message.parent_tool_use_id === null || forwardsSubagentText())
|
||||
@@ -8170,6 +8221,20 @@ export class ClaudeAcpAgent {
|
||||
(item) => item.type !== "text" && item.type !== "thinking",
|
||||
);
|
||||
streamedBlocksOf(message.parent_tool_use_id).length = 0;
|
||||
} else if (
|
||||
message.parent_tool_use_id !== null &&
|
||||
Array.isArray(message.message.content)
|
||||
) {
|
||||
// A foreground fork inherits a placeholder result for its own
|
||||
// launch. Only the launching side can complete that Agent call
|
||||
// with the fork's actual report.
|
||||
const launchId = message.parent_tool_use_id;
|
||||
content = message.message.content.filter(
|
||||
(block) => block.type !== "tool_result" || block.tool_use_id !== launchId,
|
||||
);
|
||||
// The message-level sidecar cannot describe a remaining result
|
||||
// after its launch result was removed.
|
||||
if (content.length !== message.message.content.length) toolUseResult = undefined;
|
||||
} else {
|
||||
content = message.message.content;
|
||||
}
|
||||
@@ -8187,7 +8252,7 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
const backgroundBashTask = backgroundBashTaskFromToolResult(
|
||||
content,
|
||||
message.tool_use_result,
|
||||
toolUseResult,
|
||||
session.toolUseCache,
|
||||
);
|
||||
if (backgroundBashTask) await asyncTasks.taskBackgrounded(backgroundBashTask);
|
||||
@@ -8216,7 +8281,7 @@ export class ClaudeAcpAgent {
|
||||
asyncTasks,
|
||||
backgroundBashTask,
|
||||
);
|
||||
const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
|
||||
const resumedAgentId = resumedNativeSubagentId(toolUseResult);
|
||||
if (resumedAgentId) {
|
||||
const resume = sendMessageResume(
|
||||
session.toolUseCache,
|
||||
@@ -8257,7 +8322,7 @@ export class ClaudeAcpAgent {
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
toolCallFields: toolCallFieldsOf(session),
|
||||
messageId: messageIdForGrouping(message),
|
||||
toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
|
||||
toolUseResult,
|
||||
// On the wire since CLI 2.1.216 but not in SDKUserMessage's
|
||||
// type, hence the cast. Validated by parseToolResultMeta.
|
||||
toolResultMeta:
|
||||
@@ -8465,6 +8530,7 @@ export class ClaudeAcpAgent {
|
||||
clearHookCallbacks(params.sessionId);
|
||||
session.nativeSubagentRuntime?.clear();
|
||||
session.asyncTaskRuntime?.clear();
|
||||
this.replayedChildModels.delete(params.sessionId);
|
||||
delete this.sessions[params.sessionId];
|
||||
} else {
|
||||
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
||||
@@ -9043,6 +9109,7 @@ export class ClaudeAcpAgent {
|
||||
clearHookCallbacks(sessionId);
|
||||
session.nativeSubagentRuntime?.clear();
|
||||
session.asyncTaskRuntime?.clear();
|
||||
this.replayedChildModels.delete(sessionId);
|
||||
delete this.sessions[sessionId];
|
||||
this.sessionIndex.onTeardown(sessionId);
|
||||
const ended = await raceTimeoutAndAbort(
|
||||
@@ -9257,8 +9324,17 @@ export class ClaudeAcpAgent {
|
||||
/** The state of the last persisted task notification of the child.
|
||||
* It comes after the launch tool_result, so it wins. */
|
||||
notifiedState?: SubagentState;
|
||||
/** The model id of the child's latest response in its transcript. */
|
||||
responseModel?: string;
|
||||
/** The `model` option entry that the replay sent to the child. */
|
||||
model?: ModelEntry;
|
||||
}
|
||||
>();
|
||||
// A load replays while the CLI starts, and the session's model list comes
|
||||
// with the CLI at an unknown point of the replay. So a pending replay maps
|
||||
// no model to the list, and each child's model is its id until the load
|
||||
// response refines it (see `publishCurrentSubagentStates`).
|
||||
const replayModels = pending ? undefined : this.subagentModels(sessionId);
|
||||
|
||||
// Registers the Agent and Task launches of `list`, and the terminal state
|
||||
// of each launch from its tool_result.
|
||||
@@ -9388,8 +9464,11 @@ export class ClaudeAcpAgent {
|
||||
: { agents: new Map() };
|
||||
const rfdReplay =
|
||||
form === "rfd"
|
||||
? new RfdSubagentReplay(rfdHistory, (notification) =>
|
||||
this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
? new RfdSubagentReplay(
|
||||
rfdHistory,
|
||||
(notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
this.subagentModels(sessionId),
|
||||
!pending,
|
||||
)
|
||||
: undefined;
|
||||
rfdReplay?.scan(messages, sessionId);
|
||||
@@ -9512,6 +9591,29 @@ export class ClaudeAcpAgent {
|
||||
: nativeReplayEnabled && parentToolUseId
|
||||
? await announceReplayChild(parentToolUseId)
|
||||
: sessionId;
|
||||
if (message.type === "assistant") {
|
||||
const model = (message.message as { model?: string }).model;
|
||||
if (rfdReplay && child) {
|
||||
await rfdReplay.responseModel(child, model);
|
||||
} else if (nativeReplayEnabled && parentToolUseId) {
|
||||
// AIR's draft: the entry of the model list that is known now. A load
|
||||
// can lack the list until the CLI started, so the entry goes again
|
||||
// after the load response when the list maps it to another one.
|
||||
const replayChild = replayChildren.get(parentToolUseId);
|
||||
const entry = responseModelEntry(model, replayModels);
|
||||
if (replayChild?.announced && entry && !sameModelEntry(replayChild.model, entry)) {
|
||||
replayChild.responseModel = model;
|
||||
replayChild.model = entry;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: replayChild.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(entry)],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
parentToolUseId &&
|
||||
@@ -9597,7 +9699,6 @@ export class ClaudeAcpAgent {
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
toolCallCapabilities: this.toolCallCapabilities,
|
||||
cwd: replayState()?.cwd,
|
||||
additionalDirectories: replayState()?.additionalDirectories,
|
||||
taskState: replayState()?.taskState,
|
||||
messageId: replayMessageId,
|
||||
parentToolUseId,
|
||||
@@ -9643,13 +9744,13 @@ export class ClaudeAcpAgent {
|
||||
for (const toolUseId of subagentLaunchIds(content)) {
|
||||
const launched = rfdReplay.childOfLaunch(toolUseId);
|
||||
if (!launched || launched.announced) continue;
|
||||
await rfdReplay.announce(launched);
|
||||
const childMessages = await readSubagentMessages(
|
||||
sessionId,
|
||||
launched.sessionId,
|
||||
rfdHistory,
|
||||
this.logger,
|
||||
);
|
||||
await rfdReplay.announce(launched, firstResponseModel(childMessages));
|
||||
rfdReplay.scan(childMessages, launched.sessionId);
|
||||
for (const childMessage of childMessages) await replayMessage(childMessage, launched);
|
||||
await rfdReplay.childReplayed(launched, childMessages);
|
||||
@@ -9659,9 +9760,17 @@ export class ClaudeAcpAgent {
|
||||
for (const message of messages) await replayMessage(message);
|
||||
if (rfdReplay && !pending?.stopped) {
|
||||
await rfdReplay.finish(clientSupportsNotices(this.clientCapabilities), sessionId);
|
||||
this.replayedSubagents.set(sessionId, rfdReplay);
|
||||
}
|
||||
|
||||
if (nativeReplayEnabled && !pending?.stopped) {
|
||||
const childModels = new Map<string, { model: string; sent: ModelEntry }>();
|
||||
for (const child of replayChildren.values()) {
|
||||
if (child.responseModel && child.model) {
|
||||
childModels.set(child.sessionId, { model: child.responseModel, sent: child.model });
|
||||
}
|
||||
}
|
||||
if (childModels.size > 0) this.replayedChildModels.set(sessionId, childModels);
|
||||
// Claude history persists sidechain messages and Agent/Task tool uses,
|
||||
// but not task_started/task_updated lifecycle frames. Recover terminal
|
||||
// state from the launch tool_result. Missing results and malformed or
|
||||
@@ -10083,7 +10192,7 @@ export class ClaudeAcpAgent {
|
||||
(session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
|
||||
const update = new AcpToolCallRenderer(this.toolCallCapabilities).toolCall(
|
||||
{ id: toolCallId, name: toolName, input: toolInput },
|
||||
{ cwd: session.cwd, additionalDirectories: session.additionalDirectories, previewContent },
|
||||
{ cwd: session.cwd, previewContent },
|
||||
);
|
||||
if (parentToolUseId) stampParentToolUseId(update, parentToolUseId);
|
||||
toolCallFieldsOf(session).apply(update);
|
||||
@@ -10132,6 +10241,84 @@ export class ClaudeAcpAgent {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The models of the session `sessionId`, for the `model` option of its
|
||||
* subagents: the entries of the session's own `model` option.
|
||||
*/
|
||||
private subagentModels(sessionId: string): SubagentModels {
|
||||
return {
|
||||
entry: (model) => {
|
||||
const session = this.sessions[sessionId];
|
||||
return session
|
||||
? modelEntryFor(session.configOptions, session.modelInfos, model)
|
||||
: undefined;
|
||||
},
|
||||
name: (model) => {
|
||||
const session = this.sessions[sessionId];
|
||||
return session ? modelDisplayName(session.modelInfos, model) : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private subagentConfigurationHook(sessionId: string): HookCallback {
|
||||
return async (input) => {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session || !input.agent_id) return { continue: true };
|
||||
const options: SessionConfigOption[] = [];
|
||||
if (input.permission_mode) {
|
||||
const mode = session.modes.availableModes.find((mode) => mode.id === input.permission_mode);
|
||||
options.push(
|
||||
SessionModeManager.configOption({
|
||||
currentModeId: input.permission_mode,
|
||||
availableModes: [mode ?? { id: input.permission_mode, name: input.permission_mode }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
const switchedModel =
|
||||
input.hook_event_name === "PostModelSwitch"
|
||||
? responseModelEntry(input.to_model, this.subagentModels(sessionId))
|
||||
: undefined;
|
||||
// The entry of the root session's `model` option, as for a child response.
|
||||
if (switchedModel) options.push(childModelOption(switchedModel));
|
||||
if (input.effort?.level) {
|
||||
options.push({
|
||||
id: EFFORT_CONFIG_ID,
|
||||
name: "Effort",
|
||||
description: "Current reasoning effort",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: input.effort.level,
|
||||
options: [{ value: input.effort.level, name: effortLevelName(input.effort.level) }],
|
||||
});
|
||||
}
|
||||
if (input.agent_type) {
|
||||
const agent = session.agents.find((agent) => agent.name === input.agent_type);
|
||||
options.push({
|
||||
id: AGENT_CONFIG_ID,
|
||||
name: "Agent",
|
||||
description: "Subagent type",
|
||||
type: "select",
|
||||
currentValue: input.agent_type,
|
||||
options: [
|
||||
{
|
||||
value: input.agent_type,
|
||||
name: input.agent_type,
|
||||
...(agent?.description ? { description: agent.description } : {}),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
await session.nativeSubagentRuntime
|
||||
?.reportConfiguration(input.agent_id, options)
|
||||
.catch((error) =>
|
||||
this.logger.error(
|
||||
`Session ${sessionId}: failed to report subagent configuration: ${error}`,
|
||||
),
|
||||
);
|
||||
return { continue: true };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The PreToolUse hook of a v2 connection, for Edit and Write: the tool call
|
||||
* shows the exact patch of the change before the tool runs.
|
||||
@@ -10592,7 +10779,7 @@ export class ClaudeAcpAgent {
|
||||
return getAvailableSlashCommands(
|
||||
commands,
|
||||
session.terminalSlashCommands,
|
||||
this.toolCallCapabilities.air.client ? session : undefined,
|
||||
this.toolCallCapabilities.air.client ? session.cwd : undefined,
|
||||
(session.skillPaths ??= new Map()),
|
||||
session.query,
|
||||
);
|
||||
@@ -10611,16 +10798,75 @@ export class ClaudeAcpAgent {
|
||||
|
||||
/**
|
||||
* The work that follows a session setup response: the session's commands,
|
||||
* and MCP server authentication. A v2 client gets the commands in the setup
|
||||
* response itself (`src/v2/agent.ts`), so it gets no update with them here.
|
||||
* the current states of the subagents a load replayed, and MCP server
|
||||
* authentication. A v2 client gets the commands in the setup response itself,
|
||||
* and the subagent states from the v2 surface once it has sent its own
|
||||
* response (`src/v2/agent.ts`), so it gets neither here.
|
||||
*/
|
||||
private afterSetupResponse(sessionId: string, mcpServers: McpServer[]): void {
|
||||
setTimeout(() => {
|
||||
if (!this.v2) this.sendAvailableCommandsUpdate(sessionId);
|
||||
if (!this.v2) {
|
||||
this.sendAvailableCommandsUpdate(sessionId);
|
||||
void this.publishCurrentSubagentStates(sessionId);
|
||||
}
|
||||
startMcpAuthentication(this, sessionId, mcpServers);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the current state of each subagent that the last load of
|
||||
* `sessionId` replayed (see {@link RfdSubagentReplay.currentStates}). Run it
|
||||
* only after the load response: before it, the client treats subagent states
|
||||
* as history.
|
||||
*/
|
||||
async publishCurrentSubagentStates(sessionId: string): Promise<void> {
|
||||
await this.publishReplayedChildModels(sessionId);
|
||||
const replay = this.replayedSubagents.get(sessionId);
|
||||
if (!replay) return;
|
||||
this.replayedSubagents.delete(sessionId);
|
||||
const runtime = this.sessions[sessionId]?.nativeSubagentRuntime;
|
||||
try {
|
||||
for (const notification of replay.currentStates(
|
||||
(child) => runtime?.liveWorkState(child),
|
||||
(child) => runtime?.configurationOf?.(child),
|
||||
)) {
|
||||
await this.client.sessionUpdate(asSdkSessionNotification(notification));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Session ${sessionId}: failed to send current subagent states:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AIR's draft: a load replays while the CLI starts, so the replay can lack
|
||||
* the session's model list and send a child's model as its id. After the
|
||||
* load response the list is known, and each replayed child whose model the
|
||||
* list maps to another entry gets that entry. The replay already ended the
|
||||
* child, and AIR's draft allows no other update after a child's end, but it
|
||||
* takes this more exact snapshot of the same configuration.
|
||||
*/
|
||||
private async publishReplayedChildModels(sessionId: string): Promise<void> {
|
||||
const childModels = this.replayedChildModels.get(sessionId);
|
||||
if (!childModels) return;
|
||||
this.replayedChildModels.delete(sessionId);
|
||||
const models = this.subagentModels(sessionId);
|
||||
try {
|
||||
for (const [childSessionId, { model, sent }] of childModels) {
|
||||
const entry = responseModelEntry(model, models);
|
||||
if (!entry || sameModelEntry(entry, sent)) continue;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: childSessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(entry)],
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Session ${sessionId}: failed to send subagent models:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateConfigOption(
|
||||
sessionId: string,
|
||||
configId: string,
|
||||
@@ -11322,7 +11568,12 @@ export class ClaudeAcpAgent {
|
||||
|
||||
// Resolve every workspace root once. The native checkpoint report uses
|
||||
// this same set for lexical path validation, and the SDK receives it below.
|
||||
const additionalDirectories = sessionAdditionalDirectories(params);
|
||||
const acpAdditionalDirectories =
|
||||
params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
|
||||
const additionalDirectories = [
|
||||
...(userProvidedOptions?.additionalDirectories ?? []),
|
||||
...acpAdditionalDirectories,
|
||||
];
|
||||
|
||||
const fileChangeReporter = supportsAgentFileChangeReport(this.clientCapabilities)
|
||||
? createNativeFileChangeReporter({
|
||||
@@ -11389,6 +11640,10 @@ export class ClaudeAcpAgent {
|
||||
`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`,
|
||||
);
|
||||
|
||||
// Both forms of native subagent sessions report the configuration of a child.
|
||||
const subagentConfigHook = subagentForm(this.clientCapabilities)
|
||||
? this.subagentConfigurationHook(sessionId)
|
||||
: undefined;
|
||||
const options: Options = {
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
@@ -11457,14 +11712,33 @@ export class ClaudeAcpAgent {
|
||||
tools,
|
||||
hooks: {
|
||||
...userProvidedOptions?.hooks,
|
||||
...(subagentConfigHook
|
||||
? {
|
||||
SubagentStart: [
|
||||
...(userProvidedOptions?.hooks?.SubagentStart ?? []),
|
||||
{ hooks: [subagentConfigHook] },
|
||||
],
|
||||
SubagentStop: [
|
||||
...(userProvidedOptions?.hooks?.SubagentStop ?? []),
|
||||
{ hooks: [subagentConfigHook] },
|
||||
],
|
||||
PostToolUseFailure: [
|
||||
...(userProvidedOptions?.hooks?.PostToolUseFailure ?? []),
|
||||
{ hooks: [subagentConfigHook] },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
// On v2, an Edit or Write shows its exact patch before it runs, also
|
||||
// when no permission request shows it (`fileChangePreviewHook`): a v2
|
||||
// diff cannot hold the input's snippet.
|
||||
...(this.v2
|
||||
...(this.v2 || subagentConfigHook
|
||||
? {
|
||||
PreToolUse: [
|
||||
...(userProvidedOptions?.hooks?.PreToolUse || []),
|
||||
{ matcher: "Edit|Write", hooks: [this.fileChangePreviewHook(sessionId)] },
|
||||
...(subagentConfigHook ? [{ hooks: [subagentConfigHook] }] : []),
|
||||
...(this.v2
|
||||
? [{ matcher: "Edit|Write", hooks: [this.fileChangePreviewHook(sessionId)] }]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
@@ -11472,6 +11746,7 @@ export class ClaudeAcpAgent {
|
||||
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
||||
{
|
||||
hooks: [
|
||||
...(subagentConfigHook ? [subagentConfigHook] : []),
|
||||
createPostToolUseHook({
|
||||
onEnterPlanMode: async () => {
|
||||
await this.sessionModes.publishCurrent(sessionId, "plan");
|
||||
@@ -11495,9 +11770,11 @@ export class ClaudeAcpAgent {
|
||||
...(userProvidedOptions?.hooks?.PostModelSwitch || []),
|
||||
{
|
||||
hooks: [
|
||||
...(subagentConfigHook ? [subagentConfigHook] : []),
|
||||
async (input) => {
|
||||
if (
|
||||
input.hook_event_name === "PostModelSwitch" &&
|
||||
!input.agent_id &&
|
||||
input.source !== "sdk" &&
|
||||
input.source !== "resume"
|
||||
) {
|
||||
@@ -11879,7 +12156,6 @@ export class ClaudeAcpAgent {
|
||||
settingsManager.getApiKeyHelper(configuredSettingsObject),
|
||||
)?.kind,
|
||||
fileChangeReporter,
|
||||
additionalDirectories,
|
||||
};
|
||||
timing.phase("register");
|
||||
this.refreshContextWindowInBackground(sessionId, this.sessions[sessionId]);
|
||||
@@ -12742,24 +13018,19 @@ function getAvailableSlashCommands(
|
||||
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
|
||||
// the static list. Raw CLI names, matched before the MCP rename.
|
||||
terminalCommands?: readonly string[],
|
||||
// The session cwd and `--add-dir` roots of an AIR client, or undefined for
|
||||
// every other client. AIR gets the kind and the SKILL.md path of each skill
|
||||
// command.
|
||||
airSkillRoots?: Pick<Session, "cwd" | "additionalDirectories">,
|
||||
// The session cwd of an AIR client, or undefined for every other client.
|
||||
// AIR gets the kind and the SKILL.md path of each skill command.
|
||||
airSkillCwd?: string,
|
||||
// The resolved SKILL.md paths of the session, keyed by the cwd and the name.
|
||||
skillPaths?: Map<string, string | undefined>,
|
||||
query?: Query,
|
||||
): AvailableCommand[] {
|
||||
// The container listings of this one pass, so a list of a hundred commands lists each container once.
|
||||
const skillScans: SkillScanCache = new Map();
|
||||
const skillPath = (
|
||||
name: string,
|
||||
{ cwd, additionalDirectories }: Pick<Session, "cwd" | "additionalDirectories">,
|
||||
): string | undefined => {
|
||||
// A session's roots never change, so the cwd and the name are key enough.
|
||||
const skillPath = (name: string, cwd: string): string | undefined => {
|
||||
const key = `${cwd}\0${name}`;
|
||||
if (skillPaths?.has(key)) return skillPaths.get(key);
|
||||
const resolved = resolveSkillPath(name, cwd, skillScans, additionalDirectories);
|
||||
const resolved = resolveSkillPath(name, cwd, skillScans);
|
||||
skillPaths?.set(key, resolved);
|
||||
return resolved;
|
||||
};
|
||||
@@ -12805,8 +13076,8 @@ function getAvailableSlashCommands(
|
||||
name = `mcp:${name.replace(" (MCP)", "")}`;
|
||||
}
|
||||
const skillMdPath =
|
||||
airSkillRoots && !command.builtin && !mcpPrompt
|
||||
? skillPath(command.name, airSkillRoots)
|
||||
airSkillCwd && !command.builtin && !mcpPrompt
|
||||
? skillPath(command.name, airSkillCwd)
|
||||
: undefined;
|
||||
return {
|
||||
name,
|
||||
@@ -13142,8 +13413,6 @@ export function toAcpNotifications(
|
||||
toolCallCapabilities?: ToolCallClientCapabilities;
|
||||
parentToolUseId?: string | null;
|
||||
cwd?: string;
|
||||
// The session's `--add-dir` roots, where a Skill tool's SKILL.md can also be.
|
||||
additionalDirectories?: readonly string[];
|
||||
taskState?: TaskState;
|
||||
// Tracks tool_use ids already emitted as a `tool_call` so a permission
|
||||
// request (which emits the tool_call eagerly) and the streamed tool_use
|
||||
@@ -13350,11 +13619,10 @@ export function toAcpNotifications(
|
||||
? // Already surfaced (full assistant message after streaming, or a
|
||||
// permission request emitted it first): refine it with the
|
||||
// complete input.
|
||||
renderer.refinement(toolUse, options?.cwd, options?.additionalDirectories)
|
||||
renderer.refinement(toolUse, options?.cwd)
|
||||
: // First surface (streaming content_block_start or replay).
|
||||
renderer.toolCall(toolUse, {
|
||||
cwd: options?.cwd,
|
||||
additionalDirectories: options?.additionalDirectories,
|
||||
inputComplete: options?.inputComplete,
|
||||
});
|
||||
}
|
||||
@@ -13578,7 +13846,6 @@ export function streamEventToAcpNotifications(
|
||||
// See toAcpNotifications.
|
||||
toolCallCapabilities?: ToolCallClientCapabilities;
|
||||
cwd?: string;
|
||||
additionalDirectories?: readonly string[];
|
||||
taskState?: TaskState;
|
||||
emittedToolCalls?: Set<string>;
|
||||
toolCallFields?: ToolCallFieldTracker;
|
||||
@@ -13594,7 +13861,6 @@ export function streamEventToAcpNotifications(
|
||||
toolCallCapabilities: options?.toolCallCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
additionalDirectories: options?.additionalDirectories,
|
||||
taskState: options?.taskState,
|
||||
emittedToolCalls: options?.emittedToolCalls,
|
||||
toolCallFields: options?.toolCallFields,
|
||||
@@ -13661,7 +13927,6 @@ export function streamEventToAcpNotifications(
|
||||
streamedInput,
|
||||
input,
|
||||
options?.cwd,
|
||||
options?.additionalDirectories,
|
||||
);
|
||||
if (message.parent_tool_use_id) stampParentToolUseId(update, message.parent_tool_use_id);
|
||||
applyMessageId(update, options?.messageId);
|
||||
@@ -13994,17 +14259,27 @@ function getMatchingModelUsage(modelUsage: Record<string, ModelUsage>, currentMo
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--add-dir` roots of a session: the `_meta.claudeCode.options` ones,
|
||||
* then the ACP `additionalDirectories`, or the legacy `_meta.additionalRoots`
|
||||
* for clients that do not send the ACP field.
|
||||
* What a `command_lifecycle` "started" frame says started the command: a
|
||||
* ScheduleWakeup (dynamic `/loop`) wakeup, with the time it was set for in
|
||||
* epoch milliseconds, or another scheduled task such as a cron job. Undefined
|
||||
* for any other command, and for a value this adapter does not know. The time
|
||||
* is given in UTC because the agent does not know the client's time zone.
|
||||
*/
|
||||
function sessionAdditionalDirectories(params: {
|
||||
additionalDirectories?: string[] | null;
|
||||
_meta?: { [key: string]: unknown } | null;
|
||||
}): string[] {
|
||||
const meta = params._meta as NewSessionMeta | undefined;
|
||||
return [
|
||||
...(meta?.claudeCode?.options?.additionalDirectories ?? []),
|
||||
...(params.additionalDirectories ?? meta?.additionalRoots ?? []),
|
||||
];
|
||||
function scheduledFireCause(frame: {
|
||||
wakeup_source?: string;
|
||||
scheduled_for?: number;
|
||||
}): string | undefined {
|
||||
switch (frame.wakeup_source) {
|
||||
case "loop_wakeup": {
|
||||
const scheduledFor =
|
||||
typeof frame.scheduled_for === "number" ? new Date(frame.scheduled_for) : undefined;
|
||||
return scheduledFor && !Number.isNaN(scheduledFor.getTime())
|
||||
? `A scheduled wakeup fired (set for ${scheduledFor.toISOString()}).`
|
||||
: "A scheduled wakeup fired.";
|
||||
}
|
||||
case "schedule_wakeup":
|
||||
return "A scheduled task fired.";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,18 +458,25 @@ export class AsyncTaskRuntime {
|
||||
* example to answer a task notification. ACP v1 has no session state, and
|
||||
* the client already got the stop reason of its last prompt, so this task is
|
||||
* the only sign that the agent works. The tool calls of the turn are in the
|
||||
* transcript already, so the task is not shown there. Does nothing while
|
||||
* such a task is open.
|
||||
* transcript already, so the task is not shown there.
|
||||
*
|
||||
* [cause] names what started the turn when Claude Code says so, for example
|
||||
* "A scheduled wakeup fired." It replaces the description of a task that is
|
||||
* already open; without it, an open task is left as it is.
|
||||
*/
|
||||
async autonomousTurnStarted(): Promise<void> {
|
||||
if (!this.enabled || this.autonomousTurn) return;
|
||||
async autonomousTurnStarted(cause?: string): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
if (this.autonomousTurn) {
|
||||
if (cause) await this.publishProgress(this.autonomousTurn, { description: cause });
|
||||
return;
|
||||
}
|
||||
const task = this.task(`autonomous-turn-${randomUUID()}`);
|
||||
task.autonomousTurn = true;
|
||||
task.startedObserved = true;
|
||||
task.name = "Claude is working";
|
||||
task.taskType = "turn";
|
||||
task.description =
|
||||
this.lastNotificationSummary ?? "Claude Code started a turn without a prompt.";
|
||||
cause ?? this.lastNotificationSummary ?? "Claude Code started a turn without a prompt.";
|
||||
task.showInTranscript = false;
|
||||
this.lastNotificationSummary = undefined;
|
||||
this.autonomousTurn = task;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { RequestError, type SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
RequestError,
|
||||
type SessionConfigOption,
|
||||
type SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
import { MODEL_CONFIG_ID } from "./session-model.js";
|
||||
import { isFinalToolCallStatus } from "./tool-calls/renderer.js";
|
||||
|
||||
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
|
||||
@@ -64,6 +69,32 @@ export type NativeSubagentSession = {
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
type Logger = { log(message: string): void };
|
||||
|
||||
/** An entry of a session's `model` select option. */
|
||||
export type ModelEntry = { value: string; name: string };
|
||||
|
||||
/**
|
||||
* The models of the root session. A child's model is an entry of the root
|
||||
* session's `model` option when one matches, so that a client can compare the
|
||||
* two sessions' models.
|
||||
*/
|
||||
export type SubagentModels = {
|
||||
/**
|
||||
* The entry of the root session's `model` option for `model`, a model id
|
||||
* that a response reported. Undefined when no entry matches.
|
||||
*/
|
||||
entry(model: string): ModelEntry | undefined;
|
||||
/** The display name of the model id `model` in the session's model list, if the list has it. */
|
||||
name(model: string): string | undefined;
|
||||
};
|
||||
|
||||
type ChildConfiguration = {
|
||||
options: Map<string, SessionConfigOption>;
|
||||
published?: string;
|
||||
/** The `model` option value and name that the client last got. */
|
||||
publishedModel?: ModelEntry;
|
||||
publishing: Promise<void>;
|
||||
};
|
||||
|
||||
/** What the SDK says about the end of a task, beyond its status. */
|
||||
export type TaskEnd = {
|
||||
/** The SDK's text for a failure. */
|
||||
@@ -137,6 +168,7 @@ export class NativeSubagentRuntime {
|
||||
private readonly parentByToolUse: Map<string, string>;
|
||||
private readonly identityByToolUse = new Map<string, SubagentIdentity>();
|
||||
private readonly controlByToolUse = new Map<string, AcpSessionNotification>();
|
||||
private readonly configBySession = new Map<string, ChildConfiguration>();
|
||||
private readonly childByParentToolUse = new Map<string, NativeSubagent>();
|
||||
/**
|
||||
* The child session of each tool call that went to a child session. A later
|
||||
@@ -153,6 +185,15 @@ export class NativeSubagentRuntime {
|
||||
* closes an unfinished call has no `toolName` to recognize it by.
|
||||
*/
|
||||
private readonly hiddenLaunches = new Map<string, { background: boolean }>();
|
||||
/**
|
||||
* The RFD form: the SendMessage calls that the client is not shown, until
|
||||
* their final update, with their first report and the session that made
|
||||
* them. A message that reaches an announced child replaces its call.
|
||||
*/
|
||||
private readonly hiddenSendMessages = new Map<
|
||||
string,
|
||||
{ initial: AcpSessionNotification; sender: string; delivered: boolean }
|
||||
>();
|
||||
/**
|
||||
* The RFD form: the SubagentHandback calls of announced children that the
|
||||
* client is not shown yet, with their first report, the report from their
|
||||
@@ -184,6 +225,8 @@ export class NativeSubagentRuntime {
|
||||
* `form` is undefined for a client without native subagent sessions. The RFD
|
||||
* form restores an agent from before a load or resume with `lineage`. The AIR
|
||||
* form does too, and `launchedAgent` finds the agent that a launch call made.
|
||||
* `models` maps the model of a child response to the root session's entry
|
||||
* (see {@link reportModel}).
|
||||
*/
|
||||
constructor(
|
||||
form: SubagentForm | undefined,
|
||||
@@ -193,6 +236,7 @@ export class NativeSubagentRuntime {
|
||||
private readonly logger: Logger,
|
||||
private readonly lineage?: SubagentLineage,
|
||||
private readonly launchedAgent?: LaunchedAgent,
|
||||
private readonly models?: SubagentModels,
|
||||
) {
|
||||
this.form = form;
|
||||
this.enabled = form !== undefined;
|
||||
@@ -212,8 +256,145 @@ export class NativeSubagentRuntime {
|
||||
forcedSessionId?: string,
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
|
||||
if (routed) this.rememberSendMessage(routed);
|
||||
return routed;
|
||||
if (!routed) return routed;
|
||||
this.rememberSendMessage(routed);
|
||||
return this.form === "rfd" ? this.routeRfdSendMessage(routed, forcedSessionId) : routed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: the work state that the client holds for the announced child
|
||||
* `sessionId` of this live session, or undefined when no such child runs here.
|
||||
*/
|
||||
liveWorkState(sessionId: string): WorkState["state"] | undefined {
|
||||
if (this.form !== "rfd") return undefined;
|
||||
const child = this.children.get(sessionId);
|
||||
return child?.announced ? child.workState : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the model of a child response, an exact model id, as the entry of
|
||||
* the root session's `model` option that matches it, else as the id (see
|
||||
* {@link responseModelEntry}). The child gets no model before its first
|
||||
* response: the adapter cannot tell the model that Claude Code picks for it.
|
||||
* The client gets a new configuration only when the entry changes.
|
||||
*/
|
||||
async reportModel(
|
||||
parentToolUseId: string | null,
|
||||
model: string | undefined,
|
||||
deliver: Publish,
|
||||
): Promise<void> {
|
||||
const entry = this.form && parentToolUseId ? responseModelEntry(model, this.models) : undefined;
|
||||
if (!entry) return;
|
||||
// Every response of a child reports its model: most repeat the model that
|
||||
// the client already has, and need no routing.
|
||||
// A publication that failed sent nothing, so only the published model counts.
|
||||
const child = this.childByParentToolUse.get(parentToolUseId!);
|
||||
if (
|
||||
child?.announced &&
|
||||
sameModelEntry(this.configBySession.get(child.sessionId)?.publishedModel, entry)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await deliver({
|
||||
sessionId: this.rootSessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(entry)],
|
||||
_meta: { claudeCode: { parentToolUseId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The configuration that the client last got for the announced child `sessionId`, if any. */
|
||||
configurationOf(sessionId: string): SessionConfigOption[] | undefined {
|
||||
const published = this.configBySession.get(sessionId)?.published;
|
||||
if (!this.childOfSession(sessionId)?.announced || !published) return undefined;
|
||||
return JSON.parse(published) as SessionConfigOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes configuration options of the child `agentId`: an SDK agent id, as a
|
||||
* hook names it, or a child session id. Hook observations can precede the
|
||||
* task event that establishes a child's parent.
|
||||
*/
|
||||
async reportConfiguration(
|
||||
agentId: string,
|
||||
options: SessionConfigOption[],
|
||||
meta?: SessionNotification["update"]["_meta"],
|
||||
): Promise<void> {
|
||||
if (!this.form || agentId === this.rootSessionId || options.length === 0) return;
|
||||
// In AIR's draft, the session id of a resumed or restored agent is
|
||||
// `<agent id>:generation:<n>`, so the agent id names its current generation.
|
||||
const known = this.children.get(agentId) ?? this.childOfSession(agentId);
|
||||
const sessionId = known?.sessionId ?? agentId;
|
||||
// AIR's draft takes no configuration of an ended child (see `finishTask`).
|
||||
if (
|
||||
this.form === "air" &&
|
||||
known &&
|
||||
(known.terminalState !== undefined || known.terminalPromise)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let config = this.configBySession.get(sessionId);
|
||||
if (!config) {
|
||||
const pending = [...this.configBySession.keys()].filter(
|
||||
(id) => !this.childOfSession(id),
|
||||
).length;
|
||||
if (!known && pending >= MAX_PENDING_PARENTS) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: dropping configuration for unannounced subagent ${agentId}; pending buffer limit reached`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
config = { options: new Map(), publishing: Promise.resolve() };
|
||||
this.configBySession.set(sessionId, config);
|
||||
}
|
||||
for (const option of options) config.options.set(option.id, option);
|
||||
if (known?.announced) await this.publishConfiguration(known, meta);
|
||||
}
|
||||
|
||||
private async publishConfiguration(
|
||||
child: NativeSubagent,
|
||||
meta?: SessionNotification["update"]["_meta"],
|
||||
): Promise<void> {
|
||||
const config = this.configBySession.get(child.sessionId);
|
||||
// AIR's draft ends a child for good, so it gets no update after its end,
|
||||
// also when the end comes while this publication waits for an earlier one.
|
||||
const ended = () =>
|
||||
this.form === "air" && (child.terminalState !== undefined || !!child.terminalPromise);
|
||||
if (!config || ended()) return;
|
||||
// SDK hooks can run concurrently with streamed responses. Serialize snapshots
|
||||
// so a delayed publication cannot replace newer settings with older ones.
|
||||
const publishing = config.publishing.then(async () => {
|
||||
if (this.configBySession.get(child.sessionId) !== config || ended()) return;
|
||||
const priority = ["mode", "model", "thought_level", "model_config"];
|
||||
const rank = (option: SessionConfigOption) => {
|
||||
const index = priority.indexOf(option.category ?? "");
|
||||
return index < 0 ? priority.length : index;
|
||||
};
|
||||
const configOptions = [...config.options.values()].sort(
|
||||
(left, right) => rank(left) - rank(right),
|
||||
);
|
||||
const snapshot = JSON.stringify(configOptions);
|
||||
if (config.published === snapshot) return;
|
||||
await this.publish({
|
||||
sessionId: child.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions,
|
||||
...(meta ? { _meta: meta } : {}),
|
||||
},
|
||||
});
|
||||
config.published = snapshot;
|
||||
const model = configOptions.find((option) => option.id === MODEL_CONFIG_ID);
|
||||
const shown = model?.type === "select" ? model.options[0] : undefined;
|
||||
if (model && shown && "name" in shown && typeof model.currentValue === "string") {
|
||||
config.publishedModel = { value: model.currentValue, name: shown.name };
|
||||
}
|
||||
});
|
||||
// The caller receives the error; later observations must still be able to retry.
|
||||
config.publishing = publishing.catch(() => {});
|
||||
await publishing;
|
||||
}
|
||||
|
||||
private async routeUpdate(
|
||||
@@ -315,6 +496,10 @@ export class NativeSubagentRuntime {
|
||||
(claudeMeta?.parentToolUseId
|
||||
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
|
||||
: undefined);
|
||||
if (announcedChild?.announced && update.sessionUpdate === "config_option_update") {
|
||||
await this.reportConfiguration(announcedChild.sessionId, update.configOptions, update._meta);
|
||||
return null;
|
||||
}
|
||||
if (this.form === "rfd" && announcedChild?.announced) {
|
||||
const handback = await this.routeRfdHandback(notification, announcedChild);
|
||||
if (handback !== undefined) return handback;
|
||||
@@ -557,6 +742,8 @@ export class NativeSubagentRuntime {
|
||||
if (child.parentToolUseId) (child.endedRuns ??= new Set()).add(child.parentToolUseId);
|
||||
} else {
|
||||
await finishNativeSubagent(this.session, taskId, state, this.publish);
|
||||
// AIR's draft takes no configuration of an ended child.
|
||||
this.configBySession.delete(child.sessionId);
|
||||
}
|
||||
} finally {
|
||||
if (child.parentToolUseId) {
|
||||
@@ -595,6 +782,7 @@ export class NativeSubagentRuntime {
|
||||
this.pendingCount = 0;
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.configBySession.clear();
|
||||
this.parentByToolUse.clear();
|
||||
this.forgetRestores();
|
||||
}
|
||||
@@ -888,13 +1076,16 @@ export class NativeSubagentRuntime {
|
||||
});
|
||||
}
|
||||
if (prompt && toolUseId) {
|
||||
const sendMessage = this.hiddenSendMessages.get(toolUseId);
|
||||
await publishPrompt(
|
||||
child,
|
||||
prompt,
|
||||
toolUseId,
|
||||
this.senderByToolUse.get(toolUseId),
|
||||
sendMessage?.sender ?? this.senderByToolUse.get(toolUseId),
|
||||
this.publish,
|
||||
{ replacesCall: sendMessage !== undefined },
|
||||
);
|
||||
if (sendMessage) sendMessage.delivered = true;
|
||||
}
|
||||
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
|
||||
}
|
||||
@@ -908,6 +1099,7 @@ export class NativeSubagentRuntime {
|
||||
*/
|
||||
private withdraw(taskId: string, child: NativeSubagent): void {
|
||||
this.children.delete(taskId);
|
||||
this.configBySession.delete(child.sessionId);
|
||||
if (child.parentToolUseId) {
|
||||
this.takePending(child.parentToolUseId);
|
||||
this.cleanupControl(child.parentToolUseId);
|
||||
@@ -929,9 +1121,14 @@ export class NativeSubagentRuntime {
|
||||
await this.reportState(child, rfdEndState("failed", { error }));
|
||||
}
|
||||
|
||||
/** Announces `child` in the client's form. A runtime without one never does. */
|
||||
/**
|
||||
* Announces `child` in the client's form, then its configuration. A runtime
|
||||
* without a form never does.
|
||||
*/
|
||||
private async announce(child: NativeSubagent): Promise<void> {
|
||||
if (this.form) await announceNativeSubagent(child, this.publish, this.form);
|
||||
if (!this.form) return;
|
||||
await announceNativeSubagent(child, this.publish, this.form);
|
||||
await this.publishConfiguration(child);
|
||||
}
|
||||
|
||||
/** The RFD form: publishes the work state of `child` on its parent. */
|
||||
@@ -1100,6 +1297,44 @@ export class NativeSubagentRuntime {
|
||||
return unexposedControlFallback(handback.initial, notification, child.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: an update of a SendMessage call, which its message replaces
|
||||
* once it reaches an announced child: the sender's outgoing view and the
|
||||
* child's incoming one, see {@link linkedSessionMessages}. The SDK reports
|
||||
* that delivery before the call's final update. A call whose message reached
|
||||
* no announced child, such as one to the parent, is shown after all, with its
|
||||
* final status. Any other update is returned as is.
|
||||
*/
|
||||
private routeRfdSendMessage(
|
||||
notification: AcpSessionNotification,
|
||||
forcedSessionId: string | undefined,
|
||||
): AcpSessionNotification | null {
|
||||
const { update } = notification;
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return notification;
|
||||
}
|
||||
const toolCallId = update.toolCallId;
|
||||
// A permission request already showed the call to the client, so it keeps
|
||||
// getting its updates, and its message goes only to the child.
|
||||
if (forcedSessionId) {
|
||||
this.hiddenSendMessages.delete(toolCallId);
|
||||
return notification;
|
||||
}
|
||||
let sendMessage = this.hiddenSendMessages.get(toolCallId);
|
||||
if (!sendMessage) {
|
||||
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
|
||||
if (update.sessionUpdate !== "tool_call" || claudeMeta?.toolName !== "SendMessage") {
|
||||
return notification;
|
||||
}
|
||||
sendMessage = { initial: notification, sender: notification.sessionId, delivered: false };
|
||||
this.hiddenSendMessages.set(toolCallId, sendMessage);
|
||||
}
|
||||
if (!isFinalToolCallStatus(update.status)) return null;
|
||||
this.hiddenSendMessages.delete(toolCallId);
|
||||
if (sendMessage.delivered) return null;
|
||||
return unexposedControlFallback(sendMessage.initial, notification, sendMessage.sender);
|
||||
}
|
||||
|
||||
/** Whether `update` is of an Agent/Task call, by its tool name or, in the
|
||||
* RFD form, by the id of a call the client is not shown. */
|
||||
private isControlUpdate(
|
||||
@@ -1157,10 +1392,12 @@ export class NativeSubagentRuntime {
|
||||
this.parentByToolUse.clear();
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.configBySession.clear();
|
||||
this.childByParentToolUse.clear();
|
||||
this.taskFinishPromises.clear();
|
||||
this.senderByToolUse.clear();
|
||||
this.hiddenLaunches.clear();
|
||||
this.hiddenSendMessages.clear();
|
||||
this.handbacks.clear();
|
||||
this.generationByTaskId.clear();
|
||||
this.pending.clear();
|
||||
@@ -1188,6 +1425,20 @@ export class NativeSubagentRuntime {
|
||||
|
||||
private buffer(parentToolUseId: string, notification: AcpSessionNotification): void {
|
||||
const updates = this.pending.get(parentToolUseId);
|
||||
// A child reports its model with each response before its announcement
|
||||
// (see `reportModel`). Only the latest such report waits, so they do not
|
||||
// fill the buffer. Any other configuration update waits as it is.
|
||||
const modelOnly = ({ update }: AcpSessionNotification) =>
|
||||
update.sessionUpdate === "config_option_update" &&
|
||||
update.configOptions.length === 1 &&
|
||||
update.configOptions[0]?.id === MODEL_CONFIG_ID;
|
||||
if (updates && modelOnly(notification)) {
|
||||
const index = updates.findIndex(modelOnly);
|
||||
if (index >= 0) {
|
||||
updates[index] = notification;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.pendingCount >= MAX_PENDING_UPDATES ||
|
||||
(updates === undefined && this.pending.size >= MAX_PENDING_PARENTS) ||
|
||||
@@ -1301,6 +1552,40 @@ export class NativeSubagentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `model` option of a child session. The client cannot change a child's
|
||||
* configuration, so the select offers only the current model.
|
||||
*/
|
||||
export function childModelOption(entry: ModelEntry): SessionConfigOption {
|
||||
return {
|
||||
id: MODEL_CONFIG_ID,
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: entry.value,
|
||||
options: [{ value: entry.value, name: entry.name }],
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two model entries have the same value and name. */
|
||||
export function sameModelEntry(left: ModelEntry | undefined, right: ModelEntry): boolean {
|
||||
return left?.value === right.value && left.name === right.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model entry for `model`, the model id of a child's response: the entry
|
||||
* of the root session's `model` option that matches it, else the id, with its
|
||||
* display name when the session's model list has one. Undefined for no model,
|
||||
* and for `<synthetic>`, which marks a message that no model wrote.
|
||||
*/
|
||||
export function responseModelEntry(
|
||||
model: unknown,
|
||||
models: SubagentModels | undefined,
|
||||
): ModelEntry | undefined {
|
||||
if (typeof model !== "string" || !model || model === "<synthetic>") return undefined;
|
||||
return models?.entry(model) ?? { value: model, name: models?.name(model) ?? model };
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces the child on its parent, once, before any update of the child.
|
||||
* The RFD form announces it as running and then sends the prompt as the
|
||||
@@ -1339,7 +1624,7 @@ export async function announceNativeSubagent(
|
||||
child.parentToolUseId,
|
||||
child.parentSessionId,
|
||||
publish,
|
||||
{ replacesLaunch: true },
|
||||
{ replacesCall: true },
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -1401,9 +1686,9 @@ export async function finishNativeSubagent(
|
||||
* transcript is the same delegation and is not sent again. `sender` is the
|
||||
* session that made that call, when known.
|
||||
*
|
||||
* A launch's prompt (`replacesLaunch`) also goes to the parent, see
|
||||
* {@link linkedSessionMessages}. A SendMessage call stays a tool call of the
|
||||
* sender, so its message has only the incoming view.
|
||||
* When the message replaces its call (`replacesCall`), a launch's or a
|
||||
* SendMessage call the client is not shown, it also goes to the sender, see
|
||||
* {@link linkedSessionMessages}. Otherwise it has only the incoming view.
|
||||
*/
|
||||
async function publishPrompt(
|
||||
child: NativeSubagent,
|
||||
@@ -1411,13 +1696,13 @@ async function publishPrompt(
|
||||
toolUseId: string | undefined,
|
||||
sender: string | undefined,
|
||||
publish: Publish,
|
||||
{ replacesLaunch = false }: { replacesLaunch?: boolean } = {},
|
||||
{ replacesCall = false }: { replacesCall?: boolean } = {},
|
||||
): Promise<void> {
|
||||
child.promptIds ??= new Set();
|
||||
const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
|
||||
if (child.promptIds.has(messageId)) return;
|
||||
child.promptIds.add(messageId);
|
||||
if (replacesLaunch && sender !== undefined && toolUseId !== undefined) {
|
||||
if (replacesCall && sender !== undefined && toolUseId !== undefined) {
|
||||
for (const view of linkedSessionMessages(toolUseId, sender, child.sessionId, prompt)) {
|
||||
await publish(view);
|
||||
}
|
||||
@@ -1439,8 +1724,9 @@ async function publishPrompt(
|
||||
* The RFD form: a message that a tool call sent, which the client is not
|
||||
* shown, as two session messages: the sender's outgoing view, then the
|
||||
* recipient's incoming one. That call is a launch's Agent/Task call, whose
|
||||
* prompt goes from the parent to the child, or a child's SubagentHandback
|
||||
* call, whose report goes from the child to the parent. Both views use the
|
||||
* prompt goes from the parent to the child, a SendMessage call, whose message
|
||||
* goes from its sender to a child, or a child's SubagentHandback call, whose
|
||||
* report goes from the child to the parent. Both views use the
|
||||
* call's tool use id, and each names the other with the draft RFD's
|
||||
* `recipientMessageId` and `senderMessageId`.
|
||||
*/
|
||||
@@ -1710,7 +1996,8 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole
|
||||
* A tool call that the client was not shown, as one `tool_call` with its
|
||||
* final status, shown after all: an Agent/Task call whose child was never
|
||||
* exposed (AIR's draft shows only a failed one, the RFD form also a completed
|
||||
* one), or a failed SubagentHandback call.
|
||||
* one), a SendMessage call whose message reached no announced child, or a
|
||||
* failed SubagentHandback call.
|
||||
*/
|
||||
function unexposedControlFallback(
|
||||
initial: AcpSessionNotification | undefined,
|
||||
|
||||
@@ -79,6 +79,13 @@ export function mergeEffortSettings(base: Settings, override: Settings | undefin
|
||||
};
|
||||
}
|
||||
|
||||
export function effortLevelName(level: string): string {
|
||||
return level
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function buildEffortConfigOption(
|
||||
modelInfos: ModelInfo[],
|
||||
currentModelId: string,
|
||||
@@ -98,10 +105,7 @@ export function buildEffortConfigOption(
|
||||
...(useRecommendedValue ? [] : [{ value: "default", name: "Default" }]),
|
||||
...supportedLevels.map((level) => ({
|
||||
value: level,
|
||||
name: level
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
name: effortLevelName(level),
|
||||
})),
|
||||
];
|
||||
const includes = (level: string) =>
|
||||
|
||||
@@ -606,3 +606,88 @@ export async function getAvailableModels(
|
||||
currentModelId: currentModel.value,
|
||||
};
|
||||
}
|
||||
|
||||
/** The display name of the model id `model` in `modelInfos`, if it is there. */
|
||||
export function modelDisplayName(modelInfos: ModelInfo[], model: string): string | undefined {
|
||||
let byModel = displayNameCache.get(modelInfos);
|
||||
if (!byModel) displayNameCache.set(modelInfos, (byModel = new Map()));
|
||||
if (!byModel.has(model)) {
|
||||
const info = modelInfos.find((info) => info.value === model || info.resolvedModel === model);
|
||||
byModel.set(model, info?.displayName);
|
||||
}
|
||||
return byModel.get(model);
|
||||
}
|
||||
|
||||
/** {@link modelDisplayName} per model list, which a session replaces and never edits. */
|
||||
const displayNameCache = new WeakMap<ModelInfo[], Map<string, string | undefined>>();
|
||||
|
||||
/** The flat entries of a session's `model` select option. */
|
||||
function modelOptionEntries(option: SessionConfigOption): Array<{ value: string; name: string }> {
|
||||
if (option.type !== "select") return [];
|
||||
return option.options.flatMap((entry) =>
|
||||
"value" in entry ? [{ value: entry.value, name: entry.name }] : entry.options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry of the `model` option among `configOptions` for `model`, a model
|
||||
* id that a response reported, so that a client can compare it with the
|
||||
* session's own model. Best first:
|
||||
*
|
||||
* 1. the current entry, when it runs that model up to a context hint: a
|
||||
* session on `opus[1m]` or `default` that answers as `claude-opus-4-6`;
|
||||
* 2. the entry whose value or resolved model is that id;
|
||||
* 3. an entry that runs that model up to a context hint.
|
||||
*
|
||||
* Among several entries for one model, a concrete entry comes before
|
||||
* `default`, as in {@link resolveModelPreference}. Undefined when no entry
|
||||
* matches. A model's resolved id comes from `modelInfos`
|
||||
* (`ModelInfo.resolvedModel`), the wire id that a response reports, as for
|
||||
* {@link matchResumedModel}. It is compared as it is: a dated id such as
|
||||
* `claude-haiku-4-5-20251001` matches only the same dated id.
|
||||
*/
|
||||
export function modelEntryFor(
|
||||
configOptions: readonly SessionConfigOption[],
|
||||
modelInfos: ModelInfo[],
|
||||
model: string,
|
||||
): { value: string; name: string } | undefined {
|
||||
// Each response of a subagent reports its model, so the result is kept per
|
||||
// list. A session replaces its lists on a change and never edits them.
|
||||
let byInfos = modelEntryCache.get(configOptions);
|
||||
if (!byInfos) modelEntryCache.set(configOptions, (byInfos = new WeakMap()));
|
||||
let byModel = byInfos.get(modelInfos);
|
||||
if (!byModel) byInfos.set(modelInfos, (byModel = new Map()));
|
||||
if (byModel.has(model)) return byModel.get(model);
|
||||
const entry = findModelEntry(configOptions, modelInfos, model);
|
||||
byModel.set(model, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
const modelEntryCache = new WeakMap<
|
||||
readonly SessionConfigOption[],
|
||||
WeakMap<ModelInfo[], Map<string, { value: string; name: string } | undefined>>
|
||||
>();
|
||||
|
||||
function findModelEntry(
|
||||
configOptions: readonly SessionConfigOption[],
|
||||
modelInfos: ModelInfo[],
|
||||
model: string,
|
||||
): { value: string; name: string } | undefined {
|
||||
const option = configOptions.find((candidate) => candidate.id === MODEL_CONFIG_ID);
|
||||
if (option?.type !== "select") return undefined;
|
||||
// Concrete entries first; `sort` keeps the list order otherwise.
|
||||
const entries = modelOptionEntries(option).sort(
|
||||
(left, right) => Number(left.value === "default") - Number(right.value === "default"),
|
||||
);
|
||||
const observed = canonicalizeModelId(model);
|
||||
const resolved = (value: string) =>
|
||||
canonicalizeModelId(modelInfos.find((info) => info.value === value)?.resolvedModel ?? value);
|
||||
const sameModel = (value: string) =>
|
||||
stripContextHints(resolved(value)) === stripContextHints(observed);
|
||||
const current = entries.find((entry) => entry.value === option.currentValue);
|
||||
if (current && sameModel(current.value)) return current;
|
||||
return (
|
||||
entries.find((entry) => entry.value === model || resolved(entry.value) === observed) ??
|
||||
entries.find((entry) => sameModel(entry.value))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,9 +292,13 @@ function isForkDirectiveMessage(message: { type?: unknown; message?: unknown }):
|
||||
* The transcript of a `subagent_type: "fork"` child without the context it
|
||||
* inherited. A fork transcript starts with a copy of the parent's launching
|
||||
* assistant message (its text, thinking and every tool_use, including the
|
||||
* Agent call itself), then a user message with a "Fork started — processing
|
||||
* in background" tool_result for each of those tool_uses and the text
|
||||
* `<fork-boilerplate>…</fork-boilerplate>\nYour directive: <prompt>`. A fork
|
||||
* Agent call itself), then a user message with a placeholder tool_result for
|
||||
* each of those tool_uses and the text
|
||||
* `<fork-boilerplate>…</fork-boilerplate>\nYour directive: <prompt>`. Since
|
||||
* Claude Code 2.1.307 the fork's own Agent call gets "Fork started — that fork
|
||||
* is you." and the other tool_uses get "This is the parent's call, not
|
||||
* yours. …"; before that, every one got "Fork started — processing in
|
||||
* background". Only the directive text is matched here. A fork
|
||||
* launched from inside a subagent also gets a copy of that subagent's history
|
||||
* before them. None of it is the child's own work: replayed as is, the
|
||||
* parent's text and tool calls would show again in the child.
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* `subagentHistory`). The replay announces each child right after that call,
|
||||
* under the session id it had live, the SDK agent id, and sends the child's
|
||||
* history to the child session. Every child's end goes out before the replay
|
||||
* ends, since no subagent from before the reload still runs.
|
||||
* ends. These states are history to the client, so each child's current
|
||||
* state goes out again after the load response (`currentStates`).
|
||||
*
|
||||
* As live, the parent is not shown the Agent or Task call of a child that the
|
||||
* replay announces: the launch's prompt is the parent's outgoing message, and
|
||||
@@ -15,21 +16,38 @@
|
||||
* Unlike live, the replay does not tell runs apart, so a child that handed
|
||||
* back any report gets no reply from its launch's result.
|
||||
*
|
||||
* A SendMessage call whose message the replay delivered to a child before it
|
||||
* reached the call is that message too: the sender's outgoing view, at the
|
||||
* call, and the child's incoming one, in the child's history. A child's
|
||||
* history follows its launch, so a call to a child from its parent is always
|
||||
* delivered first. Any other SendMessage call stays a tool call.
|
||||
*
|
||||
* Right after its announcement, each child gets its `model` option: the model
|
||||
* of its first response, in its transcript. A later response with another
|
||||
* model sends it again. A child without responses gets none. After the load
|
||||
* response, a model goes again only when the session's model list, which the
|
||||
* replay can lack, maps it to another entry.
|
||||
*
|
||||
* A launch without metadata has no known id, so it is not announced: the RFD
|
||||
* forbids inventing one. Its Agent call stays an ordinary tool call of the
|
||||
* parent, and a notice says that subagent history is missing.
|
||||
*/
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
import {
|
||||
childModelOption,
|
||||
HANDBACK_TOOL,
|
||||
handbackMessage,
|
||||
isNativeSubagentControlTool,
|
||||
launchReply,
|
||||
linkedSessionMessages,
|
||||
type ModelEntry,
|
||||
nativeSubagentState,
|
||||
responseModelEntry,
|
||||
rfdEndState,
|
||||
rfdLabels,
|
||||
type SubagentModels,
|
||||
type WorkState,
|
||||
} from "./native-subagents.js";
|
||||
import { noticeUpdate } from "./session-notices.js";
|
||||
@@ -56,6 +74,10 @@ export type ReplayChild = {
|
||||
promptSent: boolean;
|
||||
/** Whether it handed a report to its parent with a SubagentHandback call. */
|
||||
handedBack?: boolean;
|
||||
/** The `model` option entry that the replay sent. */
|
||||
model?: ModelEntry;
|
||||
/** The model id of the latest response in its transcript. */
|
||||
responseModel?: string;
|
||||
};
|
||||
|
||||
type SendMessageCall = {
|
||||
@@ -63,7 +85,12 @@ type SendMessageCall = {
|
||||
to: string;
|
||||
message: string;
|
||||
senderSessionId: string;
|
||||
delivered: boolean;
|
||||
/** The child the replay delivered the message to. */
|
||||
recipientSessionId?: string;
|
||||
/** The replay reached the call before the delivery, so it stays a tool call. */
|
||||
shownAsToolCall: boolean;
|
||||
/** The sender's view of the message went out, at the call. */
|
||||
outgoingSent: boolean;
|
||||
};
|
||||
|
||||
export class RfdSubagentReplay {
|
||||
@@ -84,6 +111,13 @@ export class RfdSubagentReplay {
|
||||
constructor(
|
||||
private readonly history: SubagentHistory,
|
||||
private readonly publish: Publish,
|
||||
private readonly models?: SubagentModels,
|
||||
/**
|
||||
* Whether the replay maps a model to the session's model list as it goes.
|
||||
* A load lacks the list until the CLI started, so it maps only in
|
||||
* {@link currentStates}, after the load response.
|
||||
*/
|
||||
private readonly mapsDuringReplay = true,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -107,7 +141,8 @@ export class RfdSubagentReplay {
|
||||
to: input.to,
|
||||
message: input.message,
|
||||
senderSessionId: ownerSessionId,
|
||||
delivered: false,
|
||||
shownAsToolCall: false,
|
||||
outgoingSent: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -129,8 +164,12 @@ export class RfdSubagentReplay {
|
||||
return this.children.get(toolUseId);
|
||||
}
|
||||
|
||||
/** Announces `child` on its parent, as live: running, with the parent's labels. */
|
||||
async announce(child: ReplayChild): Promise<void> {
|
||||
/**
|
||||
* Announces `child` on its parent, as live: running, with the parent's
|
||||
* labels. Then it sends `firstModel`, the model of the child's first
|
||||
* response, when the transcript has one.
|
||||
*/
|
||||
async announce(child: ReplayChild, firstModel?: string): Promise<void> {
|
||||
if (child.announced) return;
|
||||
const labels = rfdLabels(
|
||||
child.record.name ?? child.input.name,
|
||||
@@ -148,6 +187,30 @@ export class RfdSubagentReplay {
|
||||
},
|
||||
});
|
||||
child.announced = true;
|
||||
await this.responseModel(child, firstModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the model of a replayed response of the child `owner`. A child
|
||||
* whose model changes gets its `model` option again.
|
||||
*/
|
||||
async responseModel(owner: ReplayChild, model: string | undefined): Promise<void> {
|
||||
const entry = responseModelEntry(model, this.mapsDuringReplay ? this.models : undefined);
|
||||
if (!entry || !owner.announced) return;
|
||||
owner.responseModel = model;
|
||||
await this.sendModel(owner, entry);
|
||||
}
|
||||
|
||||
private async sendModel(child: ReplayChild, model: ModelEntry): Promise<void> {
|
||||
if (child.model?.value === model.value && child.model.name === model.name) return;
|
||||
child.model = model;
|
||||
await this.publish({
|
||||
sessionId: child.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(model)],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,10 +256,11 @@ export class RfdSubagentReplay {
|
||||
/**
|
||||
* Takes a replayed update of a tool call, from the transcript of `owner`
|
||||
* when it is a child's. Returns whether the client is not shown it: an
|
||||
* update of the launch of a child that the replay shows, or of a successful
|
||||
* update of the launch of a child that the replay shows, of a SendMessage
|
||||
* call whose message the replay already delivered, or of a successful
|
||||
* SubagentHandback call of `owner`. A foreground launch's completed result
|
||||
* goes out as the child's reply, and a hand-back as the child's message to
|
||||
* its parent.
|
||||
* goes out as the child's reply, a delivered SendMessage as the sender's
|
||||
* outgoing message, and a hand-back as the child's message to its parent.
|
||||
*/
|
||||
async toolCallUpdate(
|
||||
update: AcpSessionNotification["update"],
|
||||
@@ -205,6 +269,24 @@ export class RfdSubagentReplay {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return false;
|
||||
}
|
||||
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
|
||||
if (sendMessage) {
|
||||
if (sendMessage.recipientSessionId === undefined) {
|
||||
sendMessage.shownAsToolCall = true;
|
||||
return false;
|
||||
}
|
||||
if (!sendMessage.outgoingSent) {
|
||||
sendMessage.outgoingSent = true;
|
||||
const [outgoing] = linkedSessionMessages(
|
||||
sendMessage.toolUseId,
|
||||
sendMessage.senderSessionId,
|
||||
sendMessage.recipientSessionId,
|
||||
sendMessage.message,
|
||||
);
|
||||
if (outgoing) await this.publish(outgoing);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
|
||||
if (owner && handback && !handback.failed) {
|
||||
if (!handback.sent) {
|
||||
@@ -247,19 +329,19 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the end of every announced child, and a notice when subagent history
|
||||
* is missing. The end is the state of the child's last task notification,
|
||||
* else `cancelled` for a child the user stopped, else the result of its
|
||||
* foreground launch, else `idle` with no stop reason.
|
||||
* Sends the end of every announced child (see `endState`), and a notice when
|
||||
* subagent history is missing.
|
||||
*/
|
||||
async finish(supportsNotices: boolean, rootSessionId: string): Promise<void> {
|
||||
for (const child of [...this.children.values()].reverse()) {
|
||||
if (!child.announced) continue;
|
||||
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
|
||||
const state: WorkState = end ? rfdEndState(end, undefined) : { state: "idle" };
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
state: endState(child),
|
||||
},
|
||||
});
|
||||
}
|
||||
const missing = this.omitted.size + this.withoutHistory;
|
||||
@@ -278,6 +360,56 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of each announced child, to send after the load
|
||||
* response. The RFD makes that response the freshness boundary: a client
|
||||
* treats the replayed states before it as history, so they show no current
|
||||
* activity and allow no cancel.
|
||||
*
|
||||
* `live` gives the work state of a child that still runs in this process, as
|
||||
* when a live session is loaded again. Such a child reports that state and
|
||||
* can be cancelled, as live. Any other child cannot be running, because the
|
||||
* process that ran it is gone, so its replayed end is its current state.
|
||||
*/
|
||||
currentStates(
|
||||
live: (sessionId: string) => WorkState["state"] | undefined,
|
||||
liveConfiguration?: (sessionId: string) => SessionConfigOption[] | undefined,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.children.values()]
|
||||
.filter((child) => child.announced)
|
||||
.flatMap((child): AcpSessionNotification[] => {
|
||||
const liveState = live(child.sessionId);
|
||||
const state: WorkState =
|
||||
liveState === undefined || liveState === "idle" ? endState(child) : { state: liveState };
|
||||
// The session's model list may be known only now, after the load response.
|
||||
const entry = responseModelEntry(child.responseModel, this.models);
|
||||
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
|
||||
const current =
|
||||
liveConfiguration?.(child.sessionId) ?? (entry ? [childModelOption(entry)] : undefined);
|
||||
// A configuration that the replay already sent does not go again.
|
||||
const configOptions = current && JSON.stringify(current) !== replayed ? current : undefined;
|
||||
return [
|
||||
{
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(liveState !== undefined ? { capabilities: { cancel: {} } } : {}),
|
||||
state,
|
||||
},
|
||||
},
|
||||
...(configOptions
|
||||
? [
|
||||
{
|
||||
sessionId: child.sessionId,
|
||||
update: { sessionUpdate: "config_option_update" as const, configOptions },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
toolUseId: string,
|
||||
input: Record<string, unknown>,
|
||||
@@ -322,6 +454,9 @@ export class RfdSubagentReplay {
|
||||
* first undelivered SendMessage to the child whose message the delivered
|
||||
* text contains. With that call, the message has the call's id and text and
|
||||
* its sender, as live; without it, its transcript id and the delivered text.
|
||||
* Unless the replay already showed the call as a tool call, the message
|
||||
* replaces it, so this view names the sender's view, which goes out at the
|
||||
* call (see {@link toolCallUpdate}).
|
||||
*/
|
||||
private async sendDelivery(
|
||||
child: ReplayChild,
|
||||
@@ -331,13 +466,15 @@ export class RfdSubagentReplay {
|
||||
const names = [child.sessionId, child.record.name, child.input.name];
|
||||
const call = this.sendMessages.find(
|
||||
(candidate) =>
|
||||
!candidate.delivered &&
|
||||
candidate.recipientSessionId === undefined &&
|
||||
names.includes(candidate.to) &&
|
||||
text.includes(candidate.message.trim()),
|
||||
);
|
||||
if (call) {
|
||||
call.delivered = true;
|
||||
await this.sendMessage(child, call.toolUseId, call.message, call.senderSessionId);
|
||||
call.recipientSessionId = child.sessionId;
|
||||
await this.sendMessage(child, call.toolUseId, call.message, call.senderSessionId, {
|
||||
linked: !call.shownAsToolCall,
|
||||
});
|
||||
} else {
|
||||
await this.sendMessage(child, message.uuid, text, undefined);
|
||||
}
|
||||
@@ -348,6 +485,7 @@ export class RfdSubagentReplay {
|
||||
messageId: string,
|
||||
text: string,
|
||||
sender: string | undefined,
|
||||
{ linked = false }: { linked?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await this.publish({
|
||||
sessionId: child.sessionId,
|
||||
@@ -356,12 +494,23 @@ export class RfdSubagentReplay {
|
||||
messageId,
|
||||
...(sender ? { senderSessionId: sender } : {}),
|
||||
recipientSessionId: child.sessionId,
|
||||
...(linked ? { senderMessageId: messageId } : {}),
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The model of the first response in `messages`, a subagent's transcript. */
|
||||
export function firstResponseModel(messages: readonly SessionMessage[]): string | undefined {
|
||||
for (const message of messages) {
|
||||
if (message.type !== "assistant") continue;
|
||||
const model = (message.message as { model?: unknown } | undefined)?.model;
|
||||
if (typeof model === "string" && model && model !== "<synthetic>") return model;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a foreground launch from its tool result: an error result that
|
||||
* names a stop is a cancellation, any other error a failure.
|
||||
@@ -376,6 +525,16 @@ export function replaySubagentTerminalState(
|
||||
: "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* How `child`'s work ended: the state of its last task notification, else
|
||||
* `cancelled` for a child the user stopped, else the result of its foreground
|
||||
* launch, else `idle` with no stop reason.
|
||||
*/
|
||||
function endState(child: ReplayChild): WorkState {
|
||||
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
|
||||
return end ? rfdEndState(end, undefined) : { state: "idle" };
|
||||
}
|
||||
|
||||
function replayContentText(value: unknown, seen = new Set<unknown>()): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value !== "object" || value === null || seen.has(value)) return "";
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
RequestError,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionConfigOption,
|
||||
SessionNotification,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
type HookCallback,
|
||||
importSessionToStore,
|
||||
listSessions,
|
||||
type ModelInfo,
|
||||
PermissionUpdate,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
@@ -2269,6 +2271,57 @@ describe("usage Markdown", () => {
|
||||
expect(formatted).not.toContain("lowest");
|
||||
});
|
||||
|
||||
it("renders each window's recommendations in order, escaped, before its MCP table", () => {
|
||||
const formatted = formatUsageResponse({
|
||||
...usageResponse,
|
||||
behaviors: {
|
||||
...usageResponse.behaviors,
|
||||
day: {
|
||||
...usageResponse.behaviors.day,
|
||||
recommendations: [
|
||||
{ key: "mcp_server", pct: 41, title: "Disable my_server", detail: "/mcp" },
|
||||
{ key: "effort", pct: 12, title: "Lower effort | max" },
|
||||
],
|
||||
fix: { keys: ["mcp_server"], messages: ["/mcp disable my_server"] },
|
||||
},
|
||||
week: {
|
||||
...usageResponse.behaviors.week,
|
||||
mcp_servers: [],
|
||||
recommendations: [{ key: "agent", pct: 5, title: "Trim the reviewer agent" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const day = formatted.slice(
|
||||
formatted.indexOf("**Last 24h**"),
|
||||
formatted.indexOf("**Last 7d**"),
|
||||
);
|
||||
expect(day).toContain(
|
||||
"- **41%** Disable my\\_server · /mcp\n- **12%** Lower effort \\| max\n\n| MCP server |",
|
||||
);
|
||||
expect(formatted).not.toContain("undefined");
|
||||
expect(formatted).not.toContain("/mcp disable");
|
||||
expect(
|
||||
formatted.endsWith(
|
||||
"**Last 7d** · 43 requests · 5 sessions\n\n- **5%** Trim the reviewer agent",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the CLI text for a malformed recommendation", () => {
|
||||
expect(
|
||||
parseUsageResponse({
|
||||
...usageResponse,
|
||||
behaviors: {
|
||||
...usageResponse.behaviors,
|
||||
day: {
|
||||
...usageResponse.behaviors.day,
|
||||
recommendations: [{ key: "effort", pct: "high", title: "Lower effort" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("recognizes only exact local usage commands", () => {
|
||||
expect(isUsageCommandText(" /usage ")).toBe(true);
|
||||
expect(isUsageCommandText("/cost")).toBe(false);
|
||||
@@ -3533,6 +3586,73 @@ describe("subagent transcript replay", () => {
|
||||
return updates;
|
||||
}
|
||||
|
||||
it("refines an ended AIR child's model after the load response, once the model list is known", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: SessionNotification) =>
|
||||
updates.push(update as AcpSessionNotification),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, AIR_NATIVE_SUBAGENTS);
|
||||
// A load replays while the CLI starts: the session and its model list come later.
|
||||
await (
|
||||
agent as unknown as {
|
||||
replaySessionHistory(
|
||||
sessionId: string,
|
||||
messages: unknown[],
|
||||
pending: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
).replaySessionHistory("s1", replayHistory, {
|
||||
state: { cwd: "/tmp", taskState: new Map(), messageIdToUuid: new Map() },
|
||||
stopped: false,
|
||||
});
|
||||
const child = "s1:replay-subagent:parent-agent-call";
|
||||
const models = () =>
|
||||
updates.flatMap(({ sessionId, update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => [
|
||||
sessionId,
|
||||
option.currentValue,
|
||||
(option as { options: Array<{ name: string }> }).options[0]?.name,
|
||||
])
|
||||
: [],
|
||||
);
|
||||
// Without the list, the replay sends the model id, and then ends the child.
|
||||
expect(models()).toEqual([[child, "claude-sonnet-4-5", "claude-sonnet-4-5"]]);
|
||||
expect(updates.at(-1)?.update.sessionUpdate).toBe("subagent_state_update");
|
||||
(agent.sessions as Record<string, unknown>).s1 = {
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.5" }],
|
||||
},
|
||||
],
|
||||
modelInfos: [
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-4-5",
|
||||
displayName: "Sonnet 4.5",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
await agent.publishCurrentSubagentStates("s1");
|
||||
expect(models()).toEqual([
|
||||
[child, "claude-sonnet-4-5", "claude-sonnet-4-5"],
|
||||
[child, "sonnet", "Sonnet 4.5"],
|
||||
]);
|
||||
// Once only.
|
||||
await agent.publishCurrentSubagentStates("s1");
|
||||
expect(models()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("replays a subagent from its own transcript file", async () => {
|
||||
// Claude keeps a subagent history in <session>/subagents/, and the SDK
|
||||
// returns the main transcript without it and without parent_tool_use_id.
|
||||
@@ -3689,8 +3809,11 @@ describe("subagent transcript replay", () => {
|
||||
[sessionId, "user_message_chunk", "Explore it"],
|
||||
[sessionId, "subagent_spawned", child1],
|
||||
[child1, "user_message_chunk", "Look around"],
|
||||
// The model of the child's response, as the replay reaches it.
|
||||
[child1, "config_option_update", undefined],
|
||||
[child1, "agent_message_chunk", "Child report."],
|
||||
// A launch without a subagent file still gets its child and its state.
|
||||
// Nothing names its model, so it gets none.
|
||||
[sessionId, "subagent_spawned", child2],
|
||||
[sessionId, "agent_message_chunk", "Done."],
|
||||
[sessionId, "subagent_state_update", child2],
|
||||
@@ -3757,6 +3880,19 @@ describe("subagent transcript replay", () => {
|
||||
root: object[],
|
||||
children: Record<string, { meta: object; records: object[] }>,
|
||||
capabilities: ClientCapabilities = { subagents: {}, session: { notices: {} } },
|
||||
afterResponse?: {
|
||||
live: (childSessionId: string) => string | undefined;
|
||||
/** The session's config options and model list, known after the response. */
|
||||
models?: { configOptions: SessionConfigOption[]; modelInfos: ModelInfo[] };
|
||||
},
|
||||
{
|
||||
traceModels = false,
|
||||
load = false,
|
||||
}: {
|
||||
traceModels?: boolean;
|
||||
/** Replay as a load does, while the CLI starts: without the session record. */
|
||||
load?: boolean;
|
||||
} = {},
|
||||
): Promise<string[]> {
|
||||
const configDir = await mkdtemp(path.join(os.tmpdir(), "claude-acp-rfd-replay-"));
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
@@ -3794,15 +3930,42 @@ describe("subagent transcript replay", () => {
|
||||
);
|
||||
await initializeClient(agent, capabilities);
|
||||
vi.mocked(getSessionInfo).mockResolvedValueOnce({ cwd: "/tmp/proj" } as any);
|
||||
const pending = load
|
||||
? {
|
||||
state: { cwd: "/tmp/proj", taskState: new Map(), messageIdToUuid: new Map() },
|
||||
stopped: false,
|
||||
}
|
||||
: undefined;
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory(sessionId);
|
||||
return updates.flatMap(({ sessionId: target, update }) => {
|
||||
agent as unknown as {
|
||||
replaySessionHistory(
|
||||
sessionId: string,
|
||||
messages?: unknown[],
|
||||
pending?: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
).replaySessionHistory(sessionId, undefined, pending);
|
||||
const responseAt = updates.length;
|
||||
if (afterResponse) {
|
||||
// Stands in for the live session's runtime, which knows its running children.
|
||||
(agent.sessions as Record<string, unknown>)[sessionId] = {
|
||||
nativeSubagentRuntime: { liveWorkState: afterResponse.live },
|
||||
configOptions: afterResponse.models?.configOptions ?? [],
|
||||
modelInfos: afterResponse.models?.modelInfos ?? [],
|
||||
};
|
||||
await agent.publishCurrentSubagentStates(sessionId);
|
||||
}
|
||||
return updates.flatMap(({ sessionId: target, update }, index) => {
|
||||
const on = target === sessionId ? "root" : target;
|
||||
const u = update as any;
|
||||
const response = afterResponse && index === responseAt ? ["-- response --"] : [];
|
||||
switch (update.sessionUpdate) {
|
||||
case "subagent_update":
|
||||
return [`${on} subagent_update ${u.sessionId} ${JSON.stringify(u.state)}`];
|
||||
return [
|
||||
...response,
|
||||
`${on} subagent_update ${u.sessionId} ${JSON.stringify(u.state)}` +
|
||||
(u.capabilities ? ` capabilities=${JSON.stringify(u.capabilities)}` : ""),
|
||||
];
|
||||
case "session_message": {
|
||||
const references = [
|
||||
...(u.senderMessageId ? [`senderMessageId=${u.senderMessageId}`] : []),
|
||||
@@ -3818,6 +3981,13 @@ describe("subagent transcript replay", () => {
|
||||
}
|
||||
case "tool_call":
|
||||
return [`${on} tool_call ${u.toolCallId}`];
|
||||
case "config_option_update":
|
||||
return traceModels
|
||||
? u.configOptions.map(
|
||||
(option: any) =>
|
||||
`${on} model ${option.currentValue} ${JSON.stringify(option.options)}`,
|
||||
)
|
||||
: [];
|
||||
case "notice":
|
||||
return [`${on} notice ${u.title}: ${u.description}`];
|
||||
case "user_message_chunk":
|
||||
@@ -3837,7 +4007,8 @@ describe("subagent transcript replay", () => {
|
||||
it("replays a fork child without the launching message and boilerplate it inherited", async () => {
|
||||
// A `subagent_type: "fork"` transcript starts with a copy of the
|
||||
// launching message (same API message id) and a directive message with
|
||||
// a "Fork started" result for each of its tool_uses.
|
||||
// a placeholder result for each of its tool_uses, worded as Claude Code
|
||||
// 2.1.307+ writes them.
|
||||
const launching = [
|
||||
{ type: "text", text: "I'll fork a worker." },
|
||||
toolUse("toolu_fork", "Agent", {
|
||||
@@ -3850,7 +4021,15 @@ describe("subagent transcript replay", () => {
|
||||
const forkStarted = (id: string) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: id,
|
||||
content: [{ type: "text", text: "Fork started — processing in background" }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
id === "toolu_fork"
|
||||
? "Fork started — that fork is you."
|
||||
: "This is the parent's call, not yours. It may not have run yet, and its result goes to the parent.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const trace = await replayRfd(
|
||||
[
|
||||
@@ -3984,11 +4163,13 @@ describe("subagent transcript replay", () => {
|
||||
"def456 agent_message_chunk X.",
|
||||
"abc123 session_message toolu_nested:reply from=def456 The spec says X.",
|
||||
// The delivered message has the id, text, and sender of its SendMessage
|
||||
// call, which stays a tool call of its sender.
|
||||
"abc123 session_message toolu_send from=" + sessionId + " Check the tests too",
|
||||
// call, which it replaces: the sender's view goes out at the call.
|
||||
"abc123 session_message toolu_send from=" +
|
||||
sessionId +
|
||||
" senderMessageId=toolu_send Check the tests too",
|
||||
"abc123 agent_message_chunk Tests pass.",
|
||||
"root session_message toolu_agent:reply from=abc123 Found it.",
|
||||
"root tool_call toolu_send",
|
||||
`root session_message toolu_send from=${sessionId} recipientMessageId=toolu_send Check the tests too`,
|
||||
"root agent_message_chunk Done.",
|
||||
// Every end comes before the response.
|
||||
'abc123 subagent_update def456 {"state":"idle","stopReason":"end_turn"}',
|
||||
@@ -3997,6 +4178,198 @@ describe("subagent transcript replay", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends each child's current state after the load response", async () => {
|
||||
const launch = (id: string, name: string) =>
|
||||
toolUse(id, "Agent", { name, subagent_type: "Explore", prompt: `Run ${name}` });
|
||||
const child = (name: string, launchId: string) => ({
|
||||
meta: { agentType: "Explore", name, toolUseId: launchId, requestShape: "foreground" },
|
||||
records: [user(`${name}1`, `Run ${name}`)],
|
||||
});
|
||||
const trace = await replayRfd(
|
||||
[
|
||||
user("u1", "Go"),
|
||||
assistant("a1", [launch("toolu_live", "live"), launch("toolu_gone", "gone")]),
|
||||
user("u2", [toolResult("toolu_live", "Live."), toolResult("toolu_gone", "Gone.")]),
|
||||
],
|
||||
{ aaa111: child("live", "toolu_live"), bbb222: child("gone", "toolu_gone") },
|
||||
undefined,
|
||||
// The session is loaded again in the process that still runs `aaa111`.
|
||||
{ live: (childSessionId) => (childSessionId === "aaa111" ? "running" : undefined) },
|
||||
);
|
||||
|
||||
expect(trace.slice(trace.indexOf("-- response --"))).toEqual([
|
||||
"-- response --",
|
||||
// A child that still runs reports its live state, and can be cancelled.
|
||||
'root subagent_update aaa111 {"state":"running"} capabilities={"cancel":{}}',
|
||||
// Any other child's process is gone: its replayed end is its current
|
||||
// state, and nothing can cancel it.
|
||||
'root subagent_update bbb222 {"state":"idle","stopReason":"end_turn"}',
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the model of each child's responses, and after the load response only a changed entry", async () => {
|
||||
const launch = (id: string, name: string, input: object = {}) =>
|
||||
toolUse(id, "Agent", {
|
||||
name,
|
||||
subagent_type: "general-purpose",
|
||||
prompt: `Run ${name}`,
|
||||
...input,
|
||||
});
|
||||
const answer = (uuid: string, model: string, text: string) => ({
|
||||
...assistant(uuid, []),
|
||||
message: {
|
||||
id: `m-${uuid}`,
|
||||
role: "assistant",
|
||||
model,
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
});
|
||||
const meta = (name: string, launchId: string) => ({
|
||||
agentType: "general-purpose",
|
||||
name,
|
||||
toolUseId: launchId,
|
||||
requestShape: "foreground",
|
||||
});
|
||||
const trace = await replayRfd(
|
||||
[
|
||||
user("u1", "Go"),
|
||||
assistant("a1", [
|
||||
launch("toolu_answered", "answered"),
|
||||
launch("toolu_unlisted", "unlisted", { model: "haiku" }),
|
||||
launch("toolu_silent", "silent"),
|
||||
]),
|
||||
user("u2", [
|
||||
toolResult("toolu_answered", "Done."),
|
||||
toolResult("toolu_unlisted", "Done."),
|
||||
toolResult("toolu_silent", "Done."),
|
||||
]),
|
||||
],
|
||||
{
|
||||
aaa111: {
|
||||
meta: meta("answered", "toolu_answered"),
|
||||
records: [
|
||||
user("x1", "Run answered"),
|
||||
answer("x2", "claude-haiku-4-5", "First."),
|
||||
answer("x3", "claude-haiku-4-5", "Same model."),
|
||||
answer("x4", "claude-opus-5", "Fallback."),
|
||||
],
|
||||
},
|
||||
// A model that the session's list does not have.
|
||||
bbb222: {
|
||||
meta: meta("unlisted", "toolu_unlisted"),
|
||||
records: [user("y1", "Run unlisted"), answer("y2", "claude-fable-9", "Done.")],
|
||||
},
|
||||
// No response: the replay does not guess the launch input's or the parent's model.
|
||||
ccc333: { meta: meta("silent", "toolu_silent"), records: [user("z1", "Run silent")] },
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
live: () => undefined,
|
||||
models: {
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "opus",
|
||||
options: [
|
||||
{ value: "opus", name: "Opus 5" },
|
||||
{ value: "haiku", name: "Haiku 4.5" },
|
||||
],
|
||||
},
|
||||
],
|
||||
modelInfos: [
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus 5",
|
||||
description: "",
|
||||
resolvedModel: "claude-opus-5",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku 4.5",
|
||||
description: "",
|
||||
resolvedModel: "claude-haiku-4-5",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ traceModels: true, load: true },
|
||||
);
|
||||
|
||||
const opus = JSON.stringify([{ value: "opus", name: "Opus 5" }]);
|
||||
const raw = (model: string) => JSON.stringify([{ value: model, name: model }]);
|
||||
expect(trace.filter((line) => / (model|subagent_update) /.test(line))).toEqual([
|
||||
// During the replay the session has no model list yet, so the model is the id.
|
||||
'root subagent_update aaa111 {"state":"running"}',
|
||||
`aaa111 model claude-haiku-4-5 ${raw("claude-haiku-4-5")}`,
|
||||
// A response on another model sends the model again; the same model does not.
|
||||
`aaa111 model claude-opus-5 ${raw("claude-opus-5")}`,
|
||||
'root subagent_update bbb222 {"state":"running"}',
|
||||
`bbb222 model claude-fable-9 ${raw("claude-fable-9")}`,
|
||||
'root subagent_update ccc333 {"state":"running"}',
|
||||
'root subagent_update ccc333 {"state":"idle","stopReason":"end_turn"}',
|
||||
'root subagent_update bbb222 {"state":"idle","stopReason":"end_turn"}',
|
||||
'root subagent_update aaa111 {"state":"idle","stopReason":"end_turn"}',
|
||||
// After the response, a model goes again only when the root session's
|
||||
// `model` option maps it to another entry.
|
||||
'root subagent_update aaa111 {"state":"idle","stopReason":"end_turn"}',
|
||||
`aaa111 model opus ${opus}`,
|
||||
'root subagent_update bbb222 {"state":"idle","stopReason":"end_turn"}',
|
||||
'root subagent_update ccc333 {"state":"idle","stopReason":"end_turn"}',
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a SendMessage call that the replay reaches before its delivery as a tool call", async () => {
|
||||
const launch = (id: string, name: string) =>
|
||||
toolUse(id, "Agent", { name, subagent_type: "Explore", prompt: `Run ${name}` });
|
||||
const meta = (name: string, launchId: string) => ({
|
||||
agentType: "Explore",
|
||||
name,
|
||||
toolUseId: launchId,
|
||||
requestShape: "foreground",
|
||||
});
|
||||
const trace = await replayRfd(
|
||||
[
|
||||
user("u1", "Go"),
|
||||
assistant("a1", [launch("toolu_a", "alpha"), launch("toolu_b", "beta")]),
|
||||
user("u2", [toolResult("toolu_a", "A."), toolResult("toolu_b", "B.")]),
|
||||
],
|
||||
{
|
||||
// alpha's history, which replays first, messages beta.
|
||||
aaa111: {
|
||||
meta: meta("alpha", "toolu_a"),
|
||||
records: [
|
||||
user("a1", "Run alpha"),
|
||||
assistant("a2", [
|
||||
toolUse("toolu_sib", "SendMessage", { to: "bbb222", message: "Ping sibling" }),
|
||||
]),
|
||||
user("a3", [toolResult("toolu_sib", "Message sent.")]),
|
||||
],
|
||||
},
|
||||
bbb222: {
|
||||
meta: meta("beta", "toolu_b"),
|
||||
records: [
|
||||
user("b1", "Run beta"),
|
||||
user(
|
||||
"b2",
|
||||
"The coordinator sent a message while you were working:\nPing sibling\n\nAddress this before completing your current task.",
|
||||
{ isMeta: true, origin: { kind: "coordinator" } },
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(trace.filter((line) => line.includes("toolu_sib"))).toEqual([
|
||||
// The call came first, so it stays a tool call, and the message that
|
||||
// arrives later names no other view.
|
||||
"aaa111 tool_call toolu_sib",
|
||||
"bbb222 session_message toolu_sib from=aaa111 Ping sibling",
|
||||
]);
|
||||
});
|
||||
|
||||
it("replays a child's hand-back as its message to the parent, instead of the launch's result", async () => {
|
||||
const trace = await replayRfd(
|
||||
[
|
||||
@@ -4093,6 +4466,8 @@ describe("subagent transcript replay", () => {
|
||||
const updates = await replay("native");
|
||||
expect(updates.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
|
||||
["s1", "subagent_spawned"],
|
||||
// The model of its response, as the replay reaches it.
|
||||
["s1:replay-subagent:parent-agent-call", "config_option_update"],
|
||||
["s1:replay-subagent:parent-agent-call", "agent_message_chunk"],
|
||||
["s1:replay-subagent:parent-agent-call", "tool_call"],
|
||||
["s1", "subagent_state_update"],
|
||||
@@ -7177,6 +7552,64 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
session_id: "test-session",
|
||||
});
|
||||
|
||||
it("reports the child's observed model as a singleton config without enabling changes", async () => {
|
||||
const response = (model: string) => ({
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "toolu_parent",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
id: randomUUID(),
|
||||
role: "assistant",
|
||||
model,
|
||||
content: [{ type: "text", text: "Working" }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
});
|
||||
const first = response("child-model");
|
||||
const { agent, updates } = await runAgent([
|
||||
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" },
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "toolu_parent",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: { type: "message_start", message: first.message },
|
||||
},
|
||||
first,
|
||||
response("fallback-model"),
|
||||
taskUpdated({ status: "completed" }),
|
||||
]);
|
||||
const configs = updates.filter(
|
||||
({ update }) => update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configs).toHaveLength(2);
|
||||
for (const [index, model] of ["child-model", "fallback-model"].entries()) {
|
||||
expect(configs[index]).toMatchObject({
|
||||
sessionId: "agent-42",
|
||||
update: {
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
currentValue: model,
|
||||
options: [{ value: model, name: model }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(
|
||||
updates.findIndex(({ update }) => update.sessionUpdate === "subagent_update"),
|
||||
).toBeLessThan(updates.indexOf(configs[0]!));
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: "agent-42",
|
||||
configId: "model",
|
||||
value: "fallback-model",
|
||||
}),
|
||||
).rejects.toThrow("Session not found");
|
||||
});
|
||||
|
||||
it("announces the child as running, then sends it the prompt as a message", async () => {
|
||||
const updates = await run([
|
||||
{ ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore", prompt: "Go" },
|
||||
@@ -7593,6 +8026,18 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
state: { state: "running" },
|
||||
},
|
||||
},
|
||||
// The SendMessage call's message replaces it, as a launch's does.
|
||||
{
|
||||
sessionId: "test-session",
|
||||
update: {
|
||||
sessionUpdate: "session_message",
|
||||
messageId: "toolu_send",
|
||||
senderSessionId: "test-session",
|
||||
recipientSessionId: "agent-42",
|
||||
recipientMessageId: "toolu_send",
|
||||
content: [{ type: "text", text: "Also check Windows" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "agent-42",
|
||||
update: {
|
||||
@@ -7600,6 +8045,7 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
messageId: "toolu_send",
|
||||
senderSessionId: "test-session",
|
||||
recipientSessionId: "agent-42",
|
||||
senderMessageId: "toolu_send",
|
||||
content: [{ type: "text", text: "Also check Windows" }],
|
||||
},
|
||||
},
|
||||
@@ -7744,6 +8190,7 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
messageId: "toolu_send",
|
||||
senderSessionId: "test-session",
|
||||
recipientSessionId: "agent-7",
|
||||
senderMessageId: "toolu_send",
|
||||
content: [{ type: "text", text: "Say PING" }],
|
||||
},
|
||||
},
|
||||
@@ -7763,6 +8210,17 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
// The root has the message instead of the completed SendMessage call.
|
||||
const root = updates.flatMap(({ sessionId, update }) =>
|
||||
sessionId === "test-session" &&
|
||||
"toolCallId" in update &&
|
||||
update.toolCallId === "toolu_send"
|
||||
? [update.sessionUpdate]
|
||||
: sessionId === "test-session" && update.sessionUpdate === "session_message"
|
||||
? [`session_message ${update.messageId} to=${update.recipientSessionId}`]
|
||||
: [],
|
||||
);
|
||||
expect(root).toEqual(["session_message toolu_send to=agent-7"]);
|
||||
} finally {
|
||||
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
|
||||
@@ -8430,6 +8888,8 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
expect(updates.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
|
||||
["test-session", "subagent_spawned"],
|
||||
["agent-42", "agent_message_chunk"],
|
||||
// The model of the child's response.
|
||||
["agent-42", "config_option_update"],
|
||||
["agent-42", "tool_call"],
|
||||
["agent-42", "agent_message_chunk"],
|
||||
["test-session", "subagent_state_update"],
|
||||
@@ -12902,8 +13362,6 @@ describe("skill commands in available_commands_update", () => {
|
||||
async function advertisedCommands(
|
||||
clientCapabilities: Record<string, unknown>,
|
||||
change?: () => Promise<void>,
|
||||
additionalDirectories?: string[],
|
||||
extraCommands: Array<{ name: string; description: string; argumentHint: string }> = [],
|
||||
) {
|
||||
const sessionUpdate = vi.fn(async () => {});
|
||||
const agent = new ClaudeAcpAgent({ sessionUpdate } as unknown as AcpClient, {
|
||||
@@ -12936,7 +13394,6 @@ describe("skill commands in available_commands_update", () => {
|
||||
},
|
||||
{ name: "missing", description: "No SKILL.md", argumentHint: "" },
|
||||
{ name: "review (MCP)", description: "An MCP prompt", argumentHint: "" },
|
||||
...extraCommands,
|
||||
],
|
||||
};
|
||||
yield commandsChanged;
|
||||
@@ -12968,7 +13425,7 @@ describe("skill commands in available_commands_update", () => {
|
||||
};
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
injectGeneratorSession(agent, generator, { cwd, additionalDirectories });
|
||||
injectGeneratorSession(agent, generator, { cwd });
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
@@ -13004,29 +13461,6 @@ describe("skill commands in available_commands_update", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a skill of an --add-dir root, whose name another skill holds, as <basename>:<name>", async () => {
|
||||
const addDir = path.join(cwd, "..", `${path.basename(cwd)}-repoB`);
|
||||
const addDirSkill = path.join(addDir, ".claude", "skills", "review", "SKILL.md");
|
||||
await mkdir(path.dirname(addDirSkill), { recursive: true });
|
||||
await writeFile(addDirSkill, "# review\n");
|
||||
try {
|
||||
const commands = await advertisedCommands(
|
||||
AIR_CLIENT_CAPABILITIES,
|
||||
undefined,
|
||||
[addDir],
|
||||
[{ name: `${path.basename(addDir)}:review`, description: "", argumentHint: "" }],
|
||||
);
|
||||
expect(commands.find((c) => c.name === "review")?._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, kind: "skill", skillPath } },
|
||||
});
|
||||
expect(commands.find((c) => c.name === `${path.basename(addDir)}:review`)?._meta).toEqual({
|
||||
jetbrains: { air: { version: 1, kind: "skill", skillPath: addDirSkill } },
|
||||
});
|
||||
} finally {
|
||||
await rm(addDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a client that is not AIR no _meta", async () => {
|
||||
const commands = await advertisedCommands({});
|
||||
|
||||
@@ -15530,6 +15964,132 @@ describe("usage_update computation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreground fork tool results", () => {
|
||||
it.each([
|
||||
["plain", {}, null],
|
||||
["AIR", AIR_CLIENT_CAPABILITIES, null],
|
||||
["plain nested", {}, "toolu_outer"],
|
||||
["AIR nested", AIR_CLIENT_CAPABILITIES, "toolu_outer"],
|
||||
] as const)(
|
||||
"waits for the launching side's result for a %s client",
|
||||
async (_, capabilities, parent) => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const logger = { log: vi.fn(), error: vi.fn() };
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AcpClient,
|
||||
logger,
|
||||
);
|
||||
await initializeClient(agent, capabilities);
|
||||
const input = new Pushable<any>();
|
||||
const assistant = (content: unknown[], parentToolUseId: string | null) => ({
|
||||
type: "assistant",
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
id: randomUUID(),
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content,
|
||||
usage: successfulResultMessage().usage,
|
||||
},
|
||||
});
|
||||
const user = (content: unknown[], parentToolUseId: string | null) => ({
|
||||
type: "user",
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: { role: "user", content },
|
||||
});
|
||||
const toolResult = (id: string, text: string) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: id,
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
async function* messages() {
|
||||
const { value } = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(value);
|
||||
yield assistant(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_fork",
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "fork",
|
||||
run_in_background: false,
|
||||
prompt: "Inspect the parser",
|
||||
},
|
||||
},
|
||||
],
|
||||
parent,
|
||||
);
|
||||
// The fork inherits a result for its own launch. It cannot complete
|
||||
// that call: the launching side sends the actual report later.
|
||||
yield user([toolResult("toolu_fork", "Fork started — that fork is you.")], "toolu_fork");
|
||||
yield assistant(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_child",
|
||||
name: "Agent",
|
||||
input: { subagent_type: "general-purpose", prompt: "Read the parser" },
|
||||
},
|
||||
],
|
||||
"toolu_fork",
|
||||
);
|
||||
// Keep results of tools the fork itself called, even in a message
|
||||
// that also carries the copied launch result.
|
||||
yield {
|
||||
...user(
|
||||
[
|
||||
toolResult("toolu_fork", "Fork started — processing in background"),
|
||||
toolResult("toolu_child", "child report"),
|
||||
],
|
||||
"toolu_fork",
|
||||
),
|
||||
// Filtering must not attribute the inherited launch sidecar to
|
||||
// the one result that remains in this message.
|
||||
tool_use_result: {
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "inherited launch report" }],
|
||||
},
|
||||
};
|
||||
yield user([toolResult("toolu_fork", "fork report")], parent);
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(messages()), input });
|
||||
|
||||
await expect(
|
||||
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "Inspect it" }] }),
|
||||
).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
|
||||
const completed = updates.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "tool_call_update" && update.status === "completed"
|
||||
? [{ id: update.toolCallId, content: update.content, rawOutput: update.rawOutput }]
|
||||
: [],
|
||||
);
|
||||
expect(completed.map(({ id }) => id)).toEqual(["toolu_child", "toolu_fork"]);
|
||||
expect(completed.map(({ content }) => content)).toEqual([
|
||||
[{ type: "content", content: { type: "text", text: "child report" } }],
|
||||
[{ type: "content", content: { type: "text", text: "fork report" } }],
|
||||
]);
|
||||
if (!("_meta" in capabilities)) {
|
||||
expect(completed.map(({ rawOutput }) => rawOutput)).toEqual([
|
||||
[{ type: "text", text: "child report" }],
|
||||
[{ type: "text", text: "fork report" }],
|
||||
]);
|
||||
}
|
||||
expect(logger.error.mock.calls.flat().join("\n")).not.toContain("wasn't tracked");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("assembled assistant text fallback", () => {
|
||||
const ZERO_USAGE = {
|
||||
input_tokens: 0,
|
||||
@@ -24189,6 +24749,93 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("started by a scheduler fire", () => {
|
||||
const lifecycleStarted = (fire: { wakeup_source?: string; scheduled_for?: number }) =>
|
||||
({
|
||||
type: "command_lifecycle",
|
||||
command_uuid: "scheduler-fire",
|
||||
state: "started",
|
||||
...fire,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
}) as any;
|
||||
|
||||
const autonomousTurn = async (fireFrames: (afterRunning: boolean) => any[]) => {
|
||||
const { agent, asyncTaskUpdates } = await airAgent();
|
||||
let wake = () => {};
|
||||
const woken = new Promise<void>((resolve) => (wake = resolve));
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
yield userEcho((await iter.next()).value);
|
||||
yield running();
|
||||
yield resultMessage();
|
||||
yield idle();
|
||||
await woken;
|
||||
yield* fireFrames(false);
|
||||
yield running();
|
||||
yield* fireFrames(true);
|
||||
yield assistantText("woke up");
|
||||
yield resultMessage();
|
||||
yield idle();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
|
||||
wake();
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
return asyncTaskUpdates()
|
||||
.filter((update) => update.sessionUpdate !== "async_task_state_update")
|
||||
.map(({ sessionUpdate, description }) => ({ sessionUpdate, description }));
|
||||
};
|
||||
|
||||
it("names a ScheduleWakeup fire that arrives before the running state", async () => {
|
||||
const updates = await autonomousTurn((afterRunning) =>
|
||||
afterRunning
|
||||
? []
|
||||
: [
|
||||
lifecycleStarted({
|
||||
wakeup_source: "loop_wakeup",
|
||||
scheduled_for: 1_790_000_000_000,
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
sessionUpdate: "async_task_spawned",
|
||||
description: "A scheduled wakeup fired (set for 2026-09-21T14:13:20.000Z).",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("renames the open task for a scheduled task fire after the running state", async () => {
|
||||
const updates = await autonomousTurn((afterRunning) =>
|
||||
afterRunning ? [lifecycleStarted({ wakeup_source: "schedule_wakeup" })] : [],
|
||||
);
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
sessionUpdate: "async_task_spawned",
|
||||
description: "Claude Code started a turn without a prompt.",
|
||||
},
|
||||
{ sessionUpdate: "async_task_progress", description: "A scheduled task fired." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the task as before for a started frame without a known wakeup source", async () => {
|
||||
const updates = await autonomousTurn((afterRunning) =>
|
||||
afterRunning
|
||||
? [lifecycleStarted({}), lifecycleStarted({ wakeup_source: "future_source" })]
|
||||
: [],
|
||||
);
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
sessionUpdate: "async_task_spawned",
|
||||
description: "Claude Code started a turn without a prompt.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("ends the turn task when a prompt is folded into the turn", async () => {
|
||||
const { agent, updates, asyncTaskUpdates } = await airAgent();
|
||||
let wake = () => {};
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
{"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":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"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>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"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"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"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":"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"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"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"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"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"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"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"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"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"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"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"}}}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
{"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":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"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"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"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"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"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"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"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"}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{"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"}},"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"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"}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientMessageId":"toolu_agent","recipientSessionId":"a2","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientSessionId":"a2","senderMessageId":"toolu_agent","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionId":"a2","sessionUpdate":"subagent_update","state":{"state":"idle","stopReason":"end_turn"}}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientMessageId":"toolu_late","recipientSessionId":"agent_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientSessionId":"agent_late","senderMessageId":"toolu_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_late"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_late_read"}}}
|
||||
{"kind":"notification","method":"session/update","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":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"The logs are clean.","type":"text"}],"messageId":"toolu_late:reply","recipientSessionId":"<session>","senderSessionId":"agent_late","sessionUpdate":"session_message"}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientMessageId":"toolu_native","recipientSessionId":"agent_n","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientSessionId":"agent_n","senderMessageId":"toolu_native","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","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":"notification","method":"session/update","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":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native"}},"content":[],"kind":"search","name":"Grep","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"grep","toolCallId":"toolu_native_grep"}}}
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientMessageId":"toolu_outer","recipientSessionId":"agent_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientSessionId":"agent_outer","senderMessageId":"toolu_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"capabilities":{"cancel":{}},"description":"Read the spec","sessionId":"agent_inner","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientMessageId":"toolu_inner","recipientSessionId":"agent_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientSessionId":"agent_inner","senderMessageId":"toolu_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_inner"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_grandchild_read"}}}
|
||||
{"kind":"notification","method":"session/update","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":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"content":{"text":"```\n1\tspec\n```","type":"text"},"type":"content"}],"rawOutput":"1\tspec","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_grandchild_read"}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientMessageId":"toolu_task","recipientSessionId":"agent_1","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientSessionId":"agent_1","senderMessageId":"toolu_task","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","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":"notification","method":"session/update","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":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":[],"kind":"read","locations":[],"name":"Read","rawInput":{},"sessionUpdate":"tool_call_update","status":"pending","title":"Read File","toolCallId":"toolu_sub_read"}}}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"sessionUpdate":"state_update","state":"running"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientMessageId":"toolu_transcript","recipientSessionId":"agent_t","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientSessionId":"agent_t","senderMessageId":"toolu_transcript","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_transcript"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"notification","method":"session/update","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":"notification","method":"session/update","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":"notification","method":"session/update","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"}}}
|
||||
|
||||
@@ -1116,15 +1116,37 @@ describe("ACP v2 prompts", () => {
|
||||
cwd,
|
||||
replayFrom: { type: "start" },
|
||||
});
|
||||
client.sessionUpdates.push({
|
||||
sessionId: "response",
|
||||
update: { sessionUpdate: "response" },
|
||||
} as never);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
client.sessionUpdates.filter(
|
||||
({ sessionId, update }) =>
|
||||
sessionId === "abc123" && update.sessionUpdate === "state_update",
|
||||
),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
|
||||
const child = client.sessionUpdates.flatMap(({ sessionId, update }) =>
|
||||
sessionId === "abc123" ? [update] : [],
|
||||
sessionId === "abc123" || sessionId === "response" ? [update] : [],
|
||||
);
|
||||
// The child's past `running` does not replay; its last state goes once,
|
||||
// after the replay.
|
||||
// after the replay. That state is history to the client, so the child's
|
||||
// current state follows the response: its process is gone, so it is idle.
|
||||
// The child's model, from its transcript, goes right after its
|
||||
// announcement. It does not go again after the response: it is unchanged.
|
||||
const model = expect.objectContaining({
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
expect.objectContaining({ configId: "model", currentValue: "claude-haiku-4-5" }),
|
||||
],
|
||||
});
|
||||
expect(child).toEqual([
|
||||
model,
|
||||
expect.objectContaining({ sessionUpdate: "session_message", content: text("Look around") }),
|
||||
// The client may still hold the child's message, so the replay clears it.
|
||||
{ sessionUpdate: "agent_message", messageId: "msg_child", content: [] },
|
||||
@@ -1134,7 +1156,16 @@ describe("ACP v2 prompts", () => {
|
||||
content: text("Child report.")[0],
|
||||
}),
|
||||
{ sessionUpdate: "state_update", state: "idle", stopReason: "end_turn" },
|
||||
{ sessionUpdate: "response" },
|
||||
{ sessionUpdate: "state_update", state: "idle", stopReason: "end_turn" },
|
||||
]);
|
||||
// The current state on the parent grants nothing: the child cannot run again here.
|
||||
expect(
|
||||
client.sessionUpdates.filter(
|
||||
({ update }) =>
|
||||
update.sessionUpdate === "subagent_update" && (update as any).capabilities != null,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("ends a resume with replay with one end event for a client with loadReplayEnd", async () => {
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { ALLOW_BYPASS } from "../permissions/modes.js";
|
||||
import { NativeSubagentRuntime } from "../native-subagents.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -185,6 +187,211 @@ describe("createSession options merging", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([false, true])("reports child settings through registered hooks (v2: %s)", async (v2) => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const client = createMockClient();
|
||||
client.sessionUpdate = async (notification) => {
|
||||
updates.push(notification);
|
||||
};
|
||||
const observedAgent = new ClaudeAcpAgent(client, undefined, { v2 });
|
||||
await observedAgent.initialize({ protocolVersion: 1, clientCapabilities: { subagents: {} } });
|
||||
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const session = observedAgent.sessions[sessionId]!;
|
||||
const parentOptions = structuredClone(session.configOptions);
|
||||
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
sessionId,
|
||||
session,
|
||||
(notification) => client.sessionUpdate(notification),
|
||||
{ log: () => {} },
|
||||
));
|
||||
const invoke = async (
|
||||
event: "PreToolUse" | "PostToolUse" | "PostModelSwitch" | "SubagentStart" | "SubagentStop",
|
||||
fields: Record<string, unknown>,
|
||||
) => {
|
||||
const matcher = capturedOptions!.hooks![event]!.find((matcher) => !matcher.matcher);
|
||||
expect(matcher).toBeDefined();
|
||||
for (const hook of matcher!.hooks) {
|
||||
await expect(
|
||||
hook(
|
||||
{
|
||||
hook_event_name: event,
|
||||
session_id: sessionId,
|
||||
cwd: process.cwd(),
|
||||
transcript_path: "/tmp/transcript",
|
||||
...fields,
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
),
|
||||
).resolves.toMatchObject({ continue: true });
|
||||
}
|
||||
};
|
||||
// A main-thread --agent persona has agent_type too, but no agent_id.
|
||||
await invoke("PreToolUse", {
|
||||
agent_type: "reviewer",
|
||||
permission_mode: "plan",
|
||||
effort: { level: "max" },
|
||||
});
|
||||
expect(updates).toEqual([]);
|
||||
await invoke("SubagentStart", {
|
||||
agent_id: "child",
|
||||
agent_type: "Explore",
|
||||
permission_mode: "acceptEdits",
|
||||
});
|
||||
expect(updates).toEqual([]);
|
||||
await runtime.taskStarted({ taskId: "child", subagentType: "Explore" }, async () => {});
|
||||
const configs = () =>
|
||||
updates.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "config_option_update" ? [update.configOptions] : [],
|
||||
);
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.map((option) => [option.id, option.currentValue]),
|
||||
).toEqual([
|
||||
["mode", "acceptEdits"],
|
||||
["agent", "Explore"],
|
||||
]);
|
||||
await invoke("PreToolUse", {
|
||||
agent_id: "child",
|
||||
agent_type: "Explore",
|
||||
effort: { level: "high" },
|
||||
});
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.find((option) => option.id === "effort"),
|
||||
).toMatchObject({
|
||||
category: "thought_level",
|
||||
currentValue: "high",
|
||||
options: [{ value: "high", name: "High" }],
|
||||
});
|
||||
await invoke("PostToolUse", {
|
||||
agent_id: "child",
|
||||
tool_name: "EnterPlanMode",
|
||||
permission_mode: "plan",
|
||||
});
|
||||
await invoke("PostModelSwitch", {
|
||||
agent_id: "child",
|
||||
to_model: "child-model",
|
||||
source: "auto",
|
||||
effort: { level: "low" },
|
||||
});
|
||||
await invoke("SubagentStop", { agent_id: "child", agent_type: "Explore" });
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.map((option) => [option.id, option.currentValue]),
|
||||
).toEqual([
|
||||
["mode", "plan"],
|
||||
["model", "child-model"],
|
||||
["effort", "low"],
|
||||
["agent", "Explore"],
|
||||
]);
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.every((option) => option.type === "select" && option.options.length === 1),
|
||||
).toBe(true);
|
||||
// A model of the root session's list goes as that list's entry, with its name.
|
||||
const rootModel = session.configOptions.find((option) => option.id === "model");
|
||||
const listed =
|
||||
rootModel?.type === "select"
|
||||
? (rootModel.options as Array<{ value: string; name: string }>).find(
|
||||
(entry) => entry.value !== "default",
|
||||
)
|
||||
: undefined;
|
||||
expect(listed).toBeDefined();
|
||||
await invoke("PostModelSwitch", { agent_id: "child", to_model: listed!.value, source: "auto" });
|
||||
expect(
|
||||
configs()
|
||||
.at(-1)
|
||||
?.find((option) => option.id === "model"),
|
||||
).toMatchObject({
|
||||
currentValue: listed!.value,
|
||||
options: [{ value: listed!.value, name: listed!.name }],
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(session.configOptions).toEqual(parentOptions);
|
||||
expect(
|
||||
updates
|
||||
.filter(({ update }) => update.sessionUpdate === "config_option_update")
|
||||
.every(({ sessionId: target }) => target === "child"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an AIR child's model switch under its current generation", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const client = createMockClient();
|
||||
client.sessionUpdate = async (notification) => {
|
||||
updates.push(notification);
|
||||
};
|
||||
const observedAgent = new ClaudeAcpAgent(client);
|
||||
await observedAgent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["nativeSubagentSessions"] } } },
|
||||
} as ClientCapabilities,
|
||||
});
|
||||
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const session = observedAgent.sessions[sessionId]!;
|
||||
const runtime = (session.nativeSubagentRuntime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
sessionId,
|
||||
session,
|
||||
(notification) => client.sessionUpdate(notification),
|
||||
{ log: () => {} },
|
||||
));
|
||||
const matcher = capturedOptions!.hooks!.PostModelSwitch!.find((matcher) => !matcher.matcher);
|
||||
const switchModel = async (fields: Record<string, unknown>) => {
|
||||
for (const hook of matcher!.hooks) {
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PostModelSwitch",
|
||||
session_id: sessionId,
|
||||
cwd: process.cwd(),
|
||||
transcript_path: "/tmp/transcript",
|
||||
source: "auto",
|
||||
...fields,
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
}
|
||||
};
|
||||
const deliver = async () => {};
|
||||
await runtime.taskStarted({ taskId: "child", subagentType: "Explore" }, deliver);
|
||||
await runtime.finishTask("child", "completed", deliver);
|
||||
await runtime.taskResumed("child", deliver, "Continue");
|
||||
// The hook names the SDK agent id; AIR's draft runs the resumed agent in a new session.
|
||||
await switchModel({ agent_id: "child", to_model: "claude-sonnet-4-6" });
|
||||
const rootModel = session.configOptions.find((option) => option.id === "model");
|
||||
const rootEntryName =
|
||||
rootModel?.type === "select"
|
||||
? (rootModel.options as Array<{ value: string; name: string }>).find(
|
||||
(entry) => entry.value === "claude-sonnet-4-6",
|
||||
)?.name
|
||||
: undefined;
|
||||
expect(rootEntryName).toBeDefined();
|
||||
const models = updates.flatMap(({ sessionId: target, update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => [target, option.currentValue, option])
|
||||
: [],
|
||||
);
|
||||
expect(models).toEqual([
|
||||
[
|
||||
"child:generation:2",
|
||||
"claude-sonnet-4-6",
|
||||
expect.objectContaining({
|
||||
id: "model",
|
||||
// The name of the root session's entry for the model.
|
||||
options: [{ value: "claude-sonnet-4-6", name: rootEntryName }],
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
@@ -1951,6 +2158,66 @@ describe("createSession options merging", () => {
|
||||
await loading;
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without subagents", [] as Record<string, unknown>[]],
|
||||
[
|
||||
"with a subagent",
|
||||
[
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "child-message",
|
||||
session_id: "replay-probe",
|
||||
parent_tool_use_id: "agent-call",
|
||||
message: {
|
||||
id: "api-child-message",
|
||||
model: "claude-sonnet-4-6",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "child report" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
])(
|
||||
"sends the end event before the CLI start finishes for native subagent sessions (%s)",
|
||||
async (_label, history) => {
|
||||
let releaseInit!: () => void;
|
||||
initGate = new Promise((resolve) => (releaseInit = resolve));
|
||||
sessionMessages = [userMessage("u1", "first"), ...history];
|
||||
const updates: SessionNotification[] = [];
|
||||
const loading = (
|
||||
await endAgent(updates, undefined, {
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: ["loadReplayEnd", "nativeSubagentSessions"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClientCapabilities)
|
||||
).loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
// The child's model goes as the replay reaches it, with no model list yet.
|
||||
await vi.waitFor(() =>
|
||||
expect(kinds(updates)).toEqual(
|
||||
history.length === 0
|
||||
? ["user_message_chunk", "end"]
|
||||
: [
|
||||
"user_message_chunk",
|
||||
"subagent_spawned",
|
||||
"config_option_update",
|
||||
"agent_message_chunk",
|
||||
"subagent_state_update",
|
||||
"end",
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
releaseInit();
|
||||
await loading;
|
||||
},
|
||||
);
|
||||
|
||||
it("sends the end event for an empty history", async () => {
|
||||
sessionMessages = [];
|
||||
const updates: SessionNotification[] = [];
|
||||
|
||||
@@ -2,11 +2,13 @@ import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
isDeniedModel,
|
||||
matchResumedModel,
|
||||
modelEntryFor,
|
||||
} from "../session-model.js";
|
||||
import { effortFlagSettings, settingsEffortForModel } from "../session-effort.js";
|
||||
|
||||
@@ -392,3 +394,120 @@ describe("effortFlagSettings", () => {
|
||||
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelEntryFor", () => {
|
||||
const infos: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-opus-4-6[1m]",
|
||||
displayName: "Default (recommended)",
|
||||
description: "",
|
||||
},
|
||||
{ value: "opus", resolvedModel: "claude-opus-4-6", displayName: "Opus 4.6", description: "" },
|
||||
{
|
||||
value: "opus[1m]",
|
||||
resolvedModel: "claude-opus-4-6[1m]",
|
||||
displayName: "Opus 4.6 (1M context)",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-4-5",
|
||||
displayName: "Haiku 4.5",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
/** The session's `model` option with `current` selected. */
|
||||
const options = (current: string): SessionConfigOption[] => [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: current,
|
||||
options: infos.map((info) => ({ value: info.value, name: info.displayName })),
|
||||
},
|
||||
];
|
||||
const entryOf = (current: string, model: string) => modelEntryFor(options(current), infos, model);
|
||||
|
||||
it("prefers the entry that the user picked when it runs the model up to a context hint", () => {
|
||||
// A response reports the model id without the context hint.
|
||||
expect(entryOf("opus[1m]", "claude-opus-4-6")).toEqual({
|
||||
value: "opus[1m]",
|
||||
name: "Opus 4.6 (1M context)",
|
||||
});
|
||||
expect(entryOf("default", "claude-opus-4-6")).toEqual({
|
||||
value: "default",
|
||||
name: "Default (recommended)",
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the listed entry of another model", () => {
|
||||
expect(entryOf("opus[1m]", "claude-haiku-4-5")).toEqual({ value: "haiku", name: "Haiku 4.5" });
|
||||
expect(entryOf("haiku", "claude-opus-4-6")).toEqual({ value: "opus", name: "Opus 4.6" });
|
||||
// A concrete entry comes before `default` when both run the model.
|
||||
expect(entryOf("haiku", "claude-opus-4-6[1m]")).toEqual({
|
||||
value: "opus[1m]",
|
||||
name: "Opus 4.6 (1M context)",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a model again when the session replaces its option list", () => {
|
||||
const before = options("haiku");
|
||||
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
|
||||
value: "opus",
|
||||
name: "Opus 4.6",
|
||||
});
|
||||
// The user picks `opus[1m]`: the session replaces its option list.
|
||||
expect(modelEntryFor(options("opus[1m]"), infos, "claude-opus-4-6")).toEqual({
|
||||
value: "opus[1m]",
|
||||
name: "Opus 4.6 (1M context)",
|
||||
});
|
||||
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
|
||||
value: "opus",
|
||||
name: "Opus 4.6",
|
||||
});
|
||||
});
|
||||
|
||||
it("compares a dated or a provider model id as it is", () => {
|
||||
// `resolvedModel` is the wire id that a response reports, date and provider prefix included.
|
||||
const dated: ModelInfo[] = [
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-4-5-20251001",
|
||||
displayName: "Haiku 4.5",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "eu.anthropic.claude-sonnet-5",
|
||||
displayName: "Sonnet 5",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
const datedOptions: SessionConfigOption[] = [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: dated.map((info) => ({ value: info.value, name: info.displayName })),
|
||||
},
|
||||
];
|
||||
expect(modelEntryFor(datedOptions, dated, "claude-haiku-4-5-20251001")).toEqual({
|
||||
value: "haiku",
|
||||
name: "Haiku 4.5",
|
||||
});
|
||||
expect(modelEntryFor(datedOptions, dated, "eu.anthropic.claude-sonnet-5")).toEqual({
|
||||
value: "sonnet",
|
||||
name: "Sonnet 5",
|
||||
});
|
||||
expect(modelEntryFor(datedOptions, dated, "claude-haiku-4-5")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches no entry for a model that the list does not have", () => {
|
||||
expect(entryOf("opus", "claude-sonnet-4-6")).toBeUndefined();
|
||||
expect(entryOf("opus", "claude-opus-4-7")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resumedNativeSubagentId,
|
||||
sendMessageResume,
|
||||
sendMessageResumePrompt,
|
||||
type SubagentModels,
|
||||
} from "../native-subagents.js";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
@@ -814,6 +815,19 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
},
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
const sendMessageResult = (
|
||||
toolCallId: string,
|
||||
status: "completed" | "failed",
|
||||
): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status,
|
||||
_meta: { claudeCode: { toolName: "SendMessage" } },
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
|
||||
/** A runtime whose child `worker-1` was launched by `launch-1` and runs. */
|
||||
async function runningWorker(input: Record<string, unknown> = {}) {
|
||||
@@ -842,6 +856,218 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
update.sessionUpdate === "subagent_update" && update.state ? [update.state] : [],
|
||||
);
|
||||
|
||||
it("reports only the observed child model and refreshes it on a model change", async () => {
|
||||
const { runtime, published } = await runningWorker({ model: "inherit" });
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await runtime.reportModel(null, "parent-model", deliver);
|
||||
await runtime.reportModel("launch-1", "<synthetic>", deliver);
|
||||
expect(published.some(({ update }) => update.sessionUpdate === "config_option_update")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "resume-1", prompt: "Continue" },
|
||||
deliver,
|
||||
);
|
||||
await runtime.reportModel("resume-1", "fallback-model", deliver);
|
||||
|
||||
const configs = published.filter(
|
||||
({ update }) => update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configs).toHaveLength(2);
|
||||
for (const [index, model] of ["child-model", "fallback-model"].entries()) {
|
||||
expect(configs[index]).toMatchObject({
|
||||
sessionId: "worker-1",
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: model,
|
||||
options: [{ value: model, name: model }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("buffers model observations until the child is announced", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
expect(published).toEqual([]);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
expect(published).toEqual([]);
|
||||
await deliver(launch("launch-1", {}));
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual([
|
||||
"subagent_update",
|
||||
"config_option_update",
|
||||
]);
|
||||
expect(published[1]?.sessionId).toBe("worker-1");
|
||||
});
|
||||
|
||||
const effortOption = (level: string) => ({
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
category: "thought_level",
|
||||
type: "select" as const,
|
||||
currentValue: level,
|
||||
options: [{ value: level, name: level }],
|
||||
});
|
||||
|
||||
it("merges hook observations with model snapshots without inheriting other sessions", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await runtime.reportConfiguration("worker-1", [
|
||||
effortOption("high"),
|
||||
{
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: "plan",
|
||||
options: [{ value: "plan", name: "Plan" }],
|
||||
},
|
||||
]);
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("low")]);
|
||||
await runtime.reportModel("launch-1", "fallback-model", deliver);
|
||||
const configs = published.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "config_option_update" ? [update.configOptions] : [],
|
||||
);
|
||||
expect(configs).toHaveLength(4);
|
||||
expect(configs.at(-1)?.map((option) => [option.id, option.currentValue])).toEqual([
|
||||
["mode", "plan"],
|
||||
["model", "fallback-model"],
|
||||
["effort", "low"],
|
||||
]);
|
||||
expect(
|
||||
configs.at(-1)?.every((option) => option.type === "select" && option.options.length === 1),
|
||||
).toBe(true);
|
||||
await runtime.reportConfiguration("root", [effortOption("max")]);
|
||||
expect(
|
||||
published.filter(({ update }) => update.sessionUpdate === "config_option_update"),
|
||||
).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("keeps hook observations pending until truthful parentage is announced", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
|
||||
expect(published).toEqual([]);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
async () => {},
|
||||
);
|
||||
expect(published).toEqual([]);
|
||||
await runtime.route(launch("launch-1", {}), async () => {});
|
||||
expect(published.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
|
||||
["root", "subagent_update"],
|
||||
["worker-1", "config_option_update"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes concurrent config publications and retries a failed snapshot", async () => {
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const value = child({ announced: true });
|
||||
let fail = false;
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
sessionWith(value),
|
||||
async (notification) => {
|
||||
if (fail) throw new Error("transport failed");
|
||||
published.push(notification);
|
||||
if (published.length === 1) {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
const first = runtime.reportConfiguration(value.sessionId, [effortOption("high")]);
|
||||
await started.promise;
|
||||
const second = runtime.reportConfiguration(value.sessionId, [effortOption("low")]);
|
||||
release.resolve();
|
||||
await Promise.all([first, second]);
|
||||
expect(
|
||||
published.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => option.currentValue)
|
||||
: [],
|
||||
),
|
||||
).toEqual(["high", "low"]);
|
||||
fail = true;
|
||||
await expect(
|
||||
runtime.reportConfiguration(value.sessionId, [effortOption("max")]),
|
||||
).rejects.toThrow("transport failed");
|
||||
fail = false;
|
||||
await runtime.reportConfiguration(value.sessionId, [effortOption("max")]);
|
||||
expect(published).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("forgets unannounced configuration when the runtime is cleared", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
|
||||
runtime.clear();
|
||||
await runtime.taskStarted({ taskId: "worker-1", subagentType: "Explore" }, async () => {});
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toEqual(["subagent_update"]);
|
||||
});
|
||||
|
||||
it("does not report child config without native subagent sessions", async () => {
|
||||
const deliver = vi.fn(async () => {});
|
||||
const runtime = new NativeSubagentRuntime(undefined, "root", {}, deliver, { log: () => {} });
|
||||
await runtime.reportModel("launch-1", "child-model", deliver);
|
||||
await runtime.reportConfiguration("worker-1", [effortOption("high")]);
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the Agent tool call, which the parent's session messages replace", async () => {
|
||||
const { runtime, control } = await runningWorker();
|
||||
|
||||
@@ -1085,10 +1311,15 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
await expect(runtime.route(output("launch-1"), async () => {})).resolves.toMatchObject({
|
||||
sessionId: "worker-1",
|
||||
});
|
||||
await runtime.route(sendMessage("send-1"), async () => {});
|
||||
// The SendMessage call is not shown: its message replaces it.
|
||||
await expect(runtime.route(sendMessage("send-1"), async () => {})).resolves.toBeNull();
|
||||
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
|
||||
// A repeated resume signal of the running child changes nothing.
|
||||
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
|
||||
// The SDK reports the delivery before the call's result, which stays hidden.
|
||||
await expect(
|
||||
runtime.route(sendMessageResult("send-1", "completed"), async () => {}),
|
||||
).resolves.toBeNull();
|
||||
await runtime.finishTask("worker-1", "completed", async () => {});
|
||||
|
||||
expect(states(published)).toEqual([
|
||||
@@ -1097,21 +1328,19 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
{ state: "running" },
|
||||
{ state: "idle", stopReason: "end_turn" },
|
||||
]);
|
||||
// The SendMessage call stays a tool call of its sender, so its message has
|
||||
// only the child's view, with no reference.
|
||||
// As a launch's prompt, the message goes to both, each view naming the other.
|
||||
const message = {
|
||||
sessionUpdate: "session_message",
|
||||
messageId: "send-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "worker-1",
|
||||
content: [{ type: "text", text: "Check the tests too" }],
|
||||
};
|
||||
expect(published.filter(({ update }) => update.sessionUpdate === "session_message")).toEqual([
|
||||
expect.objectContaining({ sessionId: "root" }),
|
||||
expect.objectContaining({ sessionId: "worker-1" }),
|
||||
{
|
||||
sessionId: "worker-1",
|
||||
update: {
|
||||
sessionUpdate: "session_message",
|
||||
messageId: "send-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "worker-1",
|
||||
content: [{ type: "text", text: "Check the tests too" }],
|
||||
},
|
||||
},
|
||||
{ sessionId: "root", update: { ...message, recipientMessageId: "send-1" } },
|
||||
{ sessionId: "worker-1", update: { ...message, senderMessageId: "send-1" } },
|
||||
]);
|
||||
expect(
|
||||
published.some(({ update }) => "sessionId" in update && update.sessionId !== "worker-1"),
|
||||
@@ -1156,7 +1385,22 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
.map(
|
||||
({ sessionId, update }) => `${sessionId} ${(update as { messageId: string }).messageId}`,
|
||||
),
|
||||
).toEqual(["root launch-1", "worker-1 launch-1", "worker-1 send-1"]);
|
||||
).toEqual(["root launch-1", "worker-1 launch-1", "root send-1", "worker-1 send-1"]);
|
||||
});
|
||||
|
||||
it("shows a SendMessage call whose message reached no announced child", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
published.length = 0;
|
||||
// A message to the parent, or to an agent this client was never shown,
|
||||
// resumes no announced child.
|
||||
await expect(runtime.route(sendMessage("send-2"), async () => {})).resolves.toBeNull();
|
||||
await expect(
|
||||
runtime.route(sendMessageResult("send-2", "completed"), async () => {}),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: "root",
|
||||
update: { sessionUpdate: "tool_call", toolCallId: "send-2", status: "completed" },
|
||||
});
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a SendMessage result that arrives after the run it started ended", async () => {
|
||||
@@ -1331,6 +1575,17 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
state: { state: "running" },
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "session_message",
|
||||
messageId: "send-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "echoer-1",
|
||||
recipientMessageId: "send-1",
|
||||
content: [{ type: "text", text: "Say PING" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "echoer-1",
|
||||
update: {
|
||||
@@ -1338,6 +1593,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
messageId: "send-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "echoer-1",
|
||||
senderMessageId: "send-1",
|
||||
content: [{ type: "text", text: "Say PING" }],
|
||||
},
|
||||
},
|
||||
@@ -1415,7 +1671,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
|
||||
expect(
|
||||
published.map(({ sessionId, update }) => `${sessionId} ${update.sessionUpdate}`),
|
||||
).toEqual(["root subagent_update", "echoer-1 session_message"]);
|
||||
).toEqual(["root subagent_update", "root session_message", "echoer-1 session_message"]);
|
||||
});
|
||||
|
||||
it("keeps an agent that the history cannot place, or not read, unexposed", async () => {
|
||||
@@ -2212,3 +2468,279 @@ describe("NativeSubagentRuntime in the AIR form, for an agent from before a load
|
||||
expect(spawned(published)).toEqual(["root -> worker-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeSubagentRuntime child models", () => {
|
||||
const entries = {
|
||||
default: { value: "default", name: "Default (recommended)" },
|
||||
sonnet: { value: "sonnet", name: "Sonnet 4.6" },
|
||||
};
|
||||
/** The root session's `model` option, as `modelEntryFor` maps a model id to it. */
|
||||
const models: SubagentModels = {
|
||||
entry: (model) => (model === "claude-sonnet-4-6" ? entries.sonnet : undefined),
|
||||
name: (model) => (model === "claude-opus-5" ? "Opus 5" : undefined),
|
||||
};
|
||||
const agentCall = (toolCallId: string, input: Record<string, unknown>): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: "Agent",
|
||||
status: "pending",
|
||||
rawInput: { description: "Fix the build", prompt: "Make it pass", ...input },
|
||||
_meta: {
|
||||
claudeCode: { toolName: "Agent" },
|
||||
jetbrains: { air: { version: 1, subagent: true } },
|
||||
},
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
|
||||
/** A runtime of `form` whose child `worker-1` the call `launch-1` with `input` launched. */
|
||||
async function spawn(form: "rfd" | "air", input: Record<string, unknown> = {}) {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
form,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
undefined,
|
||||
undefined,
|
||||
models,
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await deliver(agentCall("launch-1", input));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
return { runtime, published, deliver };
|
||||
}
|
||||
/** Each child `model` option sent: its session, value, and name, the only choice. */
|
||||
const modelUpdates = (published: AcpSessionNotification[]) =>
|
||||
published.flatMap(({ sessionId, update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => {
|
||||
const choices = option.type === "select" ? option.options : [];
|
||||
expect(option).toMatchObject({ id: "model", category: "model", type: "select" });
|
||||
expect(choices).toEqual([{ value: option.currentValue, name: expect.any(String) }]);
|
||||
return [sessionId, option.currentValue, (choices[0] as { name: string }).name];
|
||||
})
|
||||
: [],
|
||||
);
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: sends no model before the child's first response, whatever the launch asks for",
|
||||
async (form) => {
|
||||
const { runtime, published, deliver } = await spawn(form, { model: "sonnet" });
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
await runtime.reportModel("launch-1", "<synthetic>", deliver);
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: sends the model again only when the entry changes",
|
||||
async (form) => {
|
||||
const { runtime, published, deliver } = await spawn(form);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
// The root session's own responses do not change the child.
|
||||
await runtime.reportModel(null, "claude-opus-5", deliver);
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
// A model id that matches no entry goes as the id, with its display name.
|
||||
await runtime.reportModel("launch-1", "claude-opus-5", deliver);
|
||||
await runtime.reportModel("launch-1", "claude-opus-5", deliver);
|
||||
expect(modelUpdates(published)).toEqual([
|
||||
["worker-1", "sonnet", "Sonnet 4.6"],
|
||||
["worker-1", "claude-opus-5", "Opus 5"],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("air: drops a model update that waits for an earlier one when the child ends", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const firstSent = deferred();
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
// The client takes the first model update slowly.
|
||||
if (notification.update.sessionUpdate === "config_option_update") await firstSent.promise;
|
||||
},
|
||||
{ log: () => {} },
|
||||
undefined,
|
||||
undefined,
|
||||
models,
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await deliver(agentCall("launch-1", {}));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
const first = runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
await vi.waitFor(() => expect(modelUpdates(published)).toHaveLength(1));
|
||||
// The child ends while the client has not taken the first update yet, and
|
||||
// a model update comes in meanwhile.
|
||||
const finished = runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
const second = runtime.reportModel("launch-1", "claude-opus-5", deliver);
|
||||
firstSent.resolve();
|
||||
await Promise.all([first, second, finished]);
|
||||
// Nothing follows the end.
|
||||
expect(published.at(-1)?.update.sessionUpdate).toBe("subagent_state_update");
|
||||
});
|
||||
|
||||
it("air: reports a hook's configuration under the agent's current generation", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
// The SDK resumes the agent: AIR's draft opens a new generation session.
|
||||
await runtime.taskResumed("worker-1", deliver, "Continue");
|
||||
const generation = published
|
||||
.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "subagent_spawned" ? [update.subagentSessionId] : [],
|
||||
)
|
||||
.at(-1);
|
||||
expect(generation).toBe("worker-1:generation:2");
|
||||
// A hook names the SDK agent id, not the generation session.
|
||||
await runtime.reportConfiguration("worker-1", [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.6" }],
|
||||
},
|
||||
]);
|
||||
expect(modelUpdates(published)).toEqual([[generation, "sonnet", "Sonnet 4.6"]]);
|
||||
});
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: keeps only the latest model of a child that is not announced yet",
|
||||
async (form) => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
form,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
undefined,
|
||||
undefined,
|
||||
models,
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
// More responses than the buffer of one parent holds (32), before the launch call.
|
||||
for (let index = 0; index < 40; index++) {
|
||||
await runtime.reportModel(
|
||||
"launch-1",
|
||||
index % 2 ? "claude-opus-5" : "claude-sonnet-4-6",
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
await runtime.route(
|
||||
{
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "working" },
|
||||
_meta: { claudeCode: { parentToolUseId: "launch-1" } },
|
||||
},
|
||||
} as AcpSessionNotification,
|
||||
deliver,
|
||||
);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
await deliver(agentCall("launch-1", {}));
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "claude-opus-5", "Opus 5"]]);
|
||||
// The child's output is not crowded out of the buffer.
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toContain("agent_message_chunk");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: sends a model again after its publication failed",
|
||||
async (form) => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
let failures = 1;
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
form,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
if (notification.update.sessionUpdate === "config_option_update" && failures-- > 0) {
|
||||
throw new Error("client gone");
|
||||
}
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
undefined,
|
||||
undefined,
|
||||
models,
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
await deliver(agentCall("launch-1", {}));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
await expect(runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver)).rejects.toThrow(
|
||||
"client gone",
|
||||
);
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
},
|
||||
);
|
||||
|
||||
it("air: forgets the configuration of an ended child", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
expect(runtime.configurationOf("worker-1")).toHaveLength(1);
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
expect(runtime.configurationOf("worker-1")).toBeUndefined();
|
||||
// A hook after the end stores nothing either.
|
||||
await runtime.reportConfiguration("worker-1", [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.6" }],
|
||||
},
|
||||
]);
|
||||
expect(runtime.configurationOf("worker-1")).toBeUndefined();
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
});
|
||||
|
||||
it("air: sends no model to a child after its end", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
expect(modelUpdates(published)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,8 @@ const launch = (uuid: string, launchId: string) => ({
|
||||
});
|
||||
/**
|
||||
* The directive message that Claude Code writes after the copy of the launching
|
||||
* message in a `subagent_type: "fork"` child's transcript.
|
||||
* message in a `subagent_type: "fork"` child's transcript, with the tool_result
|
||||
* text of Claude Code before 2.1.307.
|
||||
*/
|
||||
const forkDirective = (uuid: string, launchIds: string[], prompt: string) => ({
|
||||
...base,
|
||||
|
||||
@@ -4575,7 +4575,7 @@ describe("Skill tool rendering", () => {
|
||||
});
|
||||
|
||||
describe("_meta.jetbrains.air.skill.path", () => {
|
||||
const skillMeta = (skill: string, cwd?: string, additionalDirectories?: string[]) =>
|
||||
const skillMeta = (skill: string, cwd?: string) =>
|
||||
(
|
||||
toAcpNotifications(
|
||||
[{ type: "tool_use", id: "toolu_skill_path", name: "Skill", input: { skill } }] as any,
|
||||
@@ -4584,7 +4584,7 @@ describe("Skill tool rendering", () => {
|
||||
{},
|
||||
{} as AcpClient,
|
||||
mockLogger,
|
||||
{ clientCapabilities: AIR_CLIENT, ...(cwd ? { cwd } : {}), additionalDirectories },
|
||||
{ clientCapabilities: AIR_CLIENT, ...(cwd ? { cwd } : {}) },
|
||||
)[0]?.update as any
|
||||
)?._meta?.jetbrains?.air?.skill;
|
||||
|
||||
@@ -4659,53 +4659,6 @@ describe("Skill tool rendering", () => {
|
||||
expect(skillMeta("reviewer:audit", root).path).toBe(file);
|
||||
});
|
||||
|
||||
it("never resolves a prefix:name skill to the unscoped skill of that name", () => {
|
||||
// Claude Code 2.1.309 offers an --add-dir skill whose name is taken as `<folder>:<name>`.
|
||||
writeSkill(".claude/skills/review");
|
||||
writeSkill(".claude/skills/audit", "audit");
|
||||
expect(skillMeta("repoB:review", root)).toEqual({ name: "repoB:review" });
|
||||
expect(skillMeta("repoB:audit", root)).toEqual({ name: "repoB:audit" });
|
||||
expect(skillMeta("review", root).path).toBe(
|
||||
path.join(root, ".claude/skills/review/SKILL.md"),
|
||||
);
|
||||
});
|
||||
|
||||
describe("in an --add-dir root", () => {
|
||||
const cwd = () => path.join(root, "w/repoA");
|
||||
|
||||
it("resolves <basename>:<name> to the root's skill and <name> to the cwd's", () => {
|
||||
const own = writeSkill("w/repoA/.claude/skills/review");
|
||||
const added = writeSkill("w/repoB/.claude/skills/review");
|
||||
expect(skillMeta("repoB:review", cwd(), ["../repoB"]).path).toBe(added);
|
||||
expect(skillMeta("review", cwd(), ["../repoB"]).path).toBe(own);
|
||||
});
|
||||
|
||||
it("labels a root <parent>/<basename> when another root has its basename", () => {
|
||||
writeSkill("w/one/app/.claude/skills/deploy");
|
||||
const second = writeSkill("w/two/app/.claude/skills/deploy");
|
||||
const roots = [path.join(root, "w/one/app"), path.join(root, "w/two/app")];
|
||||
expect(skillMeta("two/app:deploy", cwd(), roots).path).toBe(second);
|
||||
expect(skillMeta("app:deploy", cwd(), roots)).toEqual({ name: "app:deploy" });
|
||||
});
|
||||
|
||||
it("resolves a skill of a .claude folder below the root by its path", () => {
|
||||
const nested = writeSkill("w/repoB/tools/.claude/skills/lint");
|
||||
expect(skillMeta("repoB/tools:lint", cwd(), ["../repoB"]).path).toBe(nested);
|
||||
});
|
||||
|
||||
it("resolves a root's skill that no other skill names by its plain name", () => {
|
||||
const added = writeSkill("w/repoB/.claude/skills/release");
|
||||
expect(skillMeta("release", cwd(), ["../repoB"]).path).toBe(added);
|
||||
});
|
||||
|
||||
it("never leaves the root for a scope with ..", () => {
|
||||
writeSkill("w/outside/.claude/skills/review");
|
||||
expect(skillMeta("repoB/../outside:review", cwd(), ["../repoB"])).toEqual({
|
||||
name: "repoB/../outside:review",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the path when no known layout holds the skill", () => {
|
||||
const meta = skillMeta("nonexistent", root);
|
||||
expect(meta).toEqual({ name: "nonexistent" });
|
||||
|
||||
@@ -236,18 +236,13 @@ export class AcpToolCallRenderer {
|
||||
*/
|
||||
toolCall(
|
||||
toolUse: ToolUse,
|
||||
options: {
|
||||
cwd?: string;
|
||||
additionalDirectories?: readonly string[];
|
||||
inputComplete?: boolean;
|
||||
previewContent?: ToolCallContent[];
|
||||
} = {},
|
||||
options: { cwd?: string; inputComplete?: boolean; previewContent?: ToolCallContent[] } = {},
|
||||
): ToolCallUpdate {
|
||||
const facts = this.facts(toolUse, options.cwd);
|
||||
const isMcp = toolUse.name.startsWith("mcp__");
|
||||
return {
|
||||
_meta: {
|
||||
...this.toolUseMeta(toolUse, options.cwd, options.additionalDirectories),
|
||||
...this.toolUseMeta(toolUse, options.cwd),
|
||||
...(this.hasTerminal(facts) ? { terminal_info: { terminal_id: toolUse.id } } : {}),
|
||||
// Only AIR gets the MCP flag. The upstream adapter does not send it.
|
||||
...(isMcp && this.capabilities.air.client ? { is_mcp_tool_call: true } : {}),
|
||||
@@ -269,14 +264,10 @@ export class AcpToolCallRenderer {
|
||||
}
|
||||
|
||||
/** The report of a tool call whose input is complete now. */
|
||||
refinement(
|
||||
toolUse: ToolUse,
|
||||
cwd?: string,
|
||||
additionalDirectories?: readonly string[],
|
||||
): ToolCallUpdate {
|
||||
refinement(toolUse: ToolUse, cwd?: string): ToolCallUpdate {
|
||||
const facts = this.facts(toolUse, cwd);
|
||||
return {
|
||||
_meta: this.toolUseMeta(toolUse, cwd, additionalDirectories),
|
||||
_meta: this.toolUseMeta(toolUse, cwd),
|
||||
toolCallId: toolUse.id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
rawInput: this.rawInput(facts, toolUse.input),
|
||||
@@ -294,15 +285,10 @@ export class AcpToolCallRenderer {
|
||||
* invalid. AIR gets no `rawInput`, which goes out once it is complete. Every
|
||||
* other client gets the partial input as `rawInput`.
|
||||
*/
|
||||
partialRefinement(
|
||||
toolUse: Pick<ToolUse, "id" | "name">,
|
||||
input: unknown,
|
||||
cwd?: string,
|
||||
additionalDirectories?: readonly string[],
|
||||
) {
|
||||
partialRefinement(toolUse: Pick<ToolUse, "id" | "name">, input: unknown, cwd?: string) {
|
||||
const facts = this.facts({ name: toolUse.name, input }, cwd);
|
||||
return {
|
||||
_meta: this.toolUseMeta({ name: toolUse.name, input }, cwd, additionalDirectories),
|
||||
_meta: this.toolUseMeta({ name: toolUse.name, input }, cwd),
|
||||
toolCallId: toolUse.id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
...(this.capabilities.air.client ? {} : { rawInput: input }),
|
||||
@@ -774,14 +760,9 @@ export class AcpToolCallRenderer {
|
||||
* The `_meta` of a tool use report: the tool name, and for AIR the tool
|
||||
* flags under `_meta.jetbrains.air`. A shell description stays out of the
|
||||
* standard `title`, which clients use as the command preview. Every other
|
||||
* client gets only the upstream tool name. [additionalDirectories] are the
|
||||
* session's `--add-dir` roots, where a Skill tool's SKILL.md can also be.
|
||||
* client gets only the upstream tool name.
|
||||
*/
|
||||
toolUseMeta(
|
||||
toolUse: { name: string; input?: unknown },
|
||||
cwd?: string,
|
||||
additionalDirectories?: readonly string[],
|
||||
): ToolUpdateMeta {
|
||||
toolUseMeta(toolUse: { name: string; input?: unknown }, cwd?: string): ToolUpdateMeta {
|
||||
const input =
|
||||
toolUse.input !== null && typeof toolUse.input === "object"
|
||||
? (toolUse.input as Record<string, unknown>)
|
||||
@@ -798,7 +779,7 @@ export class AcpToolCallRenderer {
|
||||
if (description) meta = withAirMeta(meta, AIR_COMMAND_TITLE_KEY, description);
|
||||
if (subagent) meta = withAirMeta(meta, AIR_SUBAGENT_KEY, true);
|
||||
if (skillName) {
|
||||
const skillPath = resolveSkillPath(skillName, cwd, undefined, additionalDirectories);
|
||||
const skillPath = resolveSkillPath(skillName, cwd);
|
||||
meta = withAirMeta(meta, AIR_SKILL_KEY, {
|
||||
name: skillName,
|
||||
...(skillPath ? { path: skillPath } : {}),
|
||||
|
||||
@@ -388,28 +388,6 @@ function escapesContainer(segment: string): boolean {
|
||||
return path.isAbsolute(segment) || segment.split(/[/\\]/).includes("..");
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder whose `.claude/skills` holds the `<scope>:<name>` skill of an `--add-dir` root, as Claude Code
|
||||
* 2.1.309+ spells it. The scope starts with the root's label: its basename, or `<parent>/<basename>` when
|
||||
* another root has the same basename. A skill in a `.claude` folder below the root adds the path to that
|
||||
* folder. With `/w/repoB` added, `repoB:review` is `/w/repoB/.claude/skills/review` and `repoB/tools:lint`
|
||||
* is `/w/repoB/tools/.claude/skills/lint`.
|
||||
*/
|
||||
function addDirSkillBase(scope: string, addDirs: readonly string[]): string | undefined {
|
||||
for (const dir of addDirs) {
|
||||
const basename = path.basename(dir);
|
||||
const parent = path.basename(path.dirname(dir));
|
||||
const sharesBasename = addDirs.some(
|
||||
(other) => other !== dir && path.basename(other) === basename,
|
||||
);
|
||||
const label = sharesBasename && parent !== "" ? `${parent}/${basename}` : basename;
|
||||
if (label === "") continue;
|
||||
if (scope === label) return dir;
|
||||
if (scope.startsWith(`${label}/`)) return path.join(dir, scope.slice(label.length + 1));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path of a skill's `SKILL.md`, or `undefined` when none of the known layouts holds one.
|
||||
*
|
||||
@@ -426,16 +404,11 @@ function addDirSkillBase(scope: string, addDirs: readonly string[]): string | un
|
||||
*
|
||||
* [scans] holds the container listings of one resolution pass. Pass the same cache for every command of one
|
||||
* list, and each container is listed once instead of once per command.
|
||||
*
|
||||
* [additionalDirectories] are the session's `--add-dir` roots, relative ones taken from [cwd]. Claude Code
|
||||
* offers a skill of such a root by its plain name, or as `<label>:<name>` when another skill holds the name
|
||||
* (see {@link addDirSkillBase}).
|
||||
*/
|
||||
export function resolveSkillPath(
|
||||
skillName: string,
|
||||
cwd?: string,
|
||||
scans?: SkillScanCache,
|
||||
additionalDirectories?: readonly string[],
|
||||
): string | undefined {
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
@@ -477,21 +450,10 @@ export function resolveSkillPath(
|
||||
containers.push(path.join(marketplaces, marketplace, "plugins", scope, "skills"));
|
||||
}
|
||||
};
|
||||
const addDirs = (additionalDirectories ?? []).map((dir) => path.resolve(cwd, dir));
|
||||
if (scope !== undefined) {
|
||||
const base = addDirSkillBase(scope, addDirs);
|
||||
if (base !== undefined) addContainers(base);
|
||||
}
|
||||
addScopedContainers(cwd);
|
||||
addScopedContainers(os.homedir());
|
||||
// A `<prefix>:<name>` command is never the unscoped `<name>` skill. Since Claude Code 2.1.309 an
|
||||
// `--add-dir` skill whose name is taken is offered as `<folder>:<name>`, and `<cwd>/.claude/skills/<name>`
|
||||
// is then the other skill that holds the name.
|
||||
if (scope === undefined) {
|
||||
addContainers(cwd);
|
||||
addContainers(os.homedir());
|
||||
for (const dir of addDirs) addContainers(dir);
|
||||
}
|
||||
addContainers(cwd);
|
||||
addContainers(os.homedir());
|
||||
for (const container of containers) {
|
||||
const candidate = path.join(container, name, "SKILL.md");
|
||||
let stats: Stats;
|
||||
|
||||
@@ -431,7 +431,7 @@ export const createPostToolUseHook =
|
||||
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse") {
|
||||
// Handle EnterPlanMode tool - notify client of mode change after successful execution
|
||||
if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
|
||||
if (input.tool_name === "EnterPlanMode" && !input.agent_id && options?.onEnterPlanMode) {
|
||||
await options.onEnterPlanMode();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,18 @@ const usageWindowSchema = z
|
||||
.nullable()
|
||||
.optional();
|
||||
const contributionSchema = z.object({ name: z.string(), pct: percentSchema });
|
||||
// Claude Code 2.1.305+ suggests one change per notable source, largest share first.
|
||||
const recommendationSchema = z.object({
|
||||
key: z.string(),
|
||||
pct: percentSchema,
|
||||
title: z.string(),
|
||||
detail: z.string().optional(),
|
||||
});
|
||||
const behaviorPeriodSchema = z.object({
|
||||
request_count: countSchema,
|
||||
session_count: countSchema,
|
||||
mcp_servers: z.array(contributionSchema),
|
||||
recommendations: z.array(recommendationSchema).optional(),
|
||||
});
|
||||
const modelUsageSchema = z.object({
|
||||
inputTokens: countSchema,
|
||||
@@ -133,12 +141,23 @@ function appendContributions(
|
||||
request_count: number;
|
||||
session_count: number;
|
||||
mcp_servers: { name: string; pct: number }[];
|
||||
recommendations?: { pct: number; title: string; detail?: string }[];
|
||||
},
|
||||
): void {
|
||||
lines.push(
|
||||
"",
|
||||
`**${label}** · ${period.request_count} requests · ${period.session_count} sessions`,
|
||||
);
|
||||
// Rendered in the order Claude Code sends them, as its own `/usage` does. The
|
||||
// window's `fix` is left out: its messages must be queued at priority "later"
|
||||
// with `client_composed` set, which an ACP prompt cannot express.
|
||||
if (period.recommendations?.length) {
|
||||
lines.push("");
|
||||
for (const recommendation of period.recommendations) {
|
||||
const detail = recommendation.detail ? ` · ${escapeMarkdown(recommendation.detail)}` : "";
|
||||
lines.push(`- **${recommendation.pct}%** ${escapeMarkdown(recommendation.title)}${detail}`);
|
||||
}
|
||||
}
|
||||
if (period.mcp_servers.length === 0) return;
|
||||
lines.push("", "| MCP server | Usage |", "|:--|--:|");
|
||||
for (const server of [...period.mcp_servers].sort((a, b) => b.pct - a.pct).slice(0, 3)) {
|
||||
|
||||
@@ -95,10 +95,11 @@ export function v2AgentApp(
|
||||
sessionId: params.sessionId,
|
||||
update: stateSnapshot(agent.foregroundState(params.sessionId)),
|
||||
});
|
||||
return {
|
||||
...v2ResumeSessionResponse(response),
|
||||
...(await setupCommands(agent, params.sessionId, logger)),
|
||||
};
|
||||
const commands = await setupCommands(agent, params.sessionId, logger);
|
||||
// The response ends a replay. The subagents RFD treats the replayed
|
||||
// subagent states before it as history, so their current states follow it.
|
||||
setTimeout(() => void agent.publishCurrentSubagentStates(params.sessionId), 0);
|
||||
return { ...v2ResumeSessionResponse(response), ...commands };
|
||||
})
|
||||
.onRequest(v2.methods.agent.session.fork, ({ params }) =>
|
||||
agent.forking(params.sessionId, async () => {
|
||||
|
||||
Reference in New Issue
Block a user