Compare commits

...

3 Commits

Author SHA1 Message Date
acp-release-bot[bot]
49d73b2ad3 chore(main): release 0.41.0 (#741)
🤖 I have created a release *beep* *boop*
---


##
[0.41.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.40.0...v0.41.0)
(2026-06-04)


### Features

* **deps:** upgrade to @anthropic-ai/claude-agent-sdk@0.3.162
([#740](https://github.com/agentclientprotocol/claude-agent-acp/issues/740))
([add7e31](add7e31704))


### Bug Fixes

* Force cancellation when SDK query hangs
([#742](https://github.com/agentclientprotocol/claude-agent-acp/issues/742))
([cffea4b](cffea4be28)),
closes
[#680](https://github.com/agentclientprotocol/claude-agent-acp/issues/680)

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-06-04 14:16:42 +02:00
Ben Brandt
cffea4be28 fix: Force cancellation when SDK query hangs (#742)
Closes #680
2026-06-04 11:47:13 +00:00
dependabot[bot]
add7e31704 feat(deps): upgrade to @anthropic-ai/claude-agent-sdk@0.3.162 (#740)
Bumps the minor group with 9 updates:

| Package | From | To |
| --- | --- | --- |
|
[@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript)
| `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-darwin-arm64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-darwin-x64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-linux-arm64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-linux-arm64-musl | `0.3.160` |
`0.3.162` |
| @anthropic-ai/claude-agent-sdk-linux-x64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-linux-x64-musl | `0.3.160` | `0.3.162`
|
| @anthropic-ai/claude-agent-sdk-win32-arm64 | `0.3.160` | `0.3.162` |
| @anthropic-ai/claude-agent-sdk-win32-x64 | `0.3.160` | `0.3.162` |

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.160 to 0.3.162
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/releases">@​anthropic-ai/claude-agent-sdk's
releases</a>.</em></p>
<blockquote>
<h2>v0.3.162</h2>
<h2>What's changed</h2>
<ul>
<li>Refusal error messages now carry <code>stop_reason:
&quot;refusal&quot;</code> and <code>stop_details</code> on the
assistant message and in session transcripts, so SDK consumers can
detect refusals without text-matching the error content</li>
<li>Agent SDK sessions on native builds now default to fast embedded
<code>find</code>/<code>grep</code> search in Bash, matching the
interactive CLI, instead of always registering the dedicated Grep/Glob
tools. To keep the dedicated tools (e.g. to intercept searches via
<code>canUseTool</code> or hooks), name them in the <code>tools</code>
option or reference them in <code>allowedTools</code></li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.162
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.162
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.162
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.162
</code></pre>
<h2>v0.3.161</h2>
<h2>What's changed</h2>
<ul>
<li>The <code>initialize</code> control request is now idempotent: a
second <code>initialize</code> returns the same success payload instead
of an <code>Already initialized</code> error.
<code>ControlResponse</code> gains an optional
<code>pending_permission_requests</code> field, mirroring
<code>ControlErrorResponse</code></li>
<li><code>applyFlagSettings</code> now live-applies <code>agent</code>
changes: switching the active agent (or passing <code>null</code> to
reset) takes effect on the next turn in a running session</li>
</ul>
<h2>Update</h2>
<pre lang="sh"><code>npm install @anthropic-ai/claude-agent-sdk@0.3.161
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.161
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.161
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.161
</code></pre>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md">@​anthropic-ai/claude-agent-sdk's
changelog</a>.</em></p>
<blockquote>
<h2>0.3.162</h2>
<ul>
<li>Refusal error messages now carry <code>stop_reason:
&quot;refusal&quot;</code> and <code>stop_details</code> on the
assistant message and in session transcripts, so SDK consumers can
detect refusals without text-matching the error content</li>
<li>Agent SDK sessions on native builds now default to fast embedded
<code>find</code>/<code>grep</code> search in Bash, matching the
interactive CLI, instead of always registering the dedicated Grep/Glob
tools. To keep the dedicated tools (e.g. to intercept searches via
<code>canUseTool</code> or hooks), name them in the <code>tools</code>
option or reference them in <code>allowedTools</code></li>
</ul>
<h2>0.3.161</h2>
<ul>
<li>The <code>initialize</code> control request is now idempotent: a
second <code>initialize</code> returns the same success payload instead
of an <code>Already initialized</code> error.
<code>ControlResponse</code> gains an optional
<code>pending_permission_requests</code> field, mirroring
<code>ControlErrorResponse</code></li>
<li><code>applyFlagSettings</code> now live-applies <code>agent</code>
changes: switching the active agent (or passing <code>null</code> to
reset) takes effect on the next turn in a running session</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="e9f26a0e02"><code>e9f26a0</code></a>
chore: Update CHANGELOG.md</li>
<li><a
href="2f67c51150"><code>2f67c51</code></a>
chore: Update CHANGELOG.md</li>
<li>See full diff in <a
href="https://github.com/anthropics/claude-agent-sdk-typescript/compare/v0.3.160...v0.3.162">compare
view</a></li>
</ul>
</details>
<br />

Updates `@anthropic-ai/claude-agent-sdk-darwin-arm64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-darwin-x64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-linux-arm64-musl` from 0.3.160
to 0.3.162

Updates `@anthropic-ai/claude-agent-sdk-linux-x64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-linux-x64-musl` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-win32-arm64` from 0.3.160 to
0.3.162

Updates `@anthropic-ai/claude-agent-sdk-win32-x64` from 0.3.160 to
0.3.162


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-06-04 12:45:17 +02:00
6 changed files with 371 additions and 42 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.40.0"
".": "0.41.0"
}

View File

@@ -1,5 +1,17 @@
# Changelog
## [0.41.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.40.0...v0.41.0) (2026-06-04)
### Features
* **deps:** upgrade to @anthropic-ai/claude-agent-sdk@0.3.162 ([#740](https://github.com/agentclientprotocol/claude-agent-acp/issues/740)) ([add7e31](https://github.com/agentclientprotocol/claude-agent-acp/commit/add7e31704581aa70febb6341ca0e8cee5d93b43))
### Bug Fixes
* Force cancellation when SDK query hangs ([#742](https://github.com/agentclientprotocol/claude-agent-acp/issues/742)) ([cffea4b](https://github.com/agentclientprotocol/claude-agent-acp/commit/cffea4be28e6def91027110433a99e80b1be8a0c)), closes [#680](https://github.com/agentclientprotocol/claude-agent-acp/issues/680)
## [0.40.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.39.0...v0.40.0) (2026-06-02)

76
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.40.0",
"version": "0.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.40.0",
"version": "0.41.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.24.0",
"@anthropic-ai/claude-agent-sdk": "0.3.160",
"@anthropic-ai/claude-agent-sdk": "0.3.162",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
@@ -41,22 +41,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.160.tgz",
"integrity": "sha512-kJjgIkb09TIhYXbOEngQV+iERhYMbxQwjciXEfVYP/Zo27tacdWYS5MaIwzKxmPW+Yd1VL2BcvCIo1ES4F1tow==",
"version": "0.3.162",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.162.tgz",
"integrity": "sha512-piAlpc1h6FUMCNU+bnYNNLX1lOgMlFnqZmIhn6Myv48MaNRaecFaZEuVLY+UxV0kjGiQFYHBLR4fk/rRwi2SAA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.160",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.160",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.160",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.160"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.162",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.162",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.162",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.162",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.162",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.162",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.162",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.162"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -65,9 +65,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.160.tgz",
"integrity": "sha512-lGd7aySWuFO9MmxXj8plnRobUPVSXztk+EYdews2sDiC28483SLeJ4Ft086GYCaCb/ILeYAsK6aKo25R7Gl9QA==",
"version": "0.3.162",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.162.tgz",
"integrity": "sha512-hafbfEtDeYko1rYCgIBAQbYnFXzd/hHf3IcoaD8mmlCQOAQhKA8gT/RPdLuuzQHdOjEgqDGTNOU+IjwL+msYcw==",
"cpu": [
"arm64"
],
@@ -78,9 +78,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.160.tgz",
"integrity": "sha512-ck9r+zCg8mvOnEMZhwWSsizkuAW1evB+UzRBFqOlTQCij7KJK5Nw2YRTVVdp4knesM7/q82/cGGZgFtAVxq6IQ==",
"version": "0.3.162",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.162.tgz",
"integrity": "sha512-BNg2Mh/4zc2Jsgpq7Mj8+UH8iJ9xzHS/0CmusBMik0H2Bn7Hra5R/f+csAfZOzSflQl4CNQdGOB2bir7d2n8Ww==",
"cpu": [
"x64"
],
@@ -91,9 +91,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.160.tgz",
"integrity": "sha512-gzC5CGG2jiSoFEfj4MPc4+QkfQbvGKj8pKN6kpH9TLJeexKnoPNzDEGwy0T/+VGGn2zQwOxIl9sh0QBb+v01DA==",
"version": "0.3.162",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.162.tgz",
"integrity": "sha512-zCXYSimaXWQKZASfDJkoKXQr//toYDGIi16wKDh02Rqcr4mqFi9f5SBw/UCyimkGyYkNx3e+bmC+o/tFrLSTWw==",
"cpu": [
"arm64"
],
@@ -104,9 +104,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.160.tgz",
"integrity": "sha512-17rq9mNH26jndqIYKkCwNoZrThj/0wJosiixuGEGPEFSzjxv4LDNfLp+Rnpt553HvLBpW8a0IWhYyOyruvpsZQ==",
"version": "0.3.162",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.162.tgz",
"integrity": "sha512-Jr4cyfqzb5V2+p/1PynIfGROOI0JNtV3vBMAgckAdtDzpIFk4mV2T8936tsZzgZr04y40YH1HlQpnJDr92I+Fw==",
"cpu": [
"arm64"
],
@@ -117,9 +117,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.160.tgz",
"integrity": "sha512-WvAuHDekEwTr8O192WOVrCpDHO+cH2a1Dgm5DQAobiPMZxD64mQ9Bbo8otlSnuN3ziPGv/RbouyJHf6JFrotMA==",
"version": "0.3.162",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.162.tgz",
"integrity": "sha512-FO2+zDuSTsZ/5MqxIwExLxG0c2auA5wO6iICwZdUOWtboC6yU2AEgp4mzF0jbe1UOP0J27uODFpvz7uRpWipkg==",
"cpu": [
"x64"
],
@@ -130,9 +130,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.160.tgz",
"integrity": "sha512-85FfW+Heaop8U2Xq5LxIZzunVYcua8X3XSmcWgUX3DXFiAPMfXKyKwJOUDJC5PrVqlWduDZA2K30yTl2JvapcQ==",
"version": "0.3.162",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.162.tgz",
"integrity": "sha512-gW9Gpk7W3w3zGFHBDyY8uer/PE6T0pB+emN1aafZAomfseIH1ixJ6ya5Fw2cIS/K0/4oR2pvu4AprlbRBtr45Q==",
"cpu": [
"x64"
],
@@ -143,9 +143,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.160.tgz",
"integrity": "sha512-CEpNzmazlgAl/hCaY3Hc12QxuMdvG+kOhi0V0Cp706p5zUiHUojX/e0ahqURn4yBfTddLoAlGAq0VzlMriwsgA==",
"version": "0.3.162",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.162.tgz",
"integrity": "sha512-TZQifFDBdhzt1u6wbbpQ2AZcRkhNVYx1iZVfydAbs3L7ZMK264LjRPytBK4n4S413Is1XyLHbGMvEUNA3N+Tng==",
"cpu": [
"arm64"
],
@@ -156,9 +156,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.160",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.160.tgz",
"integrity": "sha512-EVjDyTUem5xAjbPfYu/uBAeCK/qrZNjociW9u8wMvwxF1f0mmXTH+CdCVYeQ78RZBTG5SxV+OXAsgRX/OeWxOQ==",
"version": "0.3.162",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.162.tgz",
"integrity": "sha512-8ZYDgNxkGp47xwpcEZ6/qUXd4BByA8hTnNf4fJD6+P1wWi7Ofxc/d5jwXSYwajYvBvbbktQDthmGzjtoK3lXUw==",
"cpu": [
"x64"
],

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.40.0",
"version": "0.41.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -61,7 +61,7 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.24.0",
"@anthropic-ai/claude-agent-sdk": "0.3.160",
"@anthropic-ai/claude-agent-sdk": "0.3.162",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {

View File

@@ -57,6 +57,7 @@ import {
query,
Settings,
SDKAssistantMessageError,
SDKMessage,
SDKMessageOrigin,
SDKPartialAssistantMessage,
SDKUserMessage,
@@ -134,6 +135,17 @@ const ZERO_USAGE = Object.freeze({
const DEFAULT_CONTEXT_WINDOW = 200000;
/** Floor after `session/cancel` before the adapter forces the active prompt
* loop to return "cancelled". `query.interrupt()` normally makes the SDK
* yield a trailing idle within milliseconds, and the loop returns through its
* usual path — so this timer is armed and cleared, never fired, on healthy
* cancels. It only trips when the SDK is genuinely wedged (e.g. a
* `TaskOutput { block: true }` poll against a hung background task — issue
* #680) and never yields. The value is deliberately loose: it's an
* "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
* pre-empt a slow-but-healthy interrupt. */
const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
/** Internal model-selection state. Mirrors the shape the ACP SDK exposed as
* `SessionModelState` before model selection moved entirely into
* `SessionConfigOption` (category "model"). Retained internally to track the
@@ -161,6 +173,17 @@ type Session = {
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
nextPendingOrder: number;
abortController: AbortController;
/** Per-turn signal the active prompt loop races `query.next()` against.
* Aborted by cancel() (after a grace period) to force the loop to return
* "cancelled" when the SDK is wedged and `query.next()` never yields again
* (issue #680). Distinct from `abortController`: this only wakes the loop;
* it does NOT touch the SDK query/subprocess. Undefined when no prompt is
* actively consuming the query. */
cancelController?: AbortController;
/** Pending grace-period timer that aborts `cancelController`. Cleared when
* the loop returns normally so the backstop never fires after a clean
* cancel. */
forceCancelTimer?: ReturnType<typeof setTimeout>;
emitRawSDKMessages: boolean | SDKMessageFilter[];
/** Context window size of the last top-level assistant model, carried across
* prompts so mid-stream usage_update notifications report a correct `size`
@@ -529,6 +552,10 @@ export class ClaudeAcpAgent implements Agent {
clientCapabilities?: ClientCapabilities;
logger: Logger;
gatewayAuthRequest?: GatewayAuthRequest;
/** Grace period before a `session/cancel` forces a wedged prompt loop to
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
* tests can shrink it. */
forceCancelGraceMs: number = DEFAULT_FORCE_CANCEL_GRACE_MS;
constructor(client: AgentSideConnection, logger?: Logger) {
this.sessions = {};
@@ -790,6 +817,12 @@ export class ClaudeAcpAgent implements Agent {
// forward it to clients as structured `data`, sparing them from
// pattern-matching on the human-readable message text.
let lastAssistantError: SDKAssistantMessageError | undefined;
// When a streaming classifier refuses a turn, the assistant message carries
// stop_reason "refusal" and structured stop_details. We capture the
// human-readable explanation here so the terminal `result` can surface it
// to the user (the refused assistant message itself usually has no content)
// and report ACP's dedicated `refusal` stop reason.
let lastRefusalExplanation: string | null = null;
// Tracks whether we're inside a compaction. The SDK emits the terminal
// `status` (compact_result success/failed) twice for a single failed
// compaction, and the two messages are indistinguishable — so we report the
@@ -828,9 +861,27 @@ export class ClaudeAcpAgent implements Agent {
let errored = false;
let stopReason: StopReason = "end_turn";
// Wake-up channel so cancel() can force this loop to return "cancelled"
// even when query.next() is wedged and never yields again (issue #680).
const cancelController = new AbortController();
session.cancelController = cancelController;
const cancelled = new Promise<void>((resolve) => {
cancelController.signal.addEventListener("abort", () => resolve(), { once: true });
});
try {
while (true) {
const { value: message, done } = await session.query.next();
const nextMessage = session.query.next();
const next = await Promise.race([nextMessage, cancelled]);
if (cancelController.signal.aborted) {
// The SDK never yielded after interrupt() (e.g. a wedged TaskOutput
// block). Abandon the in-flight next() — swallowing any later
// rejection so it can't surface as an unhandled rejection — and
// honor the cancel per the ACP contract.
void nextMessage.catch(() => {});
return { stopReason: "cancelled" };
}
const { value: message, done } = next as IteratorResult<SDKMessage, void>;
if (done || !message) {
if (session.cancelled) {
@@ -976,6 +1027,22 @@ export class ClaudeAcpAgent implements Agent {
});
break;
}
case "commands_changed": {
// Push the full slash-command list after a mid-session change
// (e.g. skills discovered dynamically as the agent works in a
// subdirectory). The client should REPLACE its cached command
// list with this payload: supportedCommands() is captured once
// at initialize and never reflects mid-session changes, so we
// forward message.commands directly rather than re-querying.
await this.client.sessionUpdate({
sessionId: message.session_id,
update: {
sessionUpdate: "available_commands_update",
availableCommands: getAvailableSlashCommands(message.commands),
},
});
break;
}
case "hook_started":
case "hook_progress":
case "hook_response":
@@ -1048,6 +1115,26 @@ export class ClaudeAcpAgent implements Agent {
break;
}
// A refusal can arrive on any result subtype (and may even set
// is_error), so handle it before the subtype switch — otherwise the
// is_error throw below would surface it as an internal error. The
// refused assistant message carries no visible content, so surface
// the classifier's explanation (when available) and report ACP's
// dedicated `refusal` stop reason.
if (message.stop_reason === "refusal" && !isTaskNotification) {
if (lastRefusalExplanation) {
await this.client.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: lastRefusalExplanation },
},
});
}
stopReason = "refusal";
break;
}
switch (message.subtype) {
case "success": {
if (message.result.includes("Please run /login")) {
@@ -1228,6 +1315,9 @@ export class ClaudeAcpAgent implements Agent {
if (message.error) {
lastAssistantError = message.error;
}
if (message.message.stop_reason === "refusal") {
lastRefusalExplanation = message.message.stop_details?.explanation ?? null;
}
}
// Strip <command-*>/<local-command-stdout> markers and render any
@@ -1385,6 +1475,16 @@ export class ClaudeAcpAgent implements Agent {
}
throw error;
} finally {
// The loop is returning — interrupt() succeeded or the prompt finished
// — so disarm the force-cancel backstop and release the wake-up channel
// (only if we still own it; a handoff installs the next prompt's).
if (session.forceCancelTimer) {
clearTimeout(session.forceCancelTimer);
session.forceCancelTimer = undefined;
}
if (session.cancelController === cancelController) {
session.cancelController = undefined;
}
if (!handedOff) {
session.promptRunning = false;
if (errored) {
@@ -1422,6 +1522,33 @@ export class ClaudeAcpAgent implements Agent {
pending.resolve(true);
}
session.pendingMessages.clear();
// Arm a backstop before interrupting: if a prompt is actively consuming
// the query and interrupt() doesn't make the SDK yield (e.g. a wedged
// TaskOutput block — issue #680), force the loop to return "cancelled"
// after the floor elapses so the pending session/prompt still resolves per
// the ACP cancellation contract instead of hanging forever. The loop's
// `finally` clears this timer when interrupt() works and it returns through
// the normal idle path, so on healthy cancels it is armed but never fires.
//
// Arm at most once per turn: the floor is an absolute ceiling from the
// first cancel, so a client that re-sends cancel (each call still retries
// interrupt() below) can't keep pushing the deadline out.
if (
session.promptRunning &&
session.cancelController &&
!session.cancelController.signal.aborted &&
!session.forceCancelTimer
) {
const cancelController = session.cancelController;
session.forceCancelTimer = setTimeout(() => {
this.logger.error(
`Session ${params.sessionId}: cancel floor elapsed without the SDK yielding; forcing "cancelled". The underlying query may still be wedged — a new session may be required.`,
);
cancelController.abort();
}, this.forceCancelGraceMs);
}
await session.query.interrupt();
}
@@ -1433,6 +1560,18 @@ export class ClaudeAcpAgent implements Agent {
return;
}
await this.cancel({ sessionId });
// cancel() arms the force-cancel floor and interrupts gracefully, but a
// wedged prompt loop only wakes when `cancelController` aborts — closing
// the query/abortController below doesn't touch it. Since we're tearing the
// session down anyway, wake the loop now so the in-flight prompt() resolves
// immediately instead of after the floor, and clear the timer so it can't
// outlive the deleted session (it isn't unref'd and would otherwise keep
// the event loop alive until it fires).
if (session.forceCancelTimer) {
clearTimeout(session.forceCancelTimer);
session.forceCancelTimer = undefined;
}
session.cancelController?.abort();
session.settingsManager.dispose();
session.abortController.abort();
session.query.close();

View File

@@ -3877,6 +3877,184 @@ describe("post-error recovery", () => {
});
});
describe("session/cancel wedge recovery (issue #680)", () => {
function createMockAgent() {
const mockClient = {
sessionUpdate: async () => {},
} as unknown as AgentSideConnection;
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
}
// Generator that replays the prompt's user message and then blocks forever,
// simulating the SDK wedged in a `TaskOutput { block: true }` poll against a
// hung background task. `interrupt()` is a no-op — it does NOT unblock the
// generator, matching the SDK behavior described in the issue.
function injectWedgedSession(agent: ClaudeAcpAgent, opts: { interruptUnblocks?: boolean } = {}) {
const input = new Pushable<any>();
const interrupt = vi.fn(async () => {});
const close = vi.fn();
// A promise the wedged poll awaits. When `interruptUnblocks` is set, the
// mocked interrupt() resolves it so the generator yields a trailing idle —
// the normal, healthy interrupt path.
let releaseBlock!: () => void;
const blocked = new Promise<void>((resolve) => {
releaseBlock = resolve;
});
if (opts.interruptUnblocks) {
interrupt.mockImplementation(async () => {
releaseBlock();
});
}
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const first = await iter.next();
if (!first.done && first.value) {
yield {
type: "user",
message: first.value.message,
parent_tool_use_id: null,
uuid: first.value.uuid,
session_id: "test-session",
isReplay: true,
};
}
// Wedge: never yield again unless interrupt() releases us.
await blocked;
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
const gen = Object.assign(messageGenerator(), { interrupt, close });
agent.sessions["test-session"] = {
query: gen as any,
input,
cancelled: false,
cwd: "/test",
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
abortController: new AbortController(),
emitRawSDKMessages: false,
contextWindowSize: 200000,
taskState: new Map(),
};
return { interrupt };
}
it("resolves the pending prompt with cancelled when the SDK never yields after interrupt", async () => {
const agent = createMockAgent();
// Shrink the grace period so the test doesn't wait the production default.
agent.forceCancelGraceMs = 20;
const { interrupt } = injectWedgedSession(agent);
const promptPromise = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "run cargo test" }],
});
// Let the loop consume the replay and block on the wedged query.next().
await new Promise((r) => setTimeout(r, 5));
await agent.cancel({ sessionId: "test-session" });
const response = await promptPromise;
expect(response.stopReason).toBe("cancelled");
expect(interrupt).toHaveBeenCalled();
});
it("returns cancelled through the normal idle path without waiting the grace period when interrupt works", async () => {
const agent = createMockAgent();
// Large grace so that if the test ever falls through to the backstop it
// would hang past the test timeout instead of passing by accident.
agent.forceCancelGraceMs = 60_000;
const { interrupt } = injectWedgedSession(agent, { interruptUnblocks: true });
const promptPromise = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "run cargo test" }],
});
await new Promise((r) => setTimeout(r, 5));
await agent.cancel({ sessionId: "test-session" });
const response = await promptPromise;
expect(response.stopReason).toBe("cancelled");
expect(interrupt).toHaveBeenCalled();
// Backstop timer must have been cleared so it can't fire later.
expect(agent.sessions["test-session"].forceCancelTimer).toBeUndefined();
});
it("does not arm the backstop when no prompt is running", async () => {
const agent = createMockAgent();
injectWedgedSession(agent);
await agent.cancel({ sessionId: "test-session" });
const session = agent.sessions["test-session"];
expect(session.cancelled).toBe(true);
expect(session.forceCancelTimer).toBeUndefined();
});
it("does not reset the force-cancel floor on repeated cancels", async () => {
const agent = createMockAgent();
// Long floor so the timer handle stays observable across both cancels.
agent.forceCancelGraceMs = 60_000;
injectWedgedSession(agent);
const promptPromise = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "run cargo test" }],
});
await new Promise((r) => setTimeout(r, 5));
await agent.cancel({ sessionId: "test-session" });
const firstTimer = agent.sessions["test-session"].forceCancelTimer;
expect(firstTimer).toBeDefined();
await agent.cancel({ sessionId: "test-session" });
// Same handle: the second cancel did not clear-and-rearm (which would push
// the floor out). The deadline stays anchored to the first cancel.
expect(agent.sessions["test-session"].forceCancelTimer).toBe(firstTimer);
// Clean up the wedged prompt + long timer.
await agent.closeSession({ sessionId: "test-session" });
await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" });
});
it("resolves an in-flight wedged prompt immediately when the session is closed", async () => {
const agent = createMockAgent();
// Large floor: if closeSession relied on the force-cancel timer this would
// hang past the test timeout. Teardown must wake the loop via
// cancelController instead.
agent.forceCancelGraceMs = 60_000;
injectWedgedSession(agent);
const promptPromise = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "run cargo test" }],
});
await new Promise((r) => setTimeout(r, 5));
await agent.closeSession({ sessionId: "test-session" });
await expect(promptPromise).resolves.toEqual({ stopReason: "cancelled" });
expect(agent.sessions["test-session"]).toBeUndefined();
});
});
describe("streamEventToAcpNotifications", () => {
it("treats `ping` keep-alive events as no-ops without logging to stderr", () => {
const errors: unknown[][] = [];