mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-07 18:01:07 +00:00
Compare commits
13 Commits
v0.83.0
...
v0.85.2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b69852cfc | ||
|
|
c60fa48fd8 | ||
|
|
686c0c99b3 | ||
|
|
8e893f51ea | ||
|
|
0c862e2949 | ||
|
|
4ffa625977 | ||
|
|
7f8d5471e2 | ||
|
|
c84845272f | ||
|
|
af0b43de94 | ||
|
|
f53b0d7f40 | ||
|
|
4af807968c | ||
|
|
bdb50ad984 | ||
|
|
d5e6f33046 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.83.0"
|
||||
".": "0.85.1"
|
||||
}
|
||||
|
||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## [0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1) (2026-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* answer session/close without waiting for the interrupt reply ([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216)) ([0c862e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c862e29496bb018e7a4400697bd2315dfd5b845))
|
||||
* fail turns with unfinished tools ([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212)) ([7f8d547](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f8d5471e24b981b129b3c9c2d6736c4ba3f8011))
|
||||
* restore background task stops on replay instead of showing them as prompts ([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205)) ([4ffa625](https://github.com/agentclientprotocol/claude-agent-acp/commit/4ffa625977af7e85526b9a3098f4dee5844467b4))
|
||||
|
||||
## [0.85.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.84.0...v0.85.0) (2026-10-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.286 ([#1211](https://github.com/agentclientprotocol/claude-agent-acp/issues/1211)) ([f53b0d7](https://github.com/agentclientprotocol/claude-agent-acp/commit/f53b0d7f40bcd0e9bddace85f123ff4851afcb1f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt to SDK 0.3.284 replay and ultracode changes ([#1208](https://github.com/agentclientprotocol/claude-agent-acp/issues/1208)) ([4af8079](https://github.com/agentclientprotocol/claude-agent-acp/commit/4af807968c7f0813bcb20fe2bcab2370320e9074))
|
||||
* send the legacy custom answer key to AIR ([#1210](https://github.com/agentclientprotocol/claude-agent-acp/issues/1210)) ([af0b43d](https://github.com/agentclientprotocol/claude-agent-acp/commit/af0b43de944e36f81343f40b7aa1a96cd48c3218))
|
||||
|
||||
## [0.84.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.83.0...v0.84.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1189](https://github.com/agentclientprotocol/claude-agent-acp/issues/1189)) ([d5e6f33](https://github.com/agentclientprotocol/claude-agent-acp/commit/d5e6f33046614fca4798d49f0c77edba951a6771))
|
||||
|
||||
## [0.83.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.82.0...v0.83.0) (2026-09-28)
|
||||
|
||||
|
||||
|
||||
@@ -1204,6 +1204,10 @@ For AIR, the companion field carries `_meta.jetbrains.air.customAnswer`:
|
||||
`questionId` names the select field of the same question.
|
||||
A client can render the companion as the free-text choice of that question.
|
||||
|
||||
For AIR, the companion field also carries the same value under the root key `_meta._askUserQuestionCustomAnswer`.
|
||||
Released AIR versions read only that key.
|
||||
A client that is not AIR gets neither key.
|
||||
|
||||
## Session fork point
|
||||
|
||||
AIR can fork a session at one agent message.
|
||||
@@ -1255,12 +1259,11 @@ The adapter sends the key only to an AIR client.
|
||||
|
||||
These keys moved into the AIR namespace. The adapter sends the new key only to AIR, and the old key to no client.
|
||||
|
||||
| Old key | New key |
|
||||
| --------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
|
||||
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
|
||||
| elicitation property `_meta._askUserQuestionCustomAnswer` | `_meta.jetbrains.air.customAnswer`, same value |
|
||||
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
|
||||
| Old key | New key |
|
||||
| --------------------------------------------------------- | --------------------------------------- |
|
||||
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
|
||||
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
|
||||
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
|
||||
|
||||
The adapter does not send `_meta.claudeCode.title`, `claudeCode.subagent`, `claudeCode.skill`, or `claudeCode.skillPath` to any client.
|
||||
AIR reads `_meta.jetbrains.air.commandTitle`, `subagent`, and `skill` instead.
|
||||
|
||||
438
package-lock.json
generated
438
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.83.0",
|
||||
"version": "0.85.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.83.0",
|
||||
"version": "0.85.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.5.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.283",
|
||||
"@agentclientprotocol/sdk": "1.7.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.286",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
@@ -18,52 +18,52 @@
|
||||
"claude-agent-acp": "dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.128.0",
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.70.1",
|
||||
"@typescript-eslint/parser": "8.70.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.0",
|
||||
"@typescript-eslint/parser": "8.71.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.11.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.12.0",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.2"
|
||||
"vitest": "5.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.5.1.tgz",
|
||||
"integrity": "sha512-nSUIrC1fOR9+ppKfx9KdwjBBND2sAPzFHo4yVQrGbXZHnayUYpwL8CDVR5LSxbapY/oKMOzNP0wvwfyx3PS2KA==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.7.0.tgz",
|
||||
"integrity": "sha512-ZORuxsuEnLjRly7t6Ad7zAsWitjWsyprswyPUHz/yycPb+tTm3DGa8XheaHhRXqX8LS9snuxaYavXSRA50TlHw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.283",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.283.tgz",
|
||||
"integrity": "sha512-KB+mqU5JLbH2sztlSQeCOu71bK6padYAha3uacBzxFSOVfuRTywYzvsC9P+qV6gXmPXcu98FaPqQv6vBF9j8hA==",
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.286.tgz",
|
||||
"integrity": "sha512-InL/UNmRGSwBM/81PME0J0TZDsDBBlweWqRZgq2XSViSIg2hBi8nIL8j9Hm6MHRH85wgDJQE5n6Vo/r9hIO0NQ==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.283",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.283",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.283",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.283",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.283",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.283",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.283",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.283"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.286",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.286"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -72,9 +72,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.283",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.283.tgz",
|
||||
"integrity": "sha512-UQkROekjufppyB/qrsrU81sM0fcYNWJBEITrGp7NLbOocJZBUR+uVMIx/UHVpe6j81trXPIeRWyfJWgZI17Kxg==",
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.286.tgz",
|
||||
"integrity": "sha512-gkxWcJ+Z23UxwghI1V3dL09PkELIZmB2vPelR8XsdfhS+yP1KvoW7FThvRLojcxXb3fj0ddYawjQSQYIkXFbxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -85,9 +85,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.283",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.283.tgz",
|
||||
"integrity": "sha512-WkwVppmX0cg1DnXTT9od82pmqM4OK9H3O6MIXoX89SWYty0OpMxsB37263C1tiqk4WkWCs2jwxRXhsoO62ArGQ==",
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.286.tgz",
|
||||
"integrity": "sha512-eMdni7sy1ud2IISI4QSsfVBCxotzSe62zCGdXISejL9MxDIwcRgEGZpO5OV+j7t8mNGMTe6Opl1t/3Z/1RUJaQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -98,9 +98,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.283",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.283.tgz",
|
||||
"integrity": "sha512-47IEX/XWw4DUIzPPC85qiASO90rpz7E+2gWr4AKOz5ZAa7ZqEX0PYqPIcpHEM7WpEREfmXCMZgef2bwim24u8A==",
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.286.tgz",
|
||||
"integrity": "sha512-3h+WWGek9beZ6i1qbIjjwYEFbs46D6qumjSBc6cLsEvnufcoi7mc0iBwbFSh9yRb3upxoi4ZTJdl53T3gLTdUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -114,9 +114,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.283",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.283.tgz",
|
||||
"integrity": "sha512-BRlnlh5fsRMoTtjDSGxZL6TathGRgSCMpJk2kdy9Wtz8l6qDjgkLuQilzTF12CwFIn+yf1Fqfrk+Njw+0EsL3A==",
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.286.tgz",
|
||||
"integrity": "sha512-fG8Cqx53jkFyEL86ETA0tdLH3p06yFQKHcoxnMpiU+VmJ5g6uGFhsUZbJ6gFvuT3EgyMcNI2kZwhCCDF/DTnpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -130,9 +130,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.283",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.283.tgz",
|
||||
"integrity": "sha512-cE5AebMvTlq7t7Oc0FmP5LzMOEtJ3F8XRGxkc3kTGr4aNcQgXplyYNH2yu1teW1yInuMXHzlCeFRdrmxpe2sRg==",
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.286.tgz",
|
||||
"integrity": "sha512-kNczbWhWJ1G8sPRZd4Nsx/Ozr/kx16lT2NGp/EEmTV4Hn732xfLkUOlg+tFcS78i6lOPBknjDB/cz/J5Ha4wDQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -146,9 +146,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.283",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.283.tgz",
|
||||
"integrity": "sha512-T0T8mR7MSe7bVI96tmeK7DgUy2xhG8CZD1i0nCC2C05sSmkDDn/OYgNqpGPxJbo8Ic0Psxd7TOmngQYqPKWtpA==",
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.286.tgz",
|
||||
"integrity": "sha512-WeO/wG2uPh95BhOQi1IsIECdW5gJgC1Q9xtGw4RjV04it1fBMAbUe7aVwP4DQgH8PHnmduA7dRnLtbnZnrGsjg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -162,9 +162,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.283",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.283.tgz",
|
||||
"integrity": "sha512-hZjyeIgZpALMvYq2QfyPzl4AZzOVRUoU8NOB82Z3cVXLcEvQJXJN03Hp3XNsuBq5YMUoSUJAffMannhM7UqVmg==",
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.286.tgz",
|
||||
"integrity": "sha512-N4p7Gw5Qg3q9+Y5sEht1cIHHZkmBAUfWXJnqfmDD7N8twqF3XNVK3w65hDLqli4i0ttuZu79bGmRGkgNGm9Imw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -175,9 +175,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.283",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.283.tgz",
|
||||
"integrity": "sha512-h5eZxFxk6f1LPSuUOoBH9fslhL9ncywdZN5Qw9XmWuijHYTfevXuoza5nm6N98O+hmSykJ8NjC3/muhONDvzBg==",
|
||||
"version": "0.3.286",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.286.tgz",
|
||||
"integrity": "sha512-pg35GRPBKyviod0i8Z3EVMzDnTiiiucuWUbyH1bVIFFN0UWCQQ+PRUJ15qrPKjL6+vlFxOX2ei9FfsWYjGYZwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -188,9 +188,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.128.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.128.0.tgz",
|
||||
"integrity": "sha512-tl5cBFZC1jVFrTuQyvQObA4WmuNgcYVeXfriqOumgtLAHUm4gPUj18YnszBW60v0PrjDB8nBDOC6wd9CmHQFlA==",
|
||||
"version": "0.131.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz",
|
||||
"integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
@@ -370,9 +370,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
|
||||
"integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==",
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz",
|
||||
"integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -501,9 +501,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz",
|
||||
"integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==",
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz",
|
||||
"integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -542,9 +542,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.151.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
|
||||
"integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==",
|
||||
"version": "0.152.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
|
||||
"integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -553,9 +553,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz",
|
||||
"integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
|
||||
"integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -571,9 +571,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz",
|
||||
"integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -589,9 +589,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz",
|
||||
"integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -607,9 +607,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz",
|
||||
"integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -625,9 +625,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz",
|
||||
"integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -643,9 +643,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz",
|
||||
"integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
|
||||
"integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -661,9 +661,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz",
|
||||
"integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -682,9 +682,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz",
|
||||
"integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -703,9 +703,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz",
|
||||
"integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -724,9 +724,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz",
|
||||
"integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -745,9 +745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz",
|
||||
"integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -766,9 +766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz",
|
||||
"integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -787,9 +787,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz",
|
||||
"integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -805,9 +805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz",
|
||||
"integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -823,9 +823,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz",
|
||||
"integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -939,17 +939,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz",
|
||||
"integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz",
|
||||
"integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.70.1",
|
||||
"@typescript-eslint/type-utils": "8.70.1",
|
||||
"@typescript-eslint/utils": "8.70.1",
|
||||
"@typescript-eslint/visitor-keys": "8.70.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.0",
|
||||
"@typescript-eslint/type-utils": "8.71.0",
|
||||
"@typescript-eslint/utils": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -962,22 +962,22 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.70.1",
|
||||
"@typescript-eslint/parser": "^8.71.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.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz",
|
||||
"integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz",
|
||||
"integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.70.1",
|
||||
"@typescript-eslint/types": "8.70.1",
|
||||
"@typescript-eslint/typescript-estree": "8.70.1",
|
||||
"@typescript-eslint/visitor-keys": "8.70.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/typescript-estree": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -993,14 +993,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz",
|
||||
"integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz",
|
||||
"integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.70.1",
|
||||
"@typescript-eslint/types": "^8.70.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.71.0",
|
||||
"@typescript-eslint/types": "^8.71.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1015,14 +1015,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz",
|
||||
"integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz",
|
||||
"integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.70.1",
|
||||
"@typescript-eslint/visitor-keys": "8.70.1"
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1033,9 +1033,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz",
|
||||
"integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz",
|
||||
"integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1050,15 +1050,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz",
|
||||
"integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz",
|
||||
"integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.70.1",
|
||||
"@typescript-eslint/typescript-estree": "8.70.1",
|
||||
"@typescript-eslint/utils": "8.70.1",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/typescript-estree": "8.71.0",
|
||||
"@typescript-eslint/utils": "8.71.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -1075,9 +1075,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz",
|
||||
"integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz",
|
||||
"integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1089,16 +1089,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz",
|
||||
"integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz",
|
||||
"integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.70.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.70.1",
|
||||
"@typescript-eslint/types": "8.70.1",
|
||||
"@typescript-eslint/visitor-keys": "8.70.1",
|
||||
"@typescript-eslint/project-service": "8.71.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/visitor-keys": "8.71.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -1117,16 +1117,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz",
|
||||
"integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz",
|
||||
"integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.70.1",
|
||||
"@typescript-eslint/types": "8.70.1",
|
||||
"@typescript-eslint/typescript-estree": "8.70.1"
|
||||
"@typescript-eslint/scope-manager": "8.71.0",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"@typescript-eslint/typescript-estree": "8.71.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1141,13 +1141,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz",
|
||||
"integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==",
|
||||
"version": "8.71.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz",
|
||||
"integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.70.1",
|
||||
"@typescript-eslint/types": "8.71.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1172,14 +1172,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz",
|
||||
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz",
|
||||
"integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.2",
|
||||
"@vitest/spy": "5.0.3",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^1.2.3"
|
||||
},
|
||||
@@ -1211,9 +1211,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz",
|
||||
"integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz",
|
||||
"integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1438,9 +1438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2251,9 +2251,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
|
||||
"integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
|
||||
"version": "17.13.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
||||
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2316,9 +2316,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.10",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.10.tgz",
|
||||
"integrity": "sha512-dQuLsa5oO+47QVMVMaaD9cIv8ctmVtK1iRvwWngkfloFJMeFeuoUFDswIqZGxmGX3hrRzREgEArjkK9OgsQEhA==",
|
||||
"version": "4.13.12",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz",
|
||||
"integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -2371,9 +2371,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz",
|
||||
"integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==",
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.11.tgz",
|
||||
"integrity": "sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3324,14 +3324,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.11",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz",
|
||||
"integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
|
||||
"integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.151.0",
|
||||
"@oxc-project/types": "=0.152.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3341,21 +3341,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.11",
|
||||
"@rolldown/binding-android-arm64": "1.2.11",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.11",
|
||||
"@rolldown/binding-darwin-x64": "1.2.11",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.11",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.11",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.11",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.11",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.11",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.11",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.11",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.11",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.11",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.11",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.11"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.12",
|
||||
"@rolldown/binding-android-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-x64": "1.2.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -3547,9 +3547,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
@@ -3578,9 +3578,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
|
||||
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3809,9 +3809,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
|
||||
"integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==",
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
|
||||
"integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -3819,7 +3819,7 @@
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.28",
|
||||
"rolldown": "~1.2.9",
|
||||
"rolldown": "~1.2.11",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3888,14 +3888,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz",
|
||||
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz",
|
||||
"integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.2",
|
||||
"@vitest/mocker": "5.0.3",
|
||||
"chai": "^6.2.2",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
@@ -3906,7 +3906,7 @@
|
||||
"tinybench": "^6.1.4",
|
||||
"tinyexec": "^1.3.0",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"why-is-node-running": "^3.2.1"
|
||||
"why-is-node-running": "3.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
@@ -3921,12 +3921,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.2",
|
||||
"@vitest/browser-preview": "5.0.2",
|
||||
"@vitest/browser-playwright": "5.0.3",
|
||||
"@vitest/browser-preview": "5.0.3",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.2",
|
||||
"@vitest/coverage-v8": "5.0.2",
|
||||
"@vitest/ui": "5.0.2",
|
||||
"@vitest/coverage-istanbul": "5.0.3",
|
||||
"@vitest/coverage-v8": "5.0.3",
|
||||
"@vitest/ui": "5.0.3",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -3986,9 +3986,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz",
|
||||
"integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==",
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
|
||||
"integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
16
package.json
16
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.83.0",
|
||||
"version": "0.85.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -65,26 +65,26 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.5.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.283",
|
||||
"@agentclientprotocol/sdk": "1.7.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.286",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.128.0",
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.70.1",
|
||||
"@typescript-eslint/parser": "8.70.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.0",
|
||||
"@typescript-eslint/parser": "8.71.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.11.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.12.0",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.2"
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
563
src/acp-agent.ts
563
src/acp-agent.ts
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
agent as acpAgent,
|
||||
AgentApp,
|
||||
AgentContext,
|
||||
AuthenticateRequest,
|
||||
AuthMethod,
|
||||
@@ -52,9 +53,9 @@ import {
|
||||
DeleteSessionResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
StopReason,
|
||||
ToolCallContent,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import type { StopReason, TurnEvents, TurnOutcome } from "./turn-events.js";
|
||||
import {
|
||||
AccountInfo,
|
||||
CanUseTool,
|
||||
@@ -105,12 +106,14 @@ import {
|
||||
asSdkSessionNotification,
|
||||
clientSupportsSubagents,
|
||||
SubagentAwareSessionCapabilities,
|
||||
type SubagentState,
|
||||
} from "./acp-subagents.js";
|
||||
import {
|
||||
isNativeSubagentControlTool,
|
||||
isNativeSubagentControlUpdate,
|
||||
NativeSubagent,
|
||||
NativeSubagentRuntime,
|
||||
nativeSubagentState,
|
||||
resumedNativeSubagentId,
|
||||
sendMessageResumePrompt,
|
||||
} from "./native-subagents.js";
|
||||
@@ -271,9 +274,9 @@ import {
|
||||
import {
|
||||
buildEffortConfigOption,
|
||||
EFFORT_CONFIG_ID,
|
||||
effortFlagSettings,
|
||||
mergeEffortSettings,
|
||||
settingsEffortForModel,
|
||||
toSdkEffortLevel,
|
||||
} from "./session-effort.js";
|
||||
|
||||
export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
|
||||
@@ -581,15 +584,30 @@ function parseSteerRequest(params: unknown): SteerRequest {
|
||||
};
|
||||
}
|
||||
|
||||
/** One in-flight `prompt()` call. A persistent per-session consumer (see
|
||||
* `runConsumer`) drains the SDK query stream for the whole session and settles
|
||||
* each Turn's deferred when that turn's outcome is known, so `prompt()` itself
|
||||
* holds no loop. Turns are processed FIFO: the SDK echoes queued user messages
|
||||
* back in submission order, so `turnQueue[0]` is the turn currently running. */
|
||||
/** One in-flight turn, started by `startTurn()`. A persistent per-session
|
||||
* consumer (see `runConsumer`) drains the SDK query stream for the whole
|
||||
* session and settles each Turn when that turn's outcome is known, so
|
||||
* `startTurn()` itself holds no loop. Turns are processed FIFO: the SDK echoes
|
||||
* queued user messages back in submission order, so `turnQueue[0]` is the turn
|
||||
* currently running. */
|
||||
type Turn = {
|
||||
/** uuid stamped on the pushed `SDKUserMessage`; the SDK echoes it back so the
|
||||
* consumer can match the replayed user message to this turn. */
|
||||
* consumer can match the replayed user message to this turn. It is also the
|
||||
* id of the user message the turn reports as inserted. */
|
||||
promptUuid: string;
|
||||
/** Where the turn reports how it progresses (see {@link TurnEvents}). */
|
||||
events: TurnEvents;
|
||||
/** Set once `events.inserted` has been reported. A clear-context restart
|
||||
* activates the same turn again in the fresh session, which must not report
|
||||
* it twice. */
|
||||
insertedReported?: boolean;
|
||||
/** Whether the turn last reported that it awaits the user, so that
|
||||
* `syncAwaitingUser` reports only changes. */
|
||||
awaitingUser?: boolean;
|
||||
/** Tools surfaced during this turn, awaiting completion or an explicit
|
||||
* background handoff. Intersect with emittedToolCalls at settlement so
|
||||
* tool_result counts even when a presentation hook is still pending. */
|
||||
foregroundToolCallIds?: Set<string>;
|
||||
/** Local-only slash commands (e.g. `/clear`) return a result without an echo,
|
||||
* so the consumer can't promote them via the replay; it falls back to
|
||||
* promoting the queue head when the result arrives. */
|
||||
@@ -626,7 +644,7 @@ type Turn = {
|
||||
settling?: boolean;
|
||||
/** Outcome captured before the checkpoint preview await, so cancel() can
|
||||
* preserve its usage and metadata while atomically winning that race. */
|
||||
settlingOutcome?: PromptResponse;
|
||||
settlingOutcome?: TurnOutcome;
|
||||
/** Set when a `command_lifecycle` "started" frame arrives for this turn's
|
||||
* uuid (msg_lifecycle_v1 CLIs): the SDK dispatched the command into a turn.
|
||||
* Read by cancel() to seed the orphan's state — a started orphan's turn may
|
||||
@@ -703,13 +721,17 @@ type Turn = {
|
||||
* streams — degrading to post-turn delivery for it, never worse than the
|
||||
* pre-hold behavior (pending wakes are not countable: notifications can
|
||||
* batch into one followup). */
|
||||
deferredSettle?: PromptResponse;
|
||||
deferredSettle?: TurnOutcome;
|
||||
/** Uuids of `steer()`-injected messages the SDK has not replayed back yet.
|
||||
*
|
||||
* A steer is normally delivered at priority `now`, so the CLI ABORTS
|
||||
* the running cycle: it emits its own human-origin `result` —
|
||||
* indistinguishable from a turn's terminal one — and the steered message runs
|
||||
* as a SECOND cycle. Settling at that result would answer `session/prompt`
|
||||
* A steer is normally delivered at priority `now`. When it lands during
|
||||
* generation the CLI ABORTS the running cycle: it emits its own human-origin
|
||||
* `result` — indistinguishable from a turn's terminal one — and the steered
|
||||
* message runs as a SECOND cycle. When it lands during a foreground tool call
|
||||
* (CLI 2.1.286) the CLI moves the tool to the background instead and either
|
||||
* joins the steer into the running cycle (ONE result stamped with both
|
||||
* uuids) or still ends that cycle early and runs the steer as a second one.
|
||||
* Settling at an interrupted cycle's result would answer `session/prompt`
|
||||
* mid-work, so a steered turn's results only RECORD their outcome
|
||||
* (`steeredSettle`) until one provably answers the steer.
|
||||
*
|
||||
@@ -742,14 +764,14 @@ type Turn = {
|
||||
steeredAwaitingResult?: boolean;
|
||||
/** What a steered turn settles with once its steered work has run: the outcome
|
||||
* of its latest result, so its usage covers every cycle the turn ran. */
|
||||
steeredSettle?: PromptResponse;
|
||||
steeredSettle?: TurnOutcome;
|
||||
carriedUsage?: AccumulatedUsage;
|
||||
/** `carriedUsage`'s per-model counterpart, so a turn that survives a
|
||||
* clear-context restart keeps the `_meta.quota` rows it earned pre-restart. */
|
||||
carriedModelUsage?: ModelTokenTally;
|
||||
resolve: (response: PromptResponse) => void;
|
||||
resolve: (outcome: TurnOutcome) => void;
|
||||
reject: (error: unknown) => void;
|
||||
/** Settles after the ACP prompt request completes, regardless of outcome. */
|
||||
/** Settles once the turn has ended or failed. */
|
||||
completion?: Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1198,9 +1220,7 @@ const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet<SDKMessageOrigin["kind"]> = new Set
|
||||
* held for background subagents it spawned (see Turn.deferredSettle). The
|
||||
* single spelling of the hold predicate, shared by the consumer's settle
|
||||
* lanes and cancel(). */
|
||||
function isHeldOpen(
|
||||
turn: Turn | null | undefined,
|
||||
): turn is Turn & { deferredSettle: PromptResponse } {
|
||||
function isHeldOpen(turn: Turn | null | undefined): turn is Turn & { deferredSettle: TurnOutcome } {
|
||||
return turn != null && turn.deferredSettle !== undefined && !turn.settled;
|
||||
}
|
||||
|
||||
@@ -1215,7 +1235,7 @@ function isSteering(turn: Turn | null | undefined): turn is Turn & { steeredEcho
|
||||
* (see Turn.steeredAwaitingResult), so the next idle can settle it. */
|
||||
function isSteeredSettleReady(
|
||||
turn: Turn | null | undefined,
|
||||
): turn is Turn & { steeredEchoes: Set<string>; steeredSettle: PromptResponse } {
|
||||
): turn is Turn & { steeredEchoes: Set<string>; steeredSettle: TurnOutcome } {
|
||||
return (
|
||||
isSteering(turn) &&
|
||||
turn.steeredEchoes.size === 0 &&
|
||||
@@ -1801,8 +1821,12 @@ const LOCAL_COMMAND_MARKERS = [
|
||||
// whatever the user typed. Nobody wrote it and no client sees it live — the
|
||||
// prompt loop's user-message skip covers the whole message — but it is
|
||||
// persisted alongside that prose, so replay would hand the client a prompt
|
||||
// with instructions in it the user never gave.
|
||||
const INJECTED_CONTEXT_MARKERS = ["system-reminder"].map((tag) => ({
|
||||
// with instructions in it the user never gave. A `task-notification` tells the
|
||||
// model that a background task stopped; live, the SDK `task_notification`
|
||||
// frame reports the same stop, so replay restores the task state from it (see
|
||||
// taskNotificationsOf) instead of showing it.
|
||||
const TASK_NOTIFICATION_TAG = "task-notification";
|
||||
const INJECTED_CONTEXT_MARKERS = ["system-reminder", TASK_NOTIFICATION_TAG].map((tag) => ({
|
||||
open: `<${tag}>`,
|
||||
close: `</${tag}>`,
|
||||
}));
|
||||
@@ -1910,6 +1934,69 @@ export function isLocalCommandMetadata(content: unknown): boolean {
|
||||
return stripLocalCommandMetadata(content) === null;
|
||||
}
|
||||
|
||||
/** The fields of a persisted `<task-notification>`, in the shape of the SDK
|
||||
* `task_notification` frame that reported the same stop live. */
|
||||
type PersistedTaskNotification = {
|
||||
task_id: string;
|
||||
status: string;
|
||||
tool_use_id?: string;
|
||||
summary?: string;
|
||||
output_file?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns each `<task-notification>` in user-message content (a string or its
|
||||
* text blocks). The `<result>` body is model context that the live frame does
|
||||
* not carry, so the scan stops at it and a tag quoted in it cannot match.
|
||||
*/
|
||||
function taskNotificationsOf(content: unknown): PersistedTaskNotification[] {
|
||||
const texts =
|
||||
typeof content === "string"
|
||||
? [content]
|
||||
: Array.isArray(content)
|
||||
? content.flatMap((block) =>
|
||||
block?.type === "text" && typeof block.text === "string" ? [block.text as string] : [],
|
||||
)
|
||||
: [];
|
||||
const open = `<${TASK_NOTIFICATION_TAG}>`;
|
||||
const close = `</${TASK_NOTIFICATION_TAG}>`;
|
||||
const notifications: PersistedTaskNotification[] = [];
|
||||
for (const text of texts) {
|
||||
let start = text.indexOf(open);
|
||||
while (start !== -1) {
|
||||
const end = text.indexOf(close, start + open.length);
|
||||
if (end === -1) break;
|
||||
const body = text.slice(start + open.length, end).split("<result>", 1)[0];
|
||||
const field = (tag: string) => markerTagText(body, tag)?.trim() || undefined;
|
||||
const taskId = field("task-id");
|
||||
const status = field("status");
|
||||
if (taskId && status) {
|
||||
notifications.push({
|
||||
task_id: taskId,
|
||||
status,
|
||||
tool_use_id: field("tool-use-id"),
|
||||
summary: field("summary"),
|
||||
output_file: field("output-file"),
|
||||
});
|
||||
}
|
||||
start = text.indexOf(open, end + close.length);
|
||||
}
|
||||
}
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a user record that the CLI wrote to tell the model that a
|
||||
* background task stopped. A `subkind` marks the delivery of a prompt, for
|
||||
* example a scheduled routine or a message from another session. Such a
|
||||
* delivery stays in the transcript like any other prompt.
|
||||
*/
|
||||
function isTaskNotificationRecord(message: unknown): boolean {
|
||||
const origin = (message as { origin?: unknown }).origin as
|
||||
{ kind?: unknown; subkind?: unknown } | null | undefined;
|
||||
return origin?.kind === "task-notification" && origin.subkind === undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the synthetic assistant message the CLI injects into the transcript
|
||||
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
|
||||
@@ -1943,6 +2030,41 @@ export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Origin kinds of the meta user messages `getSessionMessages` returns since
|
||||
* SDK 0.3.284 (messages from other agents, sessions and channels). */
|
||||
const REPLAY_HIDDEN_META_ORIGIN_KINDS = new Set([
|
||||
"peer",
|
||||
"channel",
|
||||
"observer",
|
||||
"observer-activity",
|
||||
"slack-ping",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True for a transcript message delivered to the model from another agent,
|
||||
* session or channel. Since SDK 0.3.284 `getSessionMessages` returns these as
|
||||
* `is_meta` user messages whose content is the full harness framing (envelope
|
||||
* XML plus the "not typed by your user" preamble). The live prompt loop never
|
||||
* renders them, so replay skips them too rather than presenting them as text
|
||||
* the user typed. Other `is_meta` messages (compact summaries) are kept.
|
||||
*
|
||||
* `is_meta` and `origin` are runtime fields not declared on `SessionMessage`.
|
||||
*/
|
||||
export function isReplayHiddenMetaMessage(message: unknown): boolean {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const { type, is_meta, origin } = message as {
|
||||
type?: unknown;
|
||||
is_meta?: unknown;
|
||||
origin?: { kind?: unknown } | null;
|
||||
};
|
||||
return (
|
||||
type === "user" &&
|
||||
is_meta === true &&
|
||||
typeof origin?.kind === "string" &&
|
||||
REPLAY_HIDDEN_META_ORIGIN_KINDS.has(origin.kind)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-facing surface the agent calls back into. This is the subset of ACP
|
||||
* client methods the agent actually uses, expressed as a narrow interface so
|
||||
@@ -3021,7 +3143,32 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an ACP v1 `session/prompt`, which answers when the turn ends. The
|
||||
* outcome of a turn is field for field a v1 prompt response.
|
||||
*/
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
let events!: TurnEvents;
|
||||
const outcome = new Promise<TurnOutcome>((resolve, reject) => {
|
||||
events = {
|
||||
inserted() {},
|
||||
awaitingUser() {},
|
||||
resumed() {},
|
||||
ended: resolve,
|
||||
failed: reject,
|
||||
};
|
||||
});
|
||||
await this.startTurn(params, events);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a turn for a prompt. Resolves once the prompt is queued for Claude
|
||||
* Code, and rejects only when the prompt is refused before that. From then
|
||||
* on, `events` reports how the turn goes (see {@link TurnEvents}), possibly
|
||||
* before this resolves.
|
||||
*/
|
||||
async startTurn(params: PromptRequest, events: TurnEvents): Promise<void> {
|
||||
if (this.providerUpdate) await this.providerUpdate;
|
||||
let session = this.sessions[params.sessionId];
|
||||
if (!session) {
|
||||
@@ -3076,41 +3223,41 @@ export class ClaudeAcpAgent {
|
||||
|
||||
session.titles.onPrompt(params.prompt);
|
||||
|
||||
// Each prompt is a Turn whose deferred the persistent consumer settles once
|
||||
// the turn's outcome is known. `prompt()` owns no loop: it enqueues the
|
||||
// turn, pushes the user message onto the streaming input, makes sure the
|
||||
// consumer is running, and awaits the deferred.
|
||||
// Each prompt is a Turn that the persistent consumer settles once the
|
||||
// turn's outcome is known. `startTurn()` owns no loop: it enqueues the
|
||||
// turn, pushes the user message onto the streaming input, and makes sure
|
||||
// the consumer is running.
|
||||
let completeTurn!: () => void;
|
||||
const turn: Turn = {
|
||||
promptUuid,
|
||||
events,
|
||||
isLocalOnlyCommand,
|
||||
...(isUsageCommand ? { isUsageCommand: true } : {}),
|
||||
...(usageMarkdownAbort ? { usageMarkdownAbort } : {}),
|
||||
...(fileChangeReport ? { fileChangeReport } : {}),
|
||||
settled: false,
|
||||
resolve: () => {},
|
||||
reject: () => {},
|
||||
completion: new Promise<void>((resolve) => {
|
||||
completeTurn = resolve;
|
||||
}),
|
||||
resolve: (outcome) => {
|
||||
events.ended(outcome);
|
||||
completeTurn();
|
||||
},
|
||||
reject: (error) => {
|
||||
events.failed(error);
|
||||
completeTurn();
|
||||
},
|
||||
};
|
||||
let completeTurn!: () => void;
|
||||
turn.completion = new Promise<void>((resolve) => {
|
||||
completeTurn = resolve;
|
||||
});
|
||||
const response = new Promise<PromptResponse>((resolve, reject) => {
|
||||
turn.resolve = (result) => {
|
||||
resolve(result);
|
||||
completeTurn();
|
||||
};
|
||||
turn.reject = (error) => {
|
||||
reject(error);
|
||||
completeTurn();
|
||||
};
|
||||
});
|
||||
|
||||
session.turnQueue ??= [];
|
||||
session.turnQueue.push(turn);
|
||||
session.input.push(userMessage);
|
||||
this.ensureConsumer(session, params.sessionId);
|
||||
await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid);
|
||||
return response;
|
||||
// The prompt is queued, so its turn goes ahead even if the client misses
|
||||
// the goal it sets; the turn's events report the outcome.
|
||||
await this.publishGoalFromPrompt(params.sessionId, firstText, promptUuid).catch((error) =>
|
||||
this.logger.error(`Session ${params.sessionId}: failed to publish the prompt's goal:`, error),
|
||||
);
|
||||
}
|
||||
|
||||
/** `--hide-claude-auth` applies only to the CLI's own login. A provider
|
||||
@@ -3379,9 +3526,12 @@ export class ClaudeAcpAgent {
|
||||
* prompt (IJAI-1191). The steered message's own output streams via
|
||||
* `session/update`, not this response.
|
||||
*
|
||||
* Pre-empting means ABORTING: the interrupted cycle emits a `result` of its
|
||||
* own and the steered message runs as a second one, so the turn is marked
|
||||
* (`Turn.steeredEchoes`) to settle only once a result answers the steer.
|
||||
* Pre-empting generation means ABORTING: the interrupted cycle emits a
|
||||
* `result` of its own and the steered message runs as a second one. A steer
|
||||
* during a foreground tool call moves that tool (Bash, Agent, MCP) to the
|
||||
* background instead, and may join the running cycle (CLI 2.1.286). Either
|
||||
* way the turn is marked (`Turn.steeredEchoes`) to settle only once a result
|
||||
* answers the steer.
|
||||
*
|
||||
* When the session is idle, the opt-in path returns `promptRequired` WITHOUT
|
||||
* calling `prompt()`, pushing SDK input, or mutating `turnQueue`: the content
|
||||
@@ -3639,6 +3789,26 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
toolCallId &&
|
||||
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update")
|
||||
) {
|
||||
if (
|
||||
claudeMeta?.parentToolUseId ||
|
||||
routedNotification.sessionId !== params.sessionId ||
|
||||
update.status === "completed" ||
|
||||
update.status === "failed"
|
||||
) {
|
||||
// A later stream frame can attribute an eager permission call to a
|
||||
// child. It must no longer count as this turn's foreground work.
|
||||
forgetForegroundToolCall(session, toolCallId);
|
||||
} else if (
|
||||
update.sessionUpdate === "tool_call" &&
|
||||
session.emittedToolCalls.has(toolCallId)
|
||||
) {
|
||||
recordForegroundToolCall(session, toolCallId);
|
||||
}
|
||||
}
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
if (
|
||||
!claudeMeta?.parentToolUseId &&
|
||||
@@ -3857,6 +4027,17 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
resetTurnScratch();
|
||||
// Activation is when Claude Code takes the prompt in: its echo, or for an
|
||||
// echo-less command, its result. That inserts it into the conversation.
|
||||
// A turn can be settled by now: an echo hand-off awaits settling the
|
||||
// previous turn, and a cancel in that window settles the queued one.
|
||||
if (!turn.insertedReported && !turn.settled) {
|
||||
turn.insertedReported = true;
|
||||
turn.events.inserted(turn.promptUuid);
|
||||
}
|
||||
// A request can already be open: Claude Code asks for a queued prompt
|
||||
// before the consumer reaches its echo.
|
||||
this.syncAwaitingUser(session);
|
||||
};
|
||||
|
||||
/** Ensure there is an active turn before a user-turn result that carries no
|
||||
@@ -4053,6 +4234,7 @@ export class ClaudeAcpAgent {
|
||||
isSubagent: boolean,
|
||||
) => {
|
||||
session.liveBackgroundTasks.set(taskId, { parentToolUseId, isSubagent });
|
||||
if (parentToolUseId) forgetForegroundToolCall(session, parentToolUseId);
|
||||
session.resumableSubagents?.delete(taskId);
|
||||
if (isSubagent && session.activeTurn && !session.activeTurn.settled) {
|
||||
(session.activeTurn.spawnedTaskIds ??= new Set()).add(taskId);
|
||||
@@ -4110,7 +4292,7 @@ export class ClaudeAcpAgent {
|
||||
* turn that can have spawned subagents must route through here: a site
|
||||
* calling settleActive directly bypasses the hold and re-opens the
|
||||
* out-of-turn permission deadlock (issue #866) through its lane. */
|
||||
const settleOrDefer = async (outcome: PromptResponse) => {
|
||||
const settleOrDefer = async (outcome: TurnOutcome) => {
|
||||
// No result ends a steered turn: the steer aborted the cycle this result
|
||||
// may belong to, and the steered one is still to come. Record the outcome
|
||||
// for the idle lane (see Turn.steeredEchoes); later cycles overwrite it,
|
||||
@@ -4135,7 +4317,7 @@ export class ClaudeAcpAgent {
|
||||
* turn exactly once, disarm the force-cancel backstop, and drop it from
|
||||
* the queue. Cancellation and provider failures skip checkpoint I/O. */
|
||||
const settleActive = async (
|
||||
result: PromptResponse,
|
||||
result: TurnOutcome,
|
||||
reportReason: FileChangeReportUnavailableReason = result.stopReason === "cancelled"
|
||||
? "cancelled"
|
||||
: "notReported",
|
||||
@@ -4144,6 +4326,62 @@ export class ClaudeAcpAgent {
|
||||
if (!turn || turn.settled || turn.settling) {
|
||||
return;
|
||||
}
|
||||
// Both a result and EOF can end the turn here. Preserve cancellation
|
||||
// and existing errors instead of replacing them with this check.
|
||||
if (result.stopReason === "end_turn" && reportReason === "notReported") {
|
||||
// A confirmed background task is allowed to outlive the turn.
|
||||
const backgroundTools = new Set(
|
||||
[...session.liveBackgroundTasks.values()].map((task) => task.parentToolUseId),
|
||||
);
|
||||
// Check only this turn's tools. A tool_result removes its id from
|
||||
// emittedToolCalls even when its PostToolUse hook has not arrived yet.
|
||||
const unfinished = [...(turn.foregroundToolCallIds ?? [])].filter(
|
||||
(id) => session.emittedToolCalls.has(id) && !backgroundTools.has(id),
|
||||
);
|
||||
if (unfinished.length > 0) {
|
||||
const message = `Claude ended the turn without returning results for tool calls: ${unfinished.join(", ")}`;
|
||||
this.logger.error(
|
||||
`Session ${params.sessionId}, turn ${turn.promptUuid}, stopReason=${result.stopReason}: ${message}`,
|
||||
);
|
||||
// Fail every unfinished tool before reporting one error for the prompt.
|
||||
for (const toolCallId of unfinished) {
|
||||
// A late hook must not overwrite the failure we are about to send.
|
||||
unregisterHookCallback(toolCallId);
|
||||
session.emittedToolCalls.delete(toolCallId);
|
||||
delete session.toolUseCache[toolCallId];
|
||||
session.toolCallFields?.delete(toolCallId);
|
||||
await sendUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "failed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "Claude ended the turn without returning a result for this tool.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
// Cancellation can arrive while we await the client update.
|
||||
if (turn.settled || session.activeTurn !== turn) return;
|
||||
if (session.cancelled) {
|
||||
await settleActive({ ...result, stopReason: "cancelled" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await failActiveWithSessionFailure(
|
||||
"internal_error",
|
||||
RequestError.internalError(errorKindData("incomplete_tool_call"), message),
|
||||
message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
turn.settling = true;
|
||||
turn.settlingOutcome = result;
|
||||
if (reportReason === "notReported") {
|
||||
@@ -6637,6 +6875,18 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
await this.cancelTurns(params, { awaitInterrupt: true });
|
||||
}
|
||||
|
||||
/** Cancel the session's turns and interrupt the SDK query. With
|
||||
* `awaitInterrupt: false` the interrupt is sent, but its reply is not
|
||||
* awaited. `teardownSession` uses that: it closes the query right after, and
|
||||
* the reply can queue behind a slow control request, such as the background
|
||||
* `getContextUsage` of a fresh session. */
|
||||
private async cancelTurns(
|
||||
params: CancelNotification,
|
||||
options: { awaitInterrupt: boolean },
|
||||
): Promise<void> {
|
||||
this.exitPlan.cancel(params.sessionId);
|
||||
const session = this.sessions[params.sessionId];
|
||||
if (!session) {
|
||||
@@ -6871,7 +7121,14 @@ export class ClaudeAcpAgent {
|
||||
}, this.forceCancelGraceMs);
|
||||
}
|
||||
|
||||
const receipt = await session.query.interrupt();
|
||||
const interrupt = session.query.interrupt();
|
||||
if (!options.awaitInterrupt) {
|
||||
// The caller closes the query next, which rejects the pending reply.
|
||||
// The receipt only adjusts orphan accounting of a live session.
|
||||
Promise.resolve(interrupt).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const receipt = await interrupt;
|
||||
// On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
|
||||
// lists exactly which queued messages survive the interrupt and will still
|
||||
// run. An orphaned turn whose uuid is absent was dropped by the interrupt
|
||||
@@ -6977,7 +7234,7 @@ export class ClaudeAcpAgent {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.cancel({ sessionId });
|
||||
await this.cancelTurns({ sessionId }, { awaitInterrupt: false });
|
||||
} catch (error) {
|
||||
this.logger.error(`Session ${sessionId}: cancellation failed during teardown`, error);
|
||||
}
|
||||
@@ -7171,6 +7428,9 @@ export class ClaudeAcpAgent {
|
||||
reconstructable: boolean;
|
||||
announced: boolean;
|
||||
terminalState?: "completed" | "failed" | "cancelled";
|
||||
/** The state of the last persisted task notification of the child.
|
||||
* It comes after the launch tool_result, so it wins. */
|
||||
notifiedState?: SubagentState;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -7291,7 +7551,59 @@ export class ClaudeAcpAgent {
|
||||
? await subagentHistory(sessionId)
|
||||
: { ids: new Map<string, string>() };
|
||||
const replayedSubagents = new Set<string>();
|
||||
|
||||
// History persists no task_started frame, so a background task gets its
|
||||
// spawn from the notification of its stop, with the tool call that
|
||||
// started it. A task still running at the end of the history gets no
|
||||
// card, because the process that ran it is gone.
|
||||
// The tool cache forgets a tool use at its result, but a notification
|
||||
// comes later and needs the tool use that started the task.
|
||||
const replayToolUses = new Map<string, { name: string; input: unknown; sessionId: string }>();
|
||||
const replayAsyncTasks = new AsyncTaskRuntime(
|
||||
clientSupportsAsyncTasks(this.clientCapabilities),
|
||||
sessionId,
|
||||
async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
{
|
||||
routeOf: (toolCallId) => {
|
||||
const target = replayToolUses.get(toolCallId)?.sessionId;
|
||||
return target && target !== sessionId
|
||||
? (notification) => ({ ...notification, sessionId: target })
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
// The replay counterpart of the live `task_notification` frame.
|
||||
const restoreTaskNotification = async (
|
||||
notification: PersistedTaskNotification,
|
||||
): Promise<void> => {
|
||||
const toolUseId = notification.tool_use_id;
|
||||
if (!toolUseId) return;
|
||||
const child = replayChildren.get(toolUseId);
|
||||
const toolUse = replayToolUses.get(toolUseId);
|
||||
if (child || (toolUse && isNativeSubagentControlTool(toolUse.name))) {
|
||||
const state = nativeSubagentState(notification.status);
|
||||
if (child && state) child.notifiedState = state;
|
||||
return;
|
||||
}
|
||||
// Like live, a task that no tool call started stays unknown.
|
||||
if (!toolUse) return;
|
||||
const input = toolUse.input as { command?: unknown } | null | undefined;
|
||||
await replayAsyncTasks.taskBackgrounded({
|
||||
task_id: notification.task_id,
|
||||
...(toolUse.name === "Bash"
|
||||
? { task_type: "local_bash", description: input?.command }
|
||||
: {}),
|
||||
is_backgrounded: true,
|
||||
output_file: notification.output_file,
|
||||
tool_use_id: toolUseId,
|
||||
});
|
||||
await replayAsyncTasks.taskNotification(notification);
|
||||
};
|
||||
|
||||
const replayMessage = async (message: SessionMessage): Promise<void> => {
|
||||
if (isReplayHiddenMetaMessage(message)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
message.type === "user" &&
|
||||
message.parent_tool_use_id === null &&
|
||||
@@ -7357,8 +7669,25 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
if (message.message.role === "user") {
|
||||
content = stripLocalCommandMetadata(content);
|
||||
for (const notification of taskNotificationsOf(content)) {
|
||||
await restoreTaskNotification(notification);
|
||||
}
|
||||
// Live, the prompt loop skips this record and the SDK frame reports the stop.
|
||||
content = isTaskNotificationRecord(message) ? null : stripLocalCommandMetadata(content);
|
||||
if (content === null) return;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (
|
||||
["tool_use", "server_tool_use", "mcp_tool_use"].includes(block?.type) &&
|
||||
typeof block.id === "string"
|
||||
) {
|
||||
replayToolUses.set(block.id, {
|
||||
name: String(block.name),
|
||||
input: block.input,
|
||||
sessionId: replayTargetSessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Claude persists the retained summary as a user message framed with
|
||||
@@ -7473,7 +7802,7 @@ export class ClaudeAcpAgent {
|
||||
sessionUpdate: "subagent_state_update",
|
||||
subagentSessionId: child.sessionId,
|
||||
state: child.reconstructable
|
||||
? (child.terminalState ?? "disconnected")
|
||||
? (child.notifiedState ?? child.terminalState ?? "disconnected")
|
||||
: "disconnected",
|
||||
},
|
||||
}),
|
||||
@@ -7499,18 +7828,37 @@ export class ClaudeAcpAgent {
|
||||
/** Mark a client request as blocking on user input for exactly the lifetime
|
||||
* of its promise. Steering consults this session-local count synchronously,
|
||||
* so a message arriving while any permission/elicitation card is open uses
|
||||
* non-interrupting SDK delivery. */
|
||||
* non-interrupting SDK delivery. The active turn reports that it awaits the
|
||||
* user while any such request is open (see `syncAwaitingUser`). */
|
||||
private async withPendingUserInput<T>(sessionId: string, request: () => Promise<T>): Promise<T> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return request();
|
||||
session.pendingUserInputCount = (session.pendingUserInputCount ?? 0) + 1;
|
||||
this.syncAwaitingUser(session);
|
||||
try {
|
||||
return await request();
|
||||
} finally {
|
||||
session.pendingUserInputCount = Math.max(0, (session.pendingUserInputCount ?? 1) - 1);
|
||||
this.syncAwaitingUser(session);
|
||||
}
|
||||
}
|
||||
|
||||
/** Report whether the session's active turn awaits the user: exactly while
|
||||
* a permission request or question is open in the session. A request does
|
||||
* not say which prompt it is for, and Claude Code can ask for a queued
|
||||
* prompt before the consumer activates it, so a request is not tied to the
|
||||
* turn that was active when it opened. Called when the count changes and
|
||||
* when a turn activates. */
|
||||
private syncAwaitingUser(session: Session): void {
|
||||
const turn = session.activeTurn;
|
||||
if (!turn || turn.settled || !turn.insertedReported) return;
|
||||
const waiting = (session.pendingUserInputCount ?? 0) > 0;
|
||||
if (waiting === (turn.awaitingUser ?? false)) return;
|
||||
turn.awaitingUser = waiting;
|
||||
if (waiting) turn.events.awaitingUser();
|
||||
else turn.events.resumed();
|
||||
}
|
||||
|
||||
/** Forward a permission request to the client, wiring the tool call's
|
||||
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
||||
* while the client's prompt is still open the signal aborts, the SDK sends
|
||||
@@ -7598,6 +7946,15 @@ export class ClaudeAcpAgent {
|
||||
return;
|
||||
}
|
||||
session.emittedToolCalls.add(toolCallId);
|
||||
// SandboxNetworkAccess is a permission-only callback, not an executing
|
||||
// tool: the SDK does not send a tool_result for its synthetic id.
|
||||
if (
|
||||
!parentToolUseId &&
|
||||
notificationSessionId === sessionId &&
|
||||
toolName !== "SandboxNetworkAccess"
|
||||
) {
|
||||
recordForegroundToolCall(session, toolCallId);
|
||||
}
|
||||
(session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
|
||||
const update = new AcpToolCallRenderer(this.toolCallCapabilities).toolCall(
|
||||
{ id: toolCallId, name: toolName, input: toolInput },
|
||||
@@ -8144,14 +8501,20 @@ export class ClaudeAcpAgent {
|
||||
const newEffort =
|
||||
typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
||||
try {
|
||||
await session.query.applyFlagSettings({
|
||||
await session.query.applyFlagSettings(
|
||||
// A legacy client's unpinned effort is display-only: the CLI
|
||||
// resolves the persisted value for the new model. When an old
|
||||
// user pin is no longer supported, clear the flag layer instead
|
||||
// of replacing it with that displayed value. Opted-in clients
|
||||
// deliberately apply their concrete displayed effort.
|
||||
effortLevel: useRecommendedValue ? toSdkEffortLevel(newEffort) : null,
|
||||
});
|
||||
effortFlagSettings(
|
||||
useRecommendedValue ? newEffort : undefined,
|
||||
mergeEffortSettings(
|
||||
session.settingsManager.getSettings(),
|
||||
session.effortSettingsOverride,
|
||||
),
|
||||
),
|
||||
);
|
||||
session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined;
|
||||
session.appliedEffortLevel =
|
||||
useRecommendedValue && newEffort !== "default" ? newEffort : undefined;
|
||||
@@ -8205,9 +8568,15 @@ export class ClaudeAcpAgent {
|
||||
} else if (configId === EFFORT_CONFIG_ID) {
|
||||
// Apply first so a rejected control request cannot leave the displayed
|
||||
// value ahead of the SDK flag layer.
|
||||
await session.query.applyFlagSettings({
|
||||
effortLevel: toSdkEffortLevel(value),
|
||||
});
|
||||
await session.query.applyFlagSettings(
|
||||
effortFlagSettings(
|
||||
value,
|
||||
mergeEffortSettings(
|
||||
session.settingsManager.getSettings(),
|
||||
session.effortSettingsOverride,
|
||||
),
|
||||
),
|
||||
);
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
@@ -9023,15 +9392,15 @@ export class ClaudeAcpAgent {
|
||||
// and persisted settings; automatic values re-seed on every model switch.
|
||||
// Legacy clients still leave effort resolution entirely to the CLI.
|
||||
const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities);
|
||||
const effortSettings = mergeEffortSettings(
|
||||
settingsManager.getSettings(),
|
||||
configuredSettingsObject,
|
||||
);
|
||||
const configOptions = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
modelInfos,
|
||||
userProvidedOptions?.effort ??
|
||||
settingsEffortForModel(
|
||||
mergeEffortSettings(settingsManager.getSettings(), configuredSettingsObject),
|
||||
currentModelInfo,
|
||||
),
|
||||
userProvidedOptions?.effort ?? settingsEffortForModel(effortSettings, currentModelInfo),
|
||||
fastMode,
|
||||
{
|
||||
useRecommendedValue,
|
||||
@@ -9039,7 +9408,7 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
|
||||
if (useRecommendedValue && typeof initialEffort?.currentValue === "string") {
|
||||
await q.applyFlagSettings({ effortLevel: toSdkEffortLevel(initialEffort.currentValue) });
|
||||
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, effortSettings));
|
||||
}
|
||||
// Seed the context window without awaited IPC. The cached authoritative
|
||||
// window from a prior turn wins (`result.modelUsage`, cross-session),
|
||||
@@ -9273,7 +9642,7 @@ function turnOutcome(
|
||||
session: Session,
|
||||
stopReason: StopReason,
|
||||
extraMeta?: Record<string, unknown>,
|
||||
): PromptResponse {
|
||||
): TurnOutcome {
|
||||
return {
|
||||
stopReason,
|
||||
usage: sessionUsage(session),
|
||||
@@ -9438,7 +9807,7 @@ function totalTokens(usage: UsageSnapshot): number {
|
||||
/** Error kinds this adapter invents itself, alongside the SDK's categorical
|
||||
* `SDKAssistantMessageError` kinds: `no_result` marks a turn the SDK declared
|
||||
* over without ever emitting its result (issue #825). */
|
||||
type AgentErrorKind = SDKAssistantMessageError | "no_result";
|
||||
type AgentErrorKind = SDKAssistantMessageError | "no_result" | "incomplete_tool_call";
|
||||
|
||||
/**
|
||||
* Build the `data` payload attached to a `RequestError.internalError` when we
|
||||
@@ -9974,6 +10343,17 @@ function shouldEmitToolCall(toolName: string): boolean {
|
||||
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
||||
}
|
||||
|
||||
/** Streamed and permission-surfaced tools can precede the SDK's user echo. */
|
||||
function recordForegroundToolCall(session: Session, toolCallId: string): void {
|
||||
const turn = session.activeTurn ?? session.turnQueue?.find((queued) => !queued.settled);
|
||||
if (turn && !turn.settled) (turn.foregroundToolCallIds ??= new Set()).add(toolCallId);
|
||||
}
|
||||
|
||||
function forgetForegroundToolCall(session: Session, toolCallId: string): void {
|
||||
session.activeTurn?.foregroundToolCallIds?.delete(toolCallId);
|
||||
for (const turn of session.turnQueue ?? []) turn.foregroundToolCallIds?.delete(toolCallId);
|
||||
}
|
||||
|
||||
/** The tool-call field tracker of a session, created on first use. */
|
||||
function toolCallFieldsOf(session: {
|
||||
toolCallFields?: ToolCallFieldTracker;
|
||||
@@ -10559,21 +10939,25 @@ export async function runPromptWithCancellation(
|
||||
}
|
||||
}
|
||||
|
||||
export function runAcp(logger?: Logger) {
|
||||
const input = nodeToWebWritable(process.stdout);
|
||||
const output = nodeToWebReadable(process.stdin);
|
||||
|
||||
const stream = ndJsonStream(input, output);
|
||||
|
||||
// `connect(...)` returns a connection-scoped peer handle (`connection.client`)
|
||||
// that stays valid for the whole connection, so the agent captures it once.
|
||||
// Handlers close over `agent`, which is assigned synchronously right after
|
||||
// `connect()` returns — before the connection processes any inbound message.
|
||||
// It cannot be `const`: its value depends on `connection.client`, which does
|
||||
// not exist until `connect()` has been called.
|
||||
// eslint-disable-next-line prefer-const
|
||||
let agent: ClaudeAcpAgent;
|
||||
const connection = acpAgent({ name: "claude-code-acp" })
|
||||
/**
|
||||
* The ACP v1 surface of the adapter, for one connection.
|
||||
*
|
||||
* The agent of the connection is created when the connection opens, from the
|
||||
* connection-scoped peer handle (`connection.client`), which stays valid for
|
||||
* the whole connection. Connect handlers run before the connection processes
|
||||
* any inbound message, so every handler below sees the agent. `onAgent`
|
||||
* receives it for the owner of the process (shutdown).
|
||||
*/
|
||||
export function v1AgentApp(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): AgentApp {
|
||||
let agent!: ClaudeAcpAgent;
|
||||
return acpAgent({ name: "claude-code-acp" })
|
||||
.onConnect((connection) => {
|
||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
||||
onAgent(agent);
|
||||
})
|
||||
.onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
|
||||
.onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
|
||||
.onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params))
|
||||
@@ -10607,10 +10991,15 @@ export function runAcp(logger?: Logger) {
|
||||
GOAL_CONTROL_METHOD,
|
||||
{ parse: parseGoalRequest },
|
||||
(ctx) => agent.goal(ctx.params),
|
||||
)
|
||||
.connect(stream);
|
||||
);
|
||||
}
|
||||
|
||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
||||
/** Serves ACP v1 on stdio. */
|
||||
export function runAcp(logger?: Logger) {
|
||||
const stream = ndJsonStream(nodeToWebWritable(process.stdout), nodeToWebReadable(process.stdin));
|
||||
let agent!: ClaudeAcpAgent;
|
||||
// `connect` runs the connect handlers before it returns, so `agent` is set.
|
||||
const connection = v1AgentApp(logger, (created) => (agent = created)).connect(stream);
|
||||
return { connection, agent };
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ export const AIR_GOAL_KEY = "goal";
|
||||
export const AIR_KIND_KEY = "kind";
|
||||
export const AIR_PERMISSION_KEY = "permission";
|
||||
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
|
||||
/**
|
||||
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released
|
||||
* AIR versions read. Remove it when the ACP registry no longer serves AIR
|
||||
* versions that read only this spelling.
|
||||
*/
|
||||
export const LEGACY_AIR_CUSTOM_ANSWER_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
|
||||
@@ -12,7 +12,11 @@ import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import { AIR_CUSTOM_ANSWER_KEY, withAirMeta } from "./air-extension.js";
|
||||
import {
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
@@ -204,6 +208,7 @@ export function askUserQuestionsToCreateRequest(
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
|
||||
const customAnswer = { questionId: questionFieldKey(index), isCustomAnswer: true };
|
||||
properties[questionCustomFieldKey(index)] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
@@ -211,13 +216,15 @@ export function askUserQuestionsToCreateRequest(
|
||||
? "Type your own answer to add to your selection above (optional)."
|
||||
: "Type your own answer, or add a note to the option you chose above (optional).",
|
||||
// Marks the field as the custom answer companion of a select question,
|
||||
// under `_meta.jetbrains.air.customAnswer`. Only AIR gets the marker.
|
||||
// under `_meta.jetbrains.air.customAnswer` and under the root key that
|
||||
// released AIR versions read. Only AIR gets the marker.
|
||||
...(airClient
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_CUSTOM_ANSWER_KEY, {
|
||||
questionId: questionFieldKey(index),
|
||||
isCustomAnswer: true,
|
||||
}),
|
||||
_meta: withAirMeta(
|
||||
{ [LEGACY_AIR_CUSTOM_ANSWER_KEY]: customAnswer },
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
customAnswer,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
import { claudeCliPath } from "./acp-agent.js";
|
||||
import { applyManagedPolicyEnv } from "./managed-policy.js";
|
||||
import { serveAcp } from "./serve.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -82,10 +83,13 @@ if (process.argv.includes("--cli")) {
|
||||
: undefined;
|
||||
logger?.log("Claude ACP started");
|
||||
|
||||
const { connection, agent } = runAcp(logger);
|
||||
// The draft ACP v2 is opt-in while it is a draft.
|
||||
const acp = serveAcp(logger, {
|
||||
experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1",
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
await acp.dispose().catch((err) => {
|
||||
console.error("Error during cleanup:", err);
|
||||
});
|
||||
process.exit(0);
|
||||
@@ -94,7 +98,7 @@ if (process.argv.includes("--cli")) {
|
||||
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
|
||||
// error). Without this, `process.stdin.resume()` keeps the event loop
|
||||
// alive indefinitely, causing orphan process accumulation in oneshot mode.
|
||||
connection.closed.then(shutdown);
|
||||
acp.closed.then(shutdown);
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
51
src/serve.ts
Normal file
51
src/serve.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* Serves ACP on stdio.
|
||||
*
|
||||
* With `experimentalV2`, the connection goes through {@link acpProtocolRouter}.
|
||||
* Without it, the adapter serves ACP v1 alone and never routes by protocol
|
||||
* version, exactly as before.
|
||||
*/
|
||||
export function serveAcp(
|
||||
logger: Logger | undefined,
|
||||
options: { experimentalV2: boolean },
|
||||
): { closed: Promise<void>; dispose(): Promise<void> } {
|
||||
const output = nodeToWebWritable(process.stdout);
|
||||
const input = nodeToWebReadable(process.stdin);
|
||||
// The router opens the connection of the negotiated version only when it
|
||||
// reads `initialize`, so a client that leaves before then leaves no agent.
|
||||
let agent: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = options.experimentalV2
|
||||
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
|
||||
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
|
||||
return {
|
||||
closed,
|
||||
dispose: async () => {
|
||||
await agent?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a connection to the protocol version that the client negotiates in
|
||||
* `initialize`: the experimental draft ACP v2 for a client that requests
|
||||
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
|
||||
* the connection once the router has opened it.
|
||||
*/
|
||||
export function acpProtocolRouter(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentProtocolRouter {
|
||||
return v2
|
||||
.agentProtocolRouter()
|
||||
.withV1(v1AgentApp(logger, onAgent))
|
||||
.withV2(v2AgentApp(logger, onAgent));
|
||||
}
|
||||
@@ -12,6 +12,18 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
|
||||
return value === undefined || value === "default" ? null : (value as EffortLevel);
|
||||
}
|
||||
|
||||
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
|
||||
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
|
||||
// ultracode on at any level, so carry a settings-requested ultracode along
|
||||
// with every effort apply.
|
||||
export function effortFlagSettings(
|
||||
value: string | undefined,
|
||||
settings: Settings,
|
||||
): { effortLevel: EffortLevel | null; ultracode?: true } {
|
||||
const effortLevel = toSdkEffortLevel(value);
|
||||
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
|
||||
@@ -154,6 +154,51 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a settings-requested ultracode on when applying effort", async () => {
|
||||
// Since CLI 2.1.284 an effortLevel apply without an ultracode key turns
|
||||
// ultracode off; the CLI's own effort control keeps it on.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ ultracode: true }),
|
||||
);
|
||||
const applyFlagSettings = vi.fn();
|
||||
querySpy.mockReturnValue(
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "medium", ultracode: true });
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: response.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "high", ultracode: true });
|
||||
});
|
||||
|
||||
it("seeds effort from higher-priority programmatic model settings", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
toAcpNotifications,
|
||||
promptToClaude,
|
||||
isLocalCommandMetadata,
|
||||
isReplayHiddenMetaMessage,
|
||||
isSyntheticLoginMessage,
|
||||
stripLocalCommandMetadata,
|
||||
ClaudeAcpAgent,
|
||||
@@ -1948,6 +1949,17 @@ describe("stripLocalCommandMetadata", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("strips a persisted task notification, keeping what the user typed", () => {
|
||||
expect(
|
||||
stripLocalCommandMetadata(
|
||||
"<task-notification>\n<task-id>bg1</task-id>\n</task-notification>\nwhat now?",
|
||||
),
|
||||
).toBe("\nwhat now?");
|
||||
expect(
|
||||
stripLocalCommandMetadata("<task-notification><task-id>bg1</task-id></task-notification>"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("drops reminder-only blocks from mixed arrays, keeping real blocks", () => {
|
||||
expect(
|
||||
stripLocalCommandMetadata([
|
||||
@@ -2527,6 +2539,365 @@ describe("synthetic login message (issue #863)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("task notification replay", () => {
|
||||
type History = Awaited<ReturnType<typeof getSessionMessages>>;
|
||||
const record = (fields: Record<string, unknown>) =>
|
||||
({
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
...fields,
|
||||
}) as unknown as History[number];
|
||||
const notificationText = (status: string, toolUseId = "toolu_bash") =>
|
||||
"<task-notification>\n<task-id>bg1</task-id>\n" +
|
||||
`<tool-use-id>${toolUseId}</tool-use-id>\n` +
|
||||
"<output-file>/tmp/tasks/bg1.output</output-file>\n" +
|
||||
`<status>${status}</status>\n` +
|
||||
'<summary>Background command "npm test" finished</summary>\n' +
|
||||
"<note>A task-notification fires each time the task stops.</note>\n" +
|
||||
"<result><status>failed</status></result>\n</task-notification>";
|
||||
const notification = (status: string, fields: Record<string, unknown> = {}) =>
|
||||
record({
|
||||
type: "user",
|
||||
uuid: `notification-${status}`,
|
||||
message: { role: "user", content: notificationText(status) },
|
||||
origin: { kind: "task-notification" },
|
||||
...fields,
|
||||
});
|
||||
const bashLaunch = [
|
||||
record({ type: "user", uuid: "u1", message: { role: "user", content: "run the tests" } }),
|
||||
record({
|
||||
type: "assistant",
|
||||
uuid: "a1",
|
||||
message: {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
model: "claude-opus-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_bash",
|
||||
name: "Bash",
|
||||
input: { command: "npm test", run_in_background: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u2",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: "Command running in background with ID: bg1.",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
async function replay(
|
||||
history: History,
|
||||
capabilities: ClientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
|
||||
},
|
||||
): Promise<AcpSessionNotification[]> {
|
||||
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, capabilities);
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce(history);
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory("s1");
|
||||
return updates;
|
||||
}
|
||||
|
||||
const userChunks = (updates: AcpSessionNotification[]) =>
|
||||
updates.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "user_message_chunk" && update.content.type === "text"
|
||||
? [update.content.text]
|
||||
: [],
|
||||
);
|
||||
const asyncTaskUpdates = (updates: AcpSessionNotification[]) =>
|
||||
updates.flatMap(({ update }) =>
|
||||
update.sessionUpdate.startsWith("async_task_") ? [update] : [],
|
||||
);
|
||||
|
||||
it("hides the notification and restores the task state like the live frame", async () => {
|
||||
const updates = await replay([...bashLaunch, notification("completed")]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["run the tests"]);
|
||||
expect(JSON.stringify(updates)).not.toContain("task-notification");
|
||||
expect(asyncTaskUpdates(updates)).toEqual([
|
||||
{
|
||||
sessionUpdate: "async_task_spawned",
|
||||
asyncTaskId: "bg1",
|
||||
name: "npm test",
|
||||
taskType: "shell",
|
||||
description: "npm test",
|
||||
showInTranscript: true,
|
||||
canStop: true,
|
||||
outputFilePath: "/tmp/tasks/bg1.output",
|
||||
toolCallId: "toolu_bash",
|
||||
},
|
||||
{
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: "bg1",
|
||||
state: "completed",
|
||||
summary: 'Background command "npm test" finished',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects a notification without an origin by its text and maps killed to stopped", async () => {
|
||||
const updates = await replay([...bashLaunch, notification("killed", { origin: undefined })]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["run the tests"]);
|
||||
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({
|
||||
sessionUpdate: "async_task_state_update",
|
||||
asyncTaskId: "bg1",
|
||||
state: "stopped",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["server_tool_use", "mcp_tool_use"])(
|
||||
"restores a background task launched by a %s block",
|
||||
async (type) => {
|
||||
const launch = record({
|
||||
...bashLaunch[1],
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type, id: "toolu_bash", name: "Bash", input: { command: "npm test" } }],
|
||||
},
|
||||
});
|
||||
const updates = await replay([
|
||||
bashLaunch[0],
|
||||
launch,
|
||||
bashLaunch[2],
|
||||
notification("completed"),
|
||||
]);
|
||||
|
||||
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "completed" });
|
||||
},
|
||||
);
|
||||
|
||||
it("routes a task to the session of its launching tool even when the root receives the stop", async () => {
|
||||
const updates = await replay(
|
||||
[
|
||||
...bashLaunch.map((message, index) =>
|
||||
index === 0 ? message : record({ ...message, parent_tool_use_id: "toolu_agent" }),
|
||||
),
|
||||
notification("completed"),
|
||||
],
|
||||
{
|
||||
subagents: {},
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
|
||||
} as ClientCapabilities,
|
||||
);
|
||||
|
||||
const tasks = updates.filter(({ update }) => update.sessionUpdate.startsWith("async_task_"));
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks.map(({ sessionId }) => sessionId)).toEqual([
|
||||
"s1:replay-subagent:toolu_agent",
|
||||
"s1:replay-subagent:toolu_agent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the user text next to an embedded notification", async () => {
|
||||
const updates = await replay([
|
||||
...bashLaunch,
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u3",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: notificationText("failed") },
|
||||
{ type: "text", text: "why did it fail?" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["run the tests", "why did it fail?"]);
|
||||
expect(JSON.stringify(updates)).not.toContain("task-notification");
|
||||
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "failed" });
|
||||
});
|
||||
|
||||
it("sends nothing for a task that no tool call in the history started", async () => {
|
||||
const updates = await replay([
|
||||
record({ type: "user", uuid: "u1", message: { role: "user", content: "hi" } }),
|
||||
notification("completed"),
|
||||
]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["hi"]);
|
||||
expect(asyncTaskUpdates(updates)).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends one terminal state for a repeated notification", async () => {
|
||||
const updates = await replay([
|
||||
...bashLaunch,
|
||||
notification("completed"),
|
||||
notification("completed", { uuid: "notification-again" }),
|
||||
]);
|
||||
|
||||
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
|
||||
"async_task_spawned",
|
||||
"async_task_state_update",
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores the notified state of a background Agent after its launch result", async () => {
|
||||
const updates = await replay(
|
||||
[
|
||||
record({
|
||||
type: "assistant",
|
||||
uuid: "a1",
|
||||
message: {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
model: "claude-opus-5",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_agent",
|
||||
name: "Agent",
|
||||
input: { description: "Explore", prompt: "Look around", run_in_background: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u2",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "toolu_agent", content: "Async agent launched." },
|
||||
],
|
||||
},
|
||||
}),
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u3",
|
||||
message: { role: "user", content: notificationText("killed", "toolu_agent") },
|
||||
origin: { kind: "task-notification" },
|
||||
}),
|
||||
],
|
||||
{
|
||||
subagents: {},
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
|
||||
} as ClientCapabilities,
|
||||
);
|
||||
|
||||
expect(userChunks(updates)).toEqual([]);
|
||||
expect(asyncTaskUpdates(updates)).toEqual([]);
|
||||
expect(updates.at(-1)?.update).toMatchObject({
|
||||
sessionUpdate: "subagent_state_update",
|
||||
subagentSessionId: "s1:replay-subagent:toolu_agent",
|
||||
state: "cancelled",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a task-notification delivery with a subkind as a prompt", async () => {
|
||||
const updates = await replay([
|
||||
record({
|
||||
type: "user",
|
||||
uuid: "u1",
|
||||
message: { role: "user", content: "Run the nightly report" },
|
||||
origin: { kind: "task-notification", subkind: "scheduled-trigger" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(userChunks(updates)).toEqual(["Run the nightly report"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("meta messages from other agents, sessions and channels", () => {
|
||||
// The shape SDK 0.3.284 getSessionMessages returns for a host-injected peer
|
||||
// message (live probe): is_meta plus origin, content is the full framing.
|
||||
const peerMessage = {
|
||||
type: "user",
|
||||
uuid: "peer-1",
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
is_meta: true,
|
||||
origin: { kind: "peer", from: "uds:/tmp/fake.sock", hostInjected: true },
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
'Another Claude session sent a message:\n<cross-session-message from="uds:/tmp/fake.sock">enveloped hi</cross-session-message>\n\nThis came from another Claude session — not typed by your user.',
|
||||
},
|
||||
};
|
||||
|
||||
it("isReplayHiddenMetaMessage matches only meta messages with an inter-agent origin", () => {
|
||||
expect(isReplayHiddenMetaMessage(peerMessage)).toBe(true);
|
||||
for (const kind of ["channel", "observer", "observer-activity", "slack-ping"]) {
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, origin: { kind } })).toBe(true);
|
||||
}
|
||||
// Not meta: an un-framed message persisted as a normal user turn.
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, is_meta: undefined })).toBe(false);
|
||||
// Meta without an inter-agent origin, such as a compact summary.
|
||||
expect(
|
||||
isReplayHiddenMetaMessage({ ...peerMessage, origin: undefined, isCompactSummary: true }),
|
||||
).toBe(false);
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, origin: { kind: "human" } })).toBe(false);
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, type: "assistant" })).toBe(false);
|
||||
expect(isReplayHiddenMetaMessage(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("loadSession replay skips them but keeps the user's own prompt", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (u: SessionNotification) => {
|
||||
updates.push(u);
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
||||
{
|
||||
type: "user",
|
||||
uuid: "u1",
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
message: { role: "user", content: [{ type: "text", text: "hi, say one word" }] },
|
||||
},
|
||||
peerMessage,
|
||||
] as Awaited<ReturnType<typeof getSessionMessages>>);
|
||||
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory("s1");
|
||||
|
||||
expect(
|
||||
updates.some(
|
||||
(u) =>
|
||||
u.update.sessionUpdate === "user_message_chunk" &&
|
||||
u.update.content.type === "text" &&
|
||||
u.update.content.text.includes("hi, say one word"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(updates)).not.toContain("cross-session-message");
|
||||
expect(JSON.stringify(updates)).not.toContain("Another Claude session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage-limit failure replay", () => {
|
||||
const usageLimitMessage = {
|
||||
type: "assistant" as const,
|
||||
@@ -16312,11 +16683,25 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
||||
},
|
||||
);
|
||||
|
||||
// The fresh query activates the same turn again; it is inserted once.
|
||||
const inserted: string[] = [];
|
||||
const startTurn = agent.startTurn.bind(agent);
|
||||
vi.spyOn(agent, "startTurn").mockImplementation((params, events) =>
|
||||
startTurn(params, {
|
||||
...events,
|
||||
inserted: (messageId) => {
|
||||
inserted.push(messageId);
|
||||
events.inserted(messageId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "plan" }],
|
||||
});
|
||||
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(createSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/test" }),
|
||||
expect.objectContaining({ publicSessionId: "test-session", permissionMode: "auto" }),
|
||||
@@ -19310,6 +19695,59 @@ describe("turn steering (_session/steering)", () => {
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
});
|
||||
|
||||
// CLI 2.1.286: a steer that lands during a foreground tool call moves the tool
|
||||
// to the background and joins the running cycle instead of aborting it, so
|
||||
// the turn ends in ONE result stamped with both the prompt and the steer.
|
||||
it.each([
|
||||
{ name: "without an echo", echo: false },
|
||||
{ name: "with a mid-cycle echo", echo: true },
|
||||
])("settles a steer that joined the running cycle $name", async ({ echo }) => {
|
||||
const timeline: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(timelineClient(timeline), {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value);
|
||||
yield createAssistantText("working on it");
|
||||
const steered = await iter.next();
|
||||
if (echo) yield userEcho(steered.value);
|
||||
yield createAssistantText("STEERED-OK");
|
||||
yield {
|
||||
...createResultMessage(),
|
||||
user_message_uuid: u1.value.uuid,
|
||||
user_message_uuids: [u1.value.uuid, steered.value.uuid],
|
||||
};
|
||||
yield idleMessage();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const turn = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "start" }] })
|
||||
.then((response) => {
|
||||
timeline.push(`prompt:${response.stopReason}`);
|
||||
return response;
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
await expect(
|
||||
agent.steer({ sessionId: "test-session", prompt: [{ type: "text", text: "also handle X" }] }),
|
||||
).resolves.toEqual({ outcome: "injected" });
|
||||
|
||||
const response = await turn;
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
expect(timeline).toEqual(["working on it", "STEERED-OK", "prompt:end_turn"]);
|
||||
// One cycle ran, so usage is the single result's.
|
||||
expect(response.usage?.inputTokens).toBe(10);
|
||||
expect(agent.sessions["test-session"].turnQueue).toHaveLength(0);
|
||||
expect(agent.sessions["test-session"].owedTrailingIdles ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
// Issue #1114's other hang, and #1063's: the steer aborts an autonomous
|
||||
// followup, whose result counts a trailing idle that the abort then folds into
|
||||
// the one idle spanning both cycles. Absorbing that idle as debt would starve
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
compareWithBaseline,
|
||||
} from "./acp-scenarios/compare.js";
|
||||
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
|
||||
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
@@ -661,6 +662,7 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
(record) => record.kind === "createElicitation",
|
||||
)!.payload as Record<string, any>;
|
||||
expect(elicitation.requestedSchema.properties.question_0_custom._meta).toEqual({
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { version: 1, customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
@@ -689,7 +691,13 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const { at, meta } of metaObjects(air(scenario.name))) {
|
||||
const duplicates = [
|
||||
...Object.keys(meta).filter((k) => AIR_ONLY_META_KEYS.has(k) && k !== "jetbrains"),
|
||||
// Released AIR versions read only the legacy custom answer key, so AIR gets both.
|
||||
...Object.keys(meta).filter(
|
||||
(k) =>
|
||||
AIR_ONLY_META_KEYS.has(k) &&
|
||||
k !== "jetbrains" &&
|
||||
k !== LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {}).filter(
|
||||
(k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"_askUserQuestionCustomAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
513
src/tests/acp-v2.test.ts
Normal file
513
src/tests/acp-v2.test.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* The experimental draft ACP v2 surface, driven through the protocol router
|
||||
* with the SDK's own v1 and v2 client apps. The v2 client app validates every
|
||||
* response, session update, and elicitation it receives against the v2
|
||||
* schema, so a malformed v2 message fails the test.
|
||||
*/
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as v1 from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { AuthStatusUpdateNotification } from "../auth-status.js";
|
||||
import { acpProtocolRouter } from "../serve.js";
|
||||
import { clientSupportsNotices } from "../session-notices.js";
|
||||
import { v2SessionUpdate } from "../v2/session-update.js";
|
||||
import { v1SetSessionConfigOptionRequest, v2ConfigOptions } from "../v2/session.js";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
|
||||
const execFileSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return { ...actual, execFile: execFileSpy };
|
||||
});
|
||||
|
||||
/** What the mocked Claude Agent SDK saw and answers. Reset before each test. */
|
||||
const sdk = vi.hoisted(() => ({
|
||||
queryOptions: [] as Options[],
|
||||
mcpServerStatus: async (): Promise<Array<{ name: string; status: string }>> => [],
|
||||
mcpAuthenticate: async (
|
||||
_serverName: string,
|
||||
): Promise<{ authUrl?: string; requiresUserAction: boolean; callbackExpected: boolean }> => ({
|
||||
requiresUserAction: false,
|
||||
callbackExpected: false,
|
||||
}),
|
||||
}));
|
||||
const sdkDefaults = { ...sdk };
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
const { makeMockQuery } = await import("./helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
query: ({ options }: { options: Options }) => {
|
||||
sdk.queryOptions.push(options);
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
supportedCommands: async () => [
|
||||
{ name: "review", description: "Review a change", argumentHint: "<pull request>" },
|
||||
],
|
||||
mcpServerStatus: () => sdk.mcpServerStatus(),
|
||||
mcpAuthenticate: (serverName: string) => sdk.mcpAuthenticate(serverName),
|
||||
});
|
||||
},
|
||||
listSessions: vi.fn(async () => [
|
||||
{
|
||||
sessionId: "11111111-2222-4333-8444-555555555555",
|
||||
cwd: "/workspace/project",
|
||||
summary: "Fix the build",
|
||||
lastModified: Date.UTC(2026, 9, 1),
|
||||
},
|
||||
]),
|
||||
getSessionMessages: vi.fn(async () => []),
|
||||
deleteSession: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
const CLI_SUBSCRIPTION = JSON.stringify({
|
||||
loggedIn: true,
|
||||
authMethod: "claude.ai",
|
||||
apiProvider: "firstParty",
|
||||
email: "user@example.com",
|
||||
subscriptionType: "max",
|
||||
});
|
||||
|
||||
const V2_CLIENT_INFO = { name: "v2-test-client", version: "1.0.0" };
|
||||
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
/** An empty Claude config directory and a session cwd, so no test reads the real ones. */
|
||||
let tempDir: string;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// `claude auth status --json` reports a subscription, `claude auth logout` succeeds.
|
||||
execFileSpy.mockImplementation((...invocation: unknown[]) => {
|
||||
const args = invocation[1] as string[];
|
||||
const callback = invocation[invocation.length - 1] as (...a: unknown[]) => void;
|
||||
callback(null, { stdout: args[1] === "status" ? CLI_SUBSCRIPTION : "", stderr: "" });
|
||||
});
|
||||
tempDir = await mkdtemp(path.join(os.tmpdir(), "acp-v2-"));
|
||||
cwd = await mkdtemp(path.join(tempDir, "project-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempDir, "claude"));
|
||||
Object.assign(sdk, sdkDefaults, { queryOptions: [] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
|
||||
execFileSpy.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Connects a fresh router to a client, and returns the client end of the stream. */
|
||||
function connectRouter(): v2.Stream {
|
||||
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||
acpProtocolRouter(undefined, (agent) => agents.push(agent)).connect({
|
||||
readable: toAgent.readable,
|
||||
writable: toClient.writable,
|
||||
});
|
||||
return { readable: toClient.readable, writable: toAgent.writable };
|
||||
}
|
||||
|
||||
/**
|
||||
* A v2 client app that collects every `_auth/status_update`, session update,
|
||||
* and elicitation it receives, and accepts every elicitation.
|
||||
*/
|
||||
function v2Client() {
|
||||
const authUpdates: AuthStatusUpdateNotification[] = [];
|
||||
const sessionUpdates: v2.UpdateSessionNotification[] = [];
|
||||
const elicitations: v2.CreateElicitationRequest[] = [];
|
||||
const completedElicitations: v2.CompleteElicitationNotification[] = [];
|
||||
let notifyAuthUpdate = () => {};
|
||||
const app = v2
|
||||
.client({ name: V2_CLIENT_INFO.name })
|
||||
.onNotification(
|
||||
"_auth/status_update",
|
||||
(params) => params as AuthStatusUpdateNotification,
|
||||
({ params }) => {
|
||||
authUpdates.push(params);
|
||||
notifyAuthUpdate();
|
||||
},
|
||||
)
|
||||
.onNotification(v2.methods.client.session.update, ({ params }) => {
|
||||
sessionUpdates.push(params);
|
||||
})
|
||||
.onRequest(v2.methods.client.elicitation.create, ({ params }) => {
|
||||
elicitations.push(params);
|
||||
return { action: "accept" };
|
||||
})
|
||||
.onNotification(v2.methods.client.elicitation.complete, ({ params }) => {
|
||||
completedElicitations.push(params);
|
||||
});
|
||||
/** Resolves once the client has received `count` auth status updates. */
|
||||
const authUpdate = (count: number) =>
|
||||
new Promise<AuthStatusUpdateNotification>((resolve) => {
|
||||
notifyAuthUpdate = () => {
|
||||
if (authUpdates.length >= count) {
|
||||
resolve(authUpdates[count - 1]);
|
||||
}
|
||||
};
|
||||
notifyAuthUpdate();
|
||||
});
|
||||
/** The session updates of one kind that the client has received. */
|
||||
const updates = <Kind extends string>(kind: Kind) =>
|
||||
sessionUpdates
|
||||
.map((notification) => notification.update)
|
||||
.filter(
|
||||
(update): update is Extract<v2.SessionUpdate, { sessionUpdate: Kind }> =>
|
||||
update.sessionUpdate === kind,
|
||||
);
|
||||
return { app, authUpdate, sessionUpdates, updates, elicitations, completedElicitations };
|
||||
}
|
||||
|
||||
async function initializeV2(agent: v2.ClientContext, capabilities: v2.ClientCapabilities = {}) {
|
||||
return agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ACP protocol routing", () => {
|
||||
it("serves the unchanged v1 handshake to a v1 client", async () => {
|
||||
let authUpdated!: () => void;
|
||||
const authUpdate = new Promise<void>((resolve) => (authUpdated = resolve));
|
||||
const response = await v1
|
||||
.client({ name: "v1-test-client" })
|
||||
.onNotification(
|
||||
"_auth/status_update",
|
||||
(params) => params,
|
||||
() => authUpdated(),
|
||||
)
|
||||
.connectWith(connectRouter() as unknown as v1.Stream, async (agent) => {
|
||||
const response = await agent.request(v1.methods.agent.initialize, {
|
||||
protocolVersion: v1.PROTOCOL_VERSION,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
await authUpdate;
|
||||
return response;
|
||||
});
|
||||
|
||||
expect(response.protocolVersion).toBe(1);
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({});
|
||||
expect(response.authMethods?.length).toBeGreaterThan(0);
|
||||
for (const method of response.authMethods ?? []) {
|
||||
expect(method).toHaveProperty("id");
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the v2 handshake to a v2 client", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
const response = await app.connectWith(connectRouter(), async (agent) => {
|
||||
const response = await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
capabilities: { auth: { terminal: {} } },
|
||||
});
|
||||
await authUpdate(1);
|
||||
return response;
|
||||
});
|
||||
|
||||
expect(response.protocolVersion).toBe(2);
|
||||
expect(response.info).toEqual({
|
||||
name: packageJson.name,
|
||||
title: "Claude Agent",
|
||||
version: packageJson.version,
|
||||
});
|
||||
// The whole v2 session baseline and the providers methods are not served yet.
|
||||
expect(response.capabilities?.session).toBeUndefined();
|
||||
expect(response.capabilities?.providers).toBeUndefined();
|
||||
expect(response.authMethods?.length).toBeGreaterThan(0);
|
||||
for (const method of response.authMethods ?? []) {
|
||||
expect(method).not.toHaveProperty("id");
|
||||
expect(method).toMatchObject({ methodId: expect.any(String), type: "terminal" });
|
||||
expect((method as v2.AuthMethodTerminal).args?.[0]).toBe("--cli");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects v2 prompts, which the v2 surface does not serve yet", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await expect(
|
||||
agent.request(v2.methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32601 });
|
||||
await authUpdate(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP v2 sessions", () => {
|
||||
it("creates a session with v2 MCP servers, config options, and commands", async () => {
|
||||
const client = v2Client();
|
||||
const response = await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const response = await agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
mcpServers: [
|
||||
{ type: "stdio", name: "files", command: "/usr/local/bin/mcp-files" },
|
||||
{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" },
|
||||
],
|
||||
});
|
||||
await vi.waitFor(() => expect(client.updates("available_commands_update")).toHaveLength(1));
|
||||
await client.authUpdate(1);
|
||||
return response;
|
||||
});
|
||||
|
||||
// A v2 stdio server has a `type`, which v1 omits, and may omit its lists.
|
||||
expect(sdk.queryOptions[0].mcpServers).toMatchObject({
|
||||
files: { type: "stdio", command: "/usr/local/bin/mcp-files", args: [] },
|
||||
linear: { type: "http", url: "https://mcp.linear.app/mcp" },
|
||||
});
|
||||
expect(response).not.toHaveProperty("modes");
|
||||
expect(response.configOptions?.map((option) => option.configId)).toEqual(["mode", "model"]);
|
||||
for (const option of response.configOptions ?? []) {
|
||||
expect(option).not.toHaveProperty("id");
|
||||
}
|
||||
expect(response.configOptions?.[0]).toMatchObject({ category: "mode", type: "select" });
|
||||
expect(client.updates("available_commands_update")[0].availableCommands).toEqual([
|
||||
{
|
||||
name: "review",
|
||||
description: "Review a change",
|
||||
input: { type: "text", hint: "<pull request>" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an MCP transport that v1 cannot express", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
await expect(
|
||||
agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
mcpServers: [{ type: "_custom", name: "custom" }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
await authUpdate(1);
|
||||
});
|
||||
expect(sdk.queryOptions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lists, resumes, closes, and deletes sessions", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
expect(await agent.request(v2.methods.agent.session.list, {})).toEqual({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: "11111111-2222-4333-8444-555555555555",
|
||||
cwd: "/workspace/project",
|
||||
title: "Fix the build",
|
||||
updatedAt: "2026-10-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.close, { sessionId });
|
||||
// A closed session is resumed from its transcript.
|
||||
const resumed = await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
|
||||
expect(sdk.queryOptions.at(-1)?.resume).toBe(sessionId);
|
||||
expect(resumed).not.toHaveProperty("modes");
|
||||
expect(resumed.configOptions?.[0].configId).toBe("mode");
|
||||
|
||||
await expect(
|
||||
agent.request(v2.methods.agent.session.resume, {
|
||||
sessionId,
|
||||
cwd,
|
||||
replayFrom: { type: "start" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: -32602 });
|
||||
|
||||
await agent.request(v2.methods.agent.session.delete, { sessionId });
|
||||
await authUpdate(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("sets a config option with a v2 value and reports modes only as config options", async () => {
|
||||
const client = v2Client();
|
||||
const response = await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
const response = await agent.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId: "mode",
|
||||
type: "id",
|
||||
value: "plan",
|
||||
});
|
||||
await client.authUpdate(1);
|
||||
return response;
|
||||
});
|
||||
|
||||
expect(response.configOptions[0]).toMatchObject({ configId: "mode", currentValue: "plan" });
|
||||
expect(client.updates("current_mode_update")).toEqual([]);
|
||||
});
|
||||
|
||||
it("tells the agent that a v2 client takes notices", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
await authUpdate(1);
|
||||
});
|
||||
expect(clientSupportsNotices(agents[0].clientCapabilities)).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards the URL elicitation of MCP OAuth to a v2 client", async () => {
|
||||
let statusCall = 0;
|
||||
sdk.mcpServerStatus = async () =>
|
||||
statusCall++ === 0
|
||||
? [{ name: "linear", status: "needs-auth" }]
|
||||
: [{ name: "linear", status: "connected" }];
|
||||
sdk.mcpAuthenticate = async () => ({
|
||||
authUrl: "https://example.com/oauth/authorize",
|
||||
requiresUserAction: true,
|
||||
callbackExpected: true,
|
||||
});
|
||||
const client = v2Client();
|
||||
const sessionId = await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent, { elicitation: { url: {} } });
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
mcpServers: [{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" }],
|
||||
});
|
||||
await vi.waitFor(() => expect(client.completedElicitations).toHaveLength(1));
|
||||
await client.authUpdate(1);
|
||||
return sessionId;
|
||||
});
|
||||
|
||||
expect(client.elicitations).toEqual([
|
||||
{
|
||||
mode: "url",
|
||||
sessionId,
|
||||
message: "Authenticate with MCP server linear",
|
||||
url: "https://example.com/oauth/authorize",
|
||||
elicitationId: expect.stringMatching(/^mcp-oauth-/),
|
||||
},
|
||||
]);
|
||||
expect(client.completedElicitations).toEqual([
|
||||
{ elicitationId: (client.elicitations[0] as { elicitationId: string }).elicitationId },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP v2 session translation", () => {
|
||||
it("renames the group id of grouped select options", () => {
|
||||
expect(
|
||||
v2ConfigOptions([
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ group: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
configId: "model",
|
||||
name: "Model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ groupId: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a config option value type that v1 cannot express", () => {
|
||||
expect(() =>
|
||||
v1SetSessionConfigOptionRequest({
|
||||
sessionId: "s",
|
||||
configId: "c",
|
||||
type: "_range",
|
||||
value: 3,
|
||||
}),
|
||||
).toThrow("Config option values of type _range are not supported");
|
||||
});
|
||||
|
||||
it("fails on session updates that it does not translate yet", () => {
|
||||
expect(() =>
|
||||
v2SessionUpdate({ sessionUpdate: "tool_call", toolCallId: "t", title: "Read" }),
|
||||
).toThrow("does not translate tool_call session updates yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP v2 auth", () => {
|
||||
it("pushes the auth status of the connection after initialize", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
const update = await app.connectWith(connectRouter(), async (agent) => {
|
||||
await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
});
|
||||
return authUpdate(1);
|
||||
});
|
||||
|
||||
expect(update.authStatus).toMatchObject({
|
||||
kind: "account",
|
||||
account: { email: "user@example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("logs in to a gateway with auth/login and out with auth/logout", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
const response = await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
capabilities: { auth: { _meta: { gateway: true } } },
|
||||
});
|
||||
expect(response.authMethods).toContainEqual({
|
||||
methodId: "gateway",
|
||||
type: "agent",
|
||||
name: "Custom model gateway",
|
||||
description: "Use a custom gateway to authenticate and access models",
|
||||
_meta: { gateway: { protocol: "anthropic" } },
|
||||
});
|
||||
await authUpdate(1);
|
||||
|
||||
await agent.request(v2.methods.agent.auth.login, {
|
||||
methodId: "gateway",
|
||||
_meta: { gateway: { baseUrl: "https://gateway.example.com", headers: {} } },
|
||||
});
|
||||
expect((await authUpdate(2)).authStatus).toMatchObject({
|
||||
kind: "gateway",
|
||||
detail: "gateway.example.com",
|
||||
});
|
||||
|
||||
await agent.request(v2.methods.agent.auth.logout, {});
|
||||
expect(execFileSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
["auth", "logout"],
|
||||
expect.any(Function),
|
||||
);
|
||||
expect((await authUpdate(3)).authStatus).toMatchObject({ kind: "account" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ let sessionMessages: Record<string, unknown>[];
|
||||
let sessionMessagesResult: () => Promise<Record<string, unknown>[]>;
|
||||
let initModels: Record<string, unknown>[] | undefined;
|
||||
let setModelImpl: ((model: string) => Promise<void>) | undefined;
|
||||
let interruptImpl: (() => Promise<undefined>) | undefined;
|
||||
let closeSpy: ReturnType<typeof vi.fn<() => void>>;
|
||||
let mcpServerStatusResult: () => Promise<Array<{ name: string; status: string }>>;
|
||||
let mcpAuthenticateImpl: (serverName: string) => Promise<{
|
||||
authUrl?: string;
|
||||
@@ -34,8 +36,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
query: (args: { prompt: unknown; options: Options }) => {
|
||||
capturedOptions = args.options;
|
||||
return makeMockQuery({
|
||||
interrupt: async () => undefined,
|
||||
close: () => {},
|
||||
interrupt: () => (interruptImpl ? interruptImpl() : Promise.resolve(undefined)),
|
||||
close: () => closeSpy(),
|
||||
initializationResult: async () => ({
|
||||
models: initModels ?? [
|
||||
{
|
||||
@@ -86,6 +88,8 @@ describe("createSession options merging", () => {
|
||||
vi.mocked(getSessionMessages).mockClear();
|
||||
initModels = undefined;
|
||||
setModelImpl = undefined;
|
||||
interruptImpl = undefined;
|
||||
closeSpy = vi.fn<() => void>();
|
||||
mcpServerStatusResult = async () => [];
|
||||
mcpAuthenticateImpl = async () => ({
|
||||
requiresUserAction: false,
|
||||
@@ -847,6 +851,36 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/close right after session/new", () => {
|
||||
it("answers at once and closes the query", async () => {
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
await agent.closeSession({ sessionId });
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledOnce();
|
||||
expect((agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId]).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not wait for an interrupt queued behind the background getContextUsage", async () => {
|
||||
// SDK control requests are serialized. On a fresh session the background
|
||||
// getContextUsage is still in flight, so a real CLI answers an interrupt
|
||||
// only after it, which took ~3s and timed out the client's close.
|
||||
contextUsageResult = () => new Promise<never>(() => {});
|
||||
interruptImpl = () => new Promise<never>(() => {});
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
const closed = await Promise.race([
|
||||
agent.closeSession({ sessionId }).then(() => "closed"),
|
||||
new Promise((resolve) => setTimeout(() => resolve("timed out"), 500)),
|
||||
]);
|
||||
|
||||
expect(closed).toBe("closed");
|
||||
expect(closeSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("context window seeding", () => {
|
||||
function sessionFor(sessionId: string) {
|
||||
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { applyPatch } from "diff";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -260,13 +260,32 @@ describe("approval patch previews", () => {
|
||||
.join("");
|
||||
const filePath = await temporaryFile(oldText);
|
||||
|
||||
// 200 changed lines. A diff that runs out of its time budget gives no
|
||||
// patch, and patchText then throws.
|
||||
const patch = patchText(
|
||||
await previewPatchContent("Write", { file_path: filePath, content: newText }),
|
||||
);
|
||||
// Verify the 200-line patch independently of the runner's speed. The
|
||||
// timeout fallback is exercised separately with an advancing clock.
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(0);
|
||||
try {
|
||||
const patch = patchText(
|
||||
await previewPatchContent("Write", { file_path: filePath, content: newText }),
|
||||
);
|
||||
expect(applyPatch(oldText, patch)).toBe(newText);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
expect(applyPatch(oldText, patch)).toBe(newText);
|
||||
it("falls back to the standard Write diff when the patch budget expires", async () => {
|
||||
const oldText = "before\n";
|
||||
const newText = "after\n";
|
||||
const filePath = await temporaryFile(oldText);
|
||||
let now = 0;
|
||||
const clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000));
|
||||
try {
|
||||
expect(await previewPatchContent("Write", { file_path: filePath, content: newText })).toEqual(
|
||||
[{ type: "diff", path: filePath, oldText, newText }],
|
||||
);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds patches that turn the old text into the new text", async () => {
|
||||
|
||||
@@ -265,6 +265,7 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
_meta: {
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
@@ -274,6 +275,7 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
_meta: {
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_1", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { customAnswer: { questionId: "question_1", isCustomAnswer: true } },
|
||||
},
|
||||
|
||||
321
src/tests/incomplete-tools.test.ts
Normal file
321
src/tests/incomplete-tools.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../tools.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
userEcho,
|
||||
wrapQuery,
|
||||
} from "./session-doubles.js";
|
||||
|
||||
const sessionId = "test-session";
|
||||
const toolCallId = "unfinished-bash";
|
||||
|
||||
afterEach(() => clearHookCallbacks(sessionId));
|
||||
|
||||
describe("incomplete foreground tools", () => {
|
||||
it.each(["result", "EOF"] as const)(
|
||||
"fails the tool and prompt at %s even after the input stream closes",
|
||||
async (ending) => {
|
||||
const { prompt, updates, logError } = createTestSession((input) =>
|
||||
unfinishedToolMessages(input, ending),
|
||||
);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
expect(updates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: "failed",
|
||||
}),
|
||||
);
|
||||
expect(logError).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.stringMatching(
|
||||
/Session test-session, turn .+, stopReason=end_turn:.*unfinished-bash/,
|
||||
),
|
||||
);
|
||||
const failed = updates.find((u) => u.toolCallId === toolCallId && u.status === "failed");
|
||||
expect(failed.content[0].content.text).toContain("without returning a result");
|
||||
expect(failed).not.toHaveProperty("rawOutput");
|
||||
expect(hasHookCallback(toolCallId)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("detects a tool emitted before the user echo", async () => {
|
||||
const { prompt } = createTestSession(toolBeforeUserEchoMessages);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
});
|
||||
|
||||
it("detects a permission tool whose streamed tool_use never arrived", async () => {
|
||||
const { prompt, updates } = createTestSession(permissionOnlyToolMessages);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
expect(updates).toContainEqual(expect.objectContaining({ toolCallId, status: "failed" }));
|
||||
});
|
||||
|
||||
it("allows a completed tool with a late PostToolUse hook", async () => {
|
||||
const { prompt, logError } = createTestSession(completedToolMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
expect(hasHookCallback(toolCallId)).toBe(true);
|
||||
await createPostToolUseHook()(
|
||||
{
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "echo test" },
|
||||
tool_response: { stdout: "test", stderr: "", interrupted: false },
|
||||
} as any,
|
||||
toolCallId,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a tool explicitly handed off to a background task", async () => {
|
||||
const { prompt, updates } = createTestSession(backgroundToolMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not claim a subagent's tool as a foreground tool", async () => {
|
||||
const { prompt } = createTestSession(subagentToolMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("does not treat a request to run in the background as a confirmed handoff", async () => {
|
||||
const { prompt } = createTestSession(unconfirmedBackgroundToolMessages);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
});
|
||||
|
||||
it("waits for deferred settlement before checking unfinished tools", async () => {
|
||||
const { prompt, logError } = createTestSession(deferredSettlementMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails every unfinished tool and allows a later prompt to succeed", async () => {
|
||||
const { prompt, updates, agent } = createTestSession(incompleteThenSuccessfulTurnMessages);
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
expect(updates.filter((u) => u.status === "failed").map((u) => u.toolCallId)).toEqual([
|
||||
toolCallId,
|
||||
"second-tool",
|
||||
]);
|
||||
expect(agent.sessions[sessionId].emittedToolCalls.size).toBe(0);
|
||||
expect(agent.sessions[sessionId].toolUseCache).toEqual({});
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("preserves cancellation and does not attribute its unfinished tool to the next turn", async () => {
|
||||
const { prompt, updates } = createTestSession(cancelledThenSuccessfulTurnMessages);
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "cancelled" });
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves an existing SDK failure", async () => {
|
||||
const { prompt } = createTestSession(sdkFailureMessages);
|
||||
|
||||
await expect(prompt()).rejects.toThrow("original failure");
|
||||
});
|
||||
});
|
||||
|
||||
function toolStart(id = toolCallId, parent: string | null = null) {
|
||||
return {
|
||||
type: "stream_event",
|
||||
session_id: sessionId,
|
||||
uuid: "stream-message",
|
||||
parent_tool_use_id: parent,
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id, name: "Bash", input: {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolResult(id = toolCallId) {
|
||||
return {
|
||||
type: "user",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: id, content: "done" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTestSession(
|
||||
createSdkMessages: (input: Pushable<any>, agent: ClaudeAcpAgent) => AsyncGenerator<any>,
|
||||
) {
|
||||
const updates: any[] = [];
|
||||
const logError = vi.fn();
|
||||
const input = new Pushable<any>();
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification.update);
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "selected", optionId: "allow-once" } }),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: logError },
|
||||
);
|
||||
agent.sessions[sessionId] = mockSessionState({
|
||||
input,
|
||||
// The real consumer reads the scripted SDK events from this generator.
|
||||
query: wrapQuery(createSdkMessages(input, agent)),
|
||||
});
|
||||
const prompt = () => agent.prompt({ sessionId, prompt: [{ type: "text", text: "continue" }] });
|
||||
return { agent, updates, logError, prompt };
|
||||
}
|
||||
|
||||
// Wait for prompt() to submit input, then echo it as the SDK would.
|
||||
async function* echoNextPrompt(input: Pushable<any>) {
|
||||
const { value } = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(value);
|
||||
}
|
||||
|
||||
async function* unfinishedToolMessages(input: Pushable<any>, ending: "result" | "EOF") {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield {
|
||||
...toolStart(),
|
||||
event: { type: "content_block_stop", index: 0 },
|
||||
};
|
||||
// The input block closed, but no tool_result arrived. Returning ends the stream.
|
||||
if (ending === "result") yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* toolBeforeUserEchoMessages(input: Pushable<any>) {
|
||||
const { value } = await input[Symbol.asyncIterator]().next();
|
||||
yield toolStart();
|
||||
yield userEcho(value);
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* permissionOnlyToolMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
|
||||
yield* echoNextPrompt(input);
|
||||
await agent.canUseTool(sessionId)("Bash", { command: "echo test" }, {
|
||||
toolUseID: toolCallId,
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
} as any);
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* completedToolMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield toolResult();
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* backgroundToolMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
session_id: sessionId,
|
||||
task_id: "background-shell",
|
||||
tool_use_id: toolCallId,
|
||||
description: "dev server",
|
||||
};
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* subagentToolMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart("child-tool", "parent-agent");
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* unconfirmedBackgroundToolMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield {
|
||||
type: "assistant",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "assistant",
|
||||
usage: successfulResultMessage().usage,
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: toolCallId,
|
||||
name: "Bash",
|
||||
input: { command: "sleep 100", run_in_background: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* deferredSettlementMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "task_started",
|
||||
session_id: sessionId,
|
||||
task_id: "child",
|
||||
tool_use_id: "parent-agent",
|
||||
subagent_type: "Explore",
|
||||
description: "investigate",
|
||||
};
|
||||
// The result arrives while the subagent is still running, so settlement waits.
|
||||
yield successfulResultMessage();
|
||||
yield toolResult();
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
session_id: sessionId,
|
||||
task_id: "child",
|
||||
tool_use_id: "parent-agent",
|
||||
status: "completed",
|
||||
summary: "done",
|
||||
};
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
|
||||
async function* incompleteThenSuccessfulTurnMessages(input: Pushable<any>) {
|
||||
const messages = input[Symbol.asyncIterator]();
|
||||
yield userEcho((await messages.next()).value);
|
||||
yield toolStart();
|
||||
yield toolStart("second-tool");
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
|
||||
// Keep the SDK stream open for the next prompt in the same session.
|
||||
yield userEcho((await messages.next()).value);
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* cancelledThenSuccessfulTurnMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
|
||||
const messages = input[Symbol.asyncIterator]();
|
||||
yield userEcho((await messages.next()).value);
|
||||
yield toolStart();
|
||||
await agent.cancel({ sessionId });
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
|
||||
yield userEcho((await messages.next()).value);
|
||||
yield successfulResultMessage();
|
||||
}
|
||||
|
||||
async function* sdkFailureMessages(input: Pushable<any>) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield toolStart();
|
||||
yield successfulResultMessage({
|
||||
subtype: "error_during_execution",
|
||||
is_error: true,
|
||||
errors: ["original failure"],
|
||||
});
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isDeniedModel,
|
||||
matchResumedModel,
|
||||
} from "../session-model.js";
|
||||
import { settingsEffortForModel } from "../session-effort.js";
|
||||
import { effortFlagSettings, settingsEffortForModel } from "../session-effort.js";
|
||||
|
||||
// Mirrors a real `supportedModels()` response: alias rows carry
|
||||
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
|
||||
@@ -369,3 +369,18 @@ describe("settingsEffortForModel", () => {
|
||||
expect(settingsEffortForModel({ modelSettings: {} }, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effortFlagSettings", () => {
|
||||
it("carries ultracode only when settings request it", () => {
|
||||
expect(effortFlagSettings("high", { ultracode: true })).toEqual({
|
||||
effortLevel: "high",
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("default", { ultracode: true })).toEqual({
|
||||
effortLevel: null,
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("high", { ultracode: false })).toEqual({ effortLevel: "high" });
|
||||
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
|
||||
});
|
||||
});
|
||||
|
||||
400
src/tests/turn-events.test.ts
Normal file
400
src/tests/turn-events.test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* The turn lifecycle that the agent reports through `TurnEvents`, which each
|
||||
* ACP version maps to its own messages. A scripted SDK query stands in for
|
||||
* Claude Code: it echoes each prompt, then runs the turn's script.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { TurnEvents } from "../turn-events.js";
|
||||
import { DEFAULT_CONTEXT_USAGE } from "./helpers.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const mockQuery = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
// The agent reads the CLI's auth status on each prompt; keep it off the real CLI.
|
||||
vi.mock("node:child_process", async () => ({
|
||||
...(await vi.importActual<typeof import("node:child_process")>("node:child_process")),
|
||||
execFile: (...args: unknown[]) =>
|
||||
(args[args.length - 1] as (error: Error) => void)(new Error("no CLI in tests")),
|
||||
}));
|
||||
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
type QueryOptions = {
|
||||
sessionId?: string;
|
||||
canUseTool: (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
extra: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
|
||||
/** The SDK side of one turn, after the echo of its prompt. */
|
||||
type TurnScript = (options: QueryOptions) => AsyncGenerator<Record<string, unknown>>;
|
||||
|
||||
function result(sessionId: string) {
|
||||
return {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "done",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Scripts the query of the next session: each prompt runs the next turn script. */
|
||||
function scriptTurns(turns: TurnScript[], end: "stay open" | "end stream" = "stay open") {
|
||||
const promptUuids: string[] = [];
|
||||
mockQuery.mockImplementation(
|
||||
({ prompt, options }: { prompt: AsyncIterable<any>; options: QueryOptions }) => {
|
||||
const sessionId = options.sessionId ?? "session";
|
||||
async function* run() {
|
||||
const input = prompt[Symbol.asyncIterator]();
|
||||
for (const turn of turns) {
|
||||
const { value, done } = await input.next();
|
||||
if (done) return;
|
||||
promptUuids.push(value.uuid);
|
||||
yield {
|
||||
type: "user",
|
||||
message: value.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: value.uuid,
|
||||
session_id: sessionId,
|
||||
isReplay: true,
|
||||
};
|
||||
for await (const message of turn(options)) yield message;
|
||||
}
|
||||
if (end === "stay open") await input.next();
|
||||
}
|
||||
return Object.assign(run(), {
|
||||
initializationResult: async () => ({
|
||||
models: [{ value: "default", displayName: "Default", description: "" }],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
mcpServerStatus: async () => [],
|
||||
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
|
||||
close: () => {},
|
||||
interrupt: async () => {},
|
||||
stopTask: async () => {},
|
||||
});
|
||||
},
|
||||
);
|
||||
return promptUuids;
|
||||
}
|
||||
|
||||
/** Records the events of one turn as readable strings. */
|
||||
function recordEvents() {
|
||||
const log: string[] = [];
|
||||
const done = Promise.withResolvers<void>();
|
||||
const events: TurnEvents = {
|
||||
inserted: (messageId) => log.push(`inserted ${messageId}`),
|
||||
awaitingUser: () => log.push("awaitingUser"),
|
||||
resumed: () => log.push("resumed"),
|
||||
ended: (outcome) => {
|
||||
log.push(`ended ${outcome.stopReason}`);
|
||||
done.resolve();
|
||||
},
|
||||
failed: (error) => {
|
||||
log.push(`failed ${error instanceof Error ? error.message : String(error)}`);
|
||||
done.resolve();
|
||||
},
|
||||
};
|
||||
return { log, events, done: done.promise };
|
||||
}
|
||||
|
||||
describe("turn events", () => {
|
||||
let agent: ClaudeAcpAgent;
|
||||
let permissionRequests: RequestPermissionRequest[];
|
||||
/** The user answers a request for this tool call only once it resolves. */
|
||||
let backgroundAnswer: PromiseWithResolvers<void>;
|
||||
/** Sees each session update; the agent waits until it returns. */
|
||||
let onSessionUpdate: (notification: SessionNotification) => Promise<void> | void;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
permissionRequests = [];
|
||||
backgroundAnswer = Promise.withResolvers<void>();
|
||||
onSessionUpdate = () => {};
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification),
|
||||
extNotification: async () => {},
|
||||
requestPermission: async (request: RequestPermissionRequest) => {
|
||||
permissionRequests.push(request);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
if (request.toolCall.toolCallId === "toolu_background") await backgroundAnswer.promise;
|
||||
const allow = request.options.find((option) => option.kind === "allow_once");
|
||||
return { outcome: { outcome: "selected", optionId: allow!.optionId } };
|
||||
},
|
||||
} as unknown as AcpClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
async function newSession() {
|
||||
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const prompt = (sessionId: string, text: string) => ({
|
||||
sessionId,
|
||||
prompt: [{ type: "text" as const, text }],
|
||||
});
|
||||
|
||||
it("reports a turn as inserted under the uuid of its user message, then ended", async () => {
|
||||
const promptUuids = scriptTurns([
|
||||
async function* (options) {
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
]);
|
||||
const sessionId = await newSession();
|
||||
const turn = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "hello"), turn.events);
|
||||
await turn.done;
|
||||
|
||||
expect(turn.log).toEqual([`inserted ${promptUuids[0]}`, "ended end_turn"]);
|
||||
});
|
||||
|
||||
it("reports waiting on a permission request, then resuming", async () => {
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
await options.canUseTool(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
|
||||
);
|
||||
yield {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
]);
|
||||
const sessionId = await newSession();
|
||||
const turn = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
|
||||
await turn.done;
|
||||
|
||||
expect(permissionRequests).toHaveLength(1);
|
||||
expect(turn.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
|
||||
});
|
||||
|
||||
it("awaits the user while any request in the session is open, also one opened before the turn", async () => {
|
||||
const first = recordEvents();
|
||||
const second = recordEvents();
|
||||
let backgroundRequest: Promise<unknown> | undefined;
|
||||
let atStart: string[] = [];
|
||||
let afterOwnAnswer: string[] | undefined;
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
yield result(options.sessionId!);
|
||||
await first.done;
|
||||
// A request that no active turn opened, such as a background task's.
|
||||
backgroundRequest = options.canUseTool(
|
||||
"Bash",
|
||||
{ command: "sleep 60" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
|
||||
);
|
||||
},
|
||||
async function* (options) {
|
||||
atStart = [...second.log];
|
||||
await options.canUseTool(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
|
||||
);
|
||||
afterOwnAnswer = [...second.log];
|
||||
await backgroundRequest;
|
||||
yield {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
]);
|
||||
const sessionId = await newSession();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "start a background task"), first.events);
|
||||
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
|
||||
await agent.startTurn(prompt(sessionId, "list files"), second.events);
|
||||
await vi.waitFor(() => expect(afterOwnAnswer).toBeDefined());
|
||||
backgroundAnswer.resolve();
|
||||
await second.done;
|
||||
|
||||
expect(first.log.slice(1)).toEqual(["ended end_turn"]);
|
||||
// Waiting from insertion, through its own answered request, until the
|
||||
// earlier request is answered too.
|
||||
expect(atStart.slice(1)).toEqual(["awaitingUser"]);
|
||||
expect(afterOwnAnswer!.slice(1)).toEqual(["awaitingUser"]);
|
||||
expect(second.log.slice(1)).toEqual(["awaitingUser", "resumed", "ended end_turn"]);
|
||||
});
|
||||
|
||||
it("ends a queued turn that is cancelled before it is inserted, without inserting it", async () => {
|
||||
const release = Promise.withResolvers<void>();
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
// Claude Code is busy with the first prompt when the cancel arrives.
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "msg_working",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "default",
|
||||
content: [{ type: "text", text: "Working on it" }],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
await release.promise;
|
||||
},
|
||||
]);
|
||||
agent.forceCancelGraceMs = 10;
|
||||
const sessionId = await newSession();
|
||||
const first = recordEvents();
|
||||
const queued = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "first"), first.events);
|
||||
await vi.waitFor(() => expect(first.log).toHaveLength(1));
|
||||
await agent.startTurn(prompt(sessionId, "second"), queued.events);
|
||||
await agent.cancel({ sessionId });
|
||||
await Promise.all([first.done, queued.done]);
|
||||
release.resolve();
|
||||
|
||||
expect(first.log).toEqual([expect.stringMatching(/^inserted /), "ended cancelled"]);
|
||||
expect(queued.log).toEqual(["ended cancelled"]);
|
||||
});
|
||||
|
||||
it("does not insert a turn that a cancel ended while the previous turn was handed off", async () => {
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
// The turn ends with a tool call that never got its result.
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "msg_tool",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "default",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_open", name: "Bash", input: { command: "ls" } },
|
||||
],
|
||||
stop_reason: "tool_use",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
},
|
||||
async function* () {},
|
||||
]);
|
||||
agent.forceCancelGraceMs = 10;
|
||||
// The hand-off to the second turn fails the open tool call; hold that update.
|
||||
const failedToolCall = Promise.withResolvers<void>();
|
||||
const releaseUpdate = Promise.withResolvers<void>();
|
||||
onSessionUpdate = async ({ update }) => {
|
||||
if (update.sessionUpdate === "tool_call_update" && update.status === "failed") {
|
||||
failedToolCall.resolve();
|
||||
await releaseUpdate.promise;
|
||||
}
|
||||
};
|
||||
const sessionId = await newSession();
|
||||
const first = recordEvents();
|
||||
const second = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "first"), first.events);
|
||||
await vi.waitFor(() => expect(first.log).toHaveLength(1));
|
||||
await agent.startTurn(prompt(sessionId, "second"), second.events);
|
||||
await failedToolCall.promise;
|
||||
const cancelling = agent.cancel({ sessionId });
|
||||
await vi.waitFor(() => expect(second.log).toEqual(["ended cancelled"]));
|
||||
releaseUpdate.resolve();
|
||||
await Promise.all([cancelling, first.done]);
|
||||
// The hand-off then activates the second turn in promise continuations,
|
||||
// which all run before a timer.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(second.log).toEqual(["ended cancelled"]);
|
||||
});
|
||||
|
||||
it("fails a queued turn without inserting it when the query ends", async () => {
|
||||
scriptTurns(
|
||||
[
|
||||
async function* (options) {
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
],
|
||||
"end stream",
|
||||
);
|
||||
const sessionId = await newSession();
|
||||
const first = recordEvents();
|
||||
const queued = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "first"), first.events);
|
||||
await agent.startTurn(prompt(sessionId, "second"), queued.events);
|
||||
await Promise.all([first.done, queued.done]);
|
||||
|
||||
expect(first.log).toEqual([expect.stringMatching(/^inserted /), "ended end_turn"]);
|
||||
expect(queued.log).toEqual([expect.stringMatching(/^failed /)]);
|
||||
});
|
||||
|
||||
it("rejects a prompt that it refuses before starting a turn, and reports nothing", async () => {
|
||||
const turn = recordEvents();
|
||||
|
||||
await expect(agent.startTurn(prompt("no-such-session", "hello"), turn.events)).rejects.toThrow(
|
||||
"Session not found",
|
||||
);
|
||||
|
||||
expect(turn.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
67
src/turn-events.ts
Normal file
67
src/turn-events.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* A prompt turn as the adapter understands it, independent of the ACP version.
|
||||
*
|
||||
* The agent reports each turn through {@link TurnEvents}, and each ACP version
|
||||
* maps the events to its own messages. ACP v1 answers `session/prompt` when the
|
||||
* turn ends or fails. ACP v2 answers it when the prompt is inserted, and
|
||||
* reports the rest as session state.
|
||||
*
|
||||
* The agent calls these synchronously while it updates its own state, so a
|
||||
* handler must not throw, nor call back into the agent before it returns.
|
||||
*/
|
||||
export interface TurnEvents {
|
||||
/**
|
||||
* Claude Code took the prompt in as the user message `messageId` of the
|
||||
* conversation. Reported at most once per turn, and never after
|
||||
* {@link ended} or {@link failed}. For a prompt Claude Code echoes, that is
|
||||
* the echo; for a command it does not echo (such as `/compact`), it is the
|
||||
* command's result, after any output the command streamed.
|
||||
*/
|
||||
inserted(messageId: string): void;
|
||||
/**
|
||||
* The turn waits for the user: a permission request or a question is open in
|
||||
* the session. Requests do not say which prompt they are for, so this
|
||||
* includes one opened before the turn was inserted. Only reported after
|
||||
* {@link inserted}.
|
||||
*/
|
||||
awaitingUser(): void;
|
||||
/**
|
||||
* The turn works again: no request is open any more. A turn that ends or
|
||||
* fails while it waits reports that instead.
|
||||
*/
|
||||
resumed(): void;
|
||||
/**
|
||||
* The turn ended. A queued turn that is cancelled before it is inserted
|
||||
* ends too, without {@link inserted}; Claude Code may still run its prompt.
|
||||
*/
|
||||
ended(outcome: TurnOutcome): void;
|
||||
/**
|
||||
* The turn failed. Without {@link inserted} before, Claude Code was not seen
|
||||
* taking the prompt in.
|
||||
*/
|
||||
failed(error: unknown): void;
|
||||
}
|
||||
|
||||
/** Why a turn ended. Every ACP version has these reasons. */
|
||||
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
|
||||
|
||||
/** How a turn ended. */
|
||||
export type TurnOutcome = {
|
||||
stopReason: StopReason;
|
||||
/** The tokens that the main agent loop of the turn used. */
|
||||
usage?: TurnUsage;
|
||||
/**
|
||||
* Extension data for the client, such as the quota breakdown of the turn
|
||||
* and a terminal session failure.
|
||||
*/
|
||||
_meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TurnUsage = {
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
thoughtTokens?: number;
|
||||
cachedReadTokens?: number;
|
||||
cachedWriteTokens?: number;
|
||||
};
|
||||
132
src/v2/agent.ts
Normal file
132
src/v2/agent.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The experimental draft ACP v2 surface of the adapter.
|
||||
*
|
||||
* `ClaudeAcpAgent` speaks ACP v1 types. This surface translates each v2
|
||||
* request into the v1 request that the agent serves, and each v1 message that
|
||||
* the agent sends into its v2 form, so v1 and v2 share one implementation.
|
||||
*/
|
||||
import type {
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
|
||||
import {
|
||||
v1NewSessionRequest,
|
||||
v1ResumeSessionRequest,
|
||||
v1SetSessionConfigOptionRequest,
|
||||
v2ConfigOptions,
|
||||
v2NewSessionResponse,
|
||||
v2ResumeSessionResponse,
|
||||
} from "./session.js";
|
||||
import { v2SessionUpdate } from "./session-update.js";
|
||||
|
||||
/**
|
||||
* The ACP v2 surface, for one connection.
|
||||
*
|
||||
* As in `v1AgentApp`, the agent of the connection is created when the
|
||||
* connection opens, before the connection processes any inbound message.
|
||||
* `onAgent` receives it for the owner of the process (shutdown).
|
||||
*/
|
||||
export function v2AgentApp(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentApp {
|
||||
let agent!: ClaudeAcpAgent;
|
||||
return v2
|
||||
.agent({ name: "claude-code-acp" })
|
||||
.onConnect((connection) => {
|
||||
agent = new ClaudeAcpAgent(new V2ClientConnection(connection.client), logger);
|
||||
onAgent(agent);
|
||||
})
|
||||
.onRequest(v2.methods.agent.initialize, async ({ params }) =>
|
||||
v2InitializeResponse(await agent.initialize(v1InitializeRequest(params))),
|
||||
)
|
||||
.onRequest(v2.methods.agent.auth.login, ({ params }) => agent.authenticate(params))
|
||||
.onRequest(v2.methods.agent.auth.logout, ({ params }) => agent.logout(params))
|
||||
.onRequest(v2.methods.agent.session.new, async ({ params }) =>
|
||||
v2NewSessionResponse(await agent.newSession(v1NewSessionRequest(params))),
|
||||
)
|
||||
.onRequest(v2.methods.agent.session.list, ({ params }) => agent.listSessions(params))
|
||||
.onRequest(v2.methods.agent.session.resume, async ({ params }) =>
|
||||
v2ResumeSessionResponse(await agent.resumeSession(v1ResumeSessionRequest(params))),
|
||||
)
|
||||
.onRequest(v2.methods.agent.session.close, ({ params }) => agent.closeSession(params))
|
||||
.onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params))
|
||||
.onRequest(v2.methods.agent.session.setConfigOption, async ({ params }) => {
|
||||
const { configOptions, ...response } = await agent.setSessionConfigOption(
|
||||
v1SetSessionConfigOptionRequest(params),
|
||||
);
|
||||
return { ...response, configOptions: v2ConfigOptions(configOptions) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link AcpClient} of an ACP v2 connection: it sends the v1 messages of
|
||||
* the agent as v2 messages.
|
||||
*
|
||||
* The v2 surface does not serve prompts yet, so permission requests, which
|
||||
* only a prompt turn makes, still reject.
|
||||
*/
|
||||
class V2ClientConnection implements AcpClient {
|
||||
constructor(private readonly ctx: v2.AgentContext) {}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
if (!isExtensionMethod(method)) {
|
||||
return Promise.reject(new Error(`${method} is not an ACP extension method`));
|
||||
}
|
||||
return this.ctx.notify(method, params);
|
||||
}
|
||||
|
||||
async sessionUpdate({ update, ...notification }: AcpSessionNotification): Promise<void> {
|
||||
const v2Update = v2SessionUpdate(update);
|
||||
if (v2Update) {
|
||||
await this.ctx.notify(v2.methods.client.session.update, {
|
||||
...notification,
|
||||
update: v2Update,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
requestPermission(): Promise<never> {
|
||||
return Promise.reject(
|
||||
new Error("The ACP v2 surface does not serve session/request_permission yet"),
|
||||
);
|
||||
}
|
||||
|
||||
// Elicitation is the same in v1 and v2.
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
return this.ctx.request(
|
||||
v2.methods.client.elicitation.create,
|
||||
// The v1 types accept a property schema with any tag; the v2 types accept
|
||||
// one only in a received value. As on v1, an MCP server's schema is
|
||||
// relayed here unchanged, so this cast does not check it.
|
||||
params as v2.CreateElicitationRequest,
|
||||
{ cancellationSignal: signal },
|
||||
);
|
||||
}
|
||||
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||
return this.ctx.notify(v2.methods.client.elicitation.complete, params);
|
||||
}
|
||||
|
||||
// v2 has no client file system. The agent never calls these on v2, because
|
||||
// `v1InitializeRequest` reports no `fs` capability.
|
||||
readTextFile(): Promise<never> {
|
||||
return Promise.reject(new Error("ACP v2 has no client file system"));
|
||||
}
|
||||
|
||||
writeTextFile(): Promise<never> {
|
||||
return Promise.reject(new Error("ACP v2 has no client file system"));
|
||||
}
|
||||
}
|
||||
|
||||
function isExtensionMethod(method: string): method is v2.ExtensionMethod {
|
||||
return method.startsWith("_");
|
||||
}
|
||||
82
src/v2/initialize.ts
Normal file
82
src/v2/initialize.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* The ACP v2 `initialize` handshake, expressed through the v1 handshake of
|
||||
* `ClaudeAcpAgent`.
|
||||
*
|
||||
* The agent decides its capabilities and auth methods in v1 terms. The v2
|
||||
* request becomes the v1 request that the agent reads, and the v1 response of
|
||||
* the agent becomes the v2 response, so both protocol versions advertise from
|
||||
* one source.
|
||||
*/
|
||||
import {
|
||||
PROTOCOL_VERSION as V1_PROTOCOL_VERSION,
|
||||
type AuthMethod,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
|
||||
/** The v1 `initialize` request that a v2 client means. */
|
||||
export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRequest {
|
||||
const capabilities = request.capabilities;
|
||||
return {
|
||||
protocolVersion: V1_PROTOCOL_VERSION,
|
||||
clientInfo: request.info,
|
||||
clientCapabilities: {
|
||||
// v2 has no client file system and no client terminals: the agent
|
||||
// reads, writes, and runs commands itself.
|
||||
fs: { readTextFile: false, writeTextFile: false },
|
||||
terminal: false,
|
||||
// Every v2 client handles boolean config options and notices; v1 asks
|
||||
// for a marker. With notices, the agent reports advisories as notices
|
||||
// rather than as transcript messages.
|
||||
session: { configOptions: { boolean: {} }, notices: {} },
|
||||
// v1 marks terminal auth support with a boolean, v2 with an object.
|
||||
auth: {
|
||||
terminal: capabilities?.auth?.terminal != null,
|
||||
...(capabilities?.auth?._meta != null ? { _meta: capabilities.auth._meta } : {}),
|
||||
},
|
||||
...(capabilities?.elicitation != null ? { elicitation: capabilities.elicitation } : {}),
|
||||
...(capabilities?._meta != null ? { _meta: capabilities._meta } : {}),
|
||||
},
|
||||
...(request._meta != null ? { _meta: request._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The v2 `initialize` response for the v1 response of the agent.
|
||||
*
|
||||
* It advertises no `session` capability yet. A v2 agent that advertises
|
||||
* `session` must serve the whole session baseline (`session/new`, `list`,
|
||||
* `resume`, `close`, `prompt`, `cancel`, and `update`), and the v2 surface of
|
||||
* the adapter does not serve it yet. Nor does it advertise the unstable
|
||||
* `providers` methods, which the v2 surface does not serve yet either.
|
||||
*/
|
||||
export function v2InitializeResponse(response: InitializeResponse): v2.InitializeResponse {
|
||||
if (!response.agentInfo) {
|
||||
throw new Error("ACP v2 requires agentInfo, and the agent reported none");
|
||||
}
|
||||
const meta = response.agentCapabilities?._meta;
|
||||
return {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: response.agentInfo,
|
||||
// v1 `auth.logout` has no v2 counterpart: a v2 agent that advertises auth
|
||||
// methods must serve `auth/logout`.
|
||||
capabilities: meta != null ? { _meta: meta } : {},
|
||||
authMethods: (response.authMethods ?? []).map(v2AuthMethod),
|
||||
...(response._meta != null ? { _meta: response._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** v2 names the id of a method `methodId`, requires `type`, and lists `env` as name and value pairs. */
|
||||
function v2AuthMethod(method: AuthMethod): v2.AuthMethod {
|
||||
if ("type" in method && method.type === "terminal") {
|
||||
const { id, env, ...terminal } = method;
|
||||
return {
|
||||
...terminal,
|
||||
methodId: id,
|
||||
...(env ? { env: Object.entries(env).map(([name, value]) => ({ name, value })) } : {}),
|
||||
};
|
||||
}
|
||||
const { id, ...agent } = method;
|
||||
return { ...agent, methodId: id, type: "agent" };
|
||||
}
|
||||
38
src/v2/session-update.ts
Normal file
38
src/v2/session-update.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The v2 form of the session updates that `ClaudeAcpAgent` sends as v1
|
||||
* updates.
|
||||
*/
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { v2AvailableCommands, v2ConfigOptions } from "./session.js";
|
||||
|
||||
/**
|
||||
* The v2 form of a v1 session update, or `undefined` when v2 has no
|
||||
* counterpart and the update tells a v2 client nothing new.
|
||||
*
|
||||
* Update kinds that the v2 surface does not translate yet throw, so a gap is
|
||||
* an error rather than a silently missing update.
|
||||
*/
|
||||
export function v2SessionUpdate(
|
||||
update: AcpSessionNotification["update"],
|
||||
): v2.SessionUpdate | undefined {
|
||||
switch (update.sessionUpdate) {
|
||||
case "available_commands_update":
|
||||
return { ...update, availableCommands: v2AvailableCommands(update.availableCommands) };
|
||||
case "config_option_update":
|
||||
return { ...update, configOptions: v2ConfigOptions(update.configOptions) };
|
||||
case "session_info_update":
|
||||
case "usage_update":
|
||||
case "notice":
|
||||
return update;
|
||||
case "current_mode_update":
|
||||
// v2 has no modes: the mode is the `mode` config option. Every mode
|
||||
// change also reaches the client as a `config_option_update` or in the
|
||||
// `configOptions` of a `session/set_config_option` response.
|
||||
return undefined;
|
||||
default:
|
||||
throw new Error(
|
||||
`The ACP v2 surface does not translate ${update.sessionUpdate} session updates yet`,
|
||||
);
|
||||
}
|
||||
}
|
||||
131
src/v2/session.ts
Normal file
131
src/v2/session.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The ACP v2 session lifecycle, expressed through the v1 session methods of
|
||||
* `ClaudeAcpAgent`: v2 requests become the v1 requests that the agent serves,
|
||||
* and its v1 responses become v2 responses.
|
||||
*/
|
||||
import {
|
||||
RequestError,
|
||||
type AvailableCommand,
|
||||
type McpServer,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type ResumeSessionRequest,
|
||||
type ResumeSessionResponse,
|
||||
type SessionConfigOption,
|
||||
type SessionConfigSelectGroup,
|
||||
type SessionConfigSelectOptions,
|
||||
type SetSessionConfigOptionRequest,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
|
||||
export function v1NewSessionRequest(params: v2.NewSessionRequest): NewSessionRequest {
|
||||
const { mcpServers, ...request } = params;
|
||||
// v1 requires the list; v2 treats an omitted list as an empty one.
|
||||
return { ...request, mcpServers: (mcpServers ?? []).map(v1McpServer) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `replayFrom` is rejected until the v2 surface can translate the replayed
|
||||
* history, which needs the message and tool call updates.
|
||||
*/
|
||||
export function v1ResumeSessionRequest(params: v2.ResumeSessionRequest): ResumeSessionRequest {
|
||||
const { replayFrom, mcpServers, ...request } = params;
|
||||
if (replayFrom != null) {
|
||||
throw RequestError.invalidParams(
|
||||
{ replayFrom },
|
||||
"The ACP v2 surface does not replay session history yet",
|
||||
);
|
||||
}
|
||||
return { ...request, ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}) };
|
||||
}
|
||||
|
||||
/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */
|
||||
export function v2NewSessionResponse(response: NewSessionResponse): v2.NewSessionResponse {
|
||||
return { sessionId: response.sessionId, ...v2ResumeSessionResponse(response) };
|
||||
}
|
||||
|
||||
export function v2ResumeSessionResponse(response: ResumeSessionResponse): v2.ResumeSessionResponse {
|
||||
return {
|
||||
...v2ConfigOptionsField(response.configOptions),
|
||||
...(response._meta != null ? { _meta: response._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** v2 tags a select value with `type: "id"`; v1 leaves it untagged. */
|
||||
export function v1SetSessionConfigOptionRequest(
|
||||
params: v2.SetSessionConfigOptionRequest,
|
||||
): SetSessionConfigOptionRequest {
|
||||
const request = {
|
||||
sessionId: params.sessionId,
|
||||
configId: params.configId,
|
||||
...(params._meta != null ? { _meta: params._meta } : {}),
|
||||
};
|
||||
if (v2.SetSessionConfigOptionRequest.isId(params)) {
|
||||
return { ...request, value: params.value };
|
||||
}
|
||||
if (v2.SetSessionConfigOptionRequest.isBoolean(params)) {
|
||||
return { ...request, type: "boolean", value: params.value };
|
||||
}
|
||||
throw RequestError.invalidParams(
|
||||
{ type: params.type },
|
||||
`Config option values of type ${params.type} are not supported`,
|
||||
);
|
||||
}
|
||||
|
||||
/** v2 names the id of an option `configId` and the id of a select group `groupId`. */
|
||||
export function v2ConfigOptions(options: SessionConfigOption[]): v2.SessionConfigOption[] {
|
||||
return options.map((option) => {
|
||||
const { id, ...rest } = option;
|
||||
if (rest.type === "select") {
|
||||
return { ...rest, configId: id, options: v2SelectOptions(rest.options) };
|
||||
}
|
||||
return { ...rest, configId: id };
|
||||
});
|
||||
}
|
||||
|
||||
function v2SelectOptions(options: SessionConfigSelectOptions): v2.SessionConfigSelectOptions {
|
||||
if (!isGrouped(options)) return options;
|
||||
return options.map(({ group, ...selectGroup }) => ({ ...selectGroup, groupId: group }));
|
||||
}
|
||||
|
||||
function isGrouped(options: SessionConfigSelectOptions): options is SessionConfigSelectGroup[] {
|
||||
return options.some((entry) => "group" in entry);
|
||||
}
|
||||
|
||||
/** v2 tags the free-text input of a command with `type: "text"`. */
|
||||
export function v2AvailableCommands(commands: AvailableCommand[]): v2.AvailableCommand[] {
|
||||
return commands.map(({ input, ...command }) => ({
|
||||
...command,
|
||||
...(input != null ? { input: { type: "text", ...input } } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** v1 responses may send `null`; v2 omits the field instead. */
|
||||
function v2ConfigOptionsField(options: SessionConfigOption[] | null | undefined): {
|
||||
configOptions?: v2.SessionConfigOption[];
|
||||
} {
|
||||
return options != null ? { configOptions: v2ConfigOptions(options) } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 requires a `type` on every transport and makes the empty lists optional.
|
||||
* The agent recognizes a v1 stdio server by its missing `type`, so the v2 tag
|
||||
* is dropped. A transport that v1 cannot express is rejected rather than
|
||||
* silently left out of the session.
|
||||
*/
|
||||
function v1McpServer(server: v2.McpServer): McpServer {
|
||||
if (v2.McpServer.isStdio(server)) {
|
||||
const { name, command, args, env, _meta } = server;
|
||||
return { name, command, args: args ?? [], env: env ?? [], ...(_meta != null ? { _meta } : {}) };
|
||||
}
|
||||
if (v2.McpServer.isHttp(server)) {
|
||||
return { ...server, headers: server.headers ?? [] };
|
||||
}
|
||||
if (v2.McpServer.isAcp(server)) {
|
||||
return server;
|
||||
}
|
||||
throw RequestError.invalidParams(
|
||||
{ type: server.type },
|
||||
`MCP servers of type ${server.type} are not supported`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user