mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-04 00:12:14 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49d73b2ad3 | ||
|
|
cffea4be28 | ||
|
|
add7e31704 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.40.0"
|
||||
".": "0.41.0"
|
||||
}
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
76
package-lock.json
generated
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
141
src/acp-agent.ts
141
src/acp-agent.ts
@@ -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();
|
||||
|
||||
@@ -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[][] = [];
|
||||
|
||||
Reference in New Issue
Block a user