From 23b30730253752f0bc4e30b619a6236f16fafdb9 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Thu, 26 Mar 2026 14:57:40 +0100 Subject: [PATCH] fix: Cleanup based on new idle state #463 (#480) #463 introduced a problem with cancelling prompts. Because cancel exited the loop early, the idle session state message was not consumed. Then, in the next prompt, we'd immediately get the idle state and assume it's done. Instead, we always wait for the idle state (and still finish early when detecting a queued prompt). Note that this is based on my tests that showed Claude only sending the idle state when all background agents are done. --------- Co-authored-by: Ben Brandt --- src/acp-agent.ts | 45 ++++++++++--------------------------- src/tests/acp-agent.test.ts | 3 +++ 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 7766ab70..b32f0362 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -40,6 +40,7 @@ import { TerminalOutputResponse, WriteTextFileRequest, WriteTextFileResponse, + StopReason, } from "@agentclientprotocol/sdk"; import { CanUseTool, @@ -473,17 +474,12 @@ export class ClaudeAcpAgent implements Agent { const promptUuid = randomUUID(); userMessage.uuid = promptUuid; - let promptReplayed = false; - // These local-only commands return a result without replaying the user // message. Mark promptReplayed=true so their result isn't consumed as a // background task result. const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : ""; const isLocalOnlyCommand = firstText.startsWith("/") && LOCAL_ONLY_COMMANDS.has(firstText.split(" ", 1)[0]); - if (isLocalOnlyCommand) { - promptReplayed = true; - } if (session.promptRunning) { session.input.push(userMessage); @@ -494,15 +490,13 @@ export class ClaudeAcpAgent implements Agent { if (cancelled) { return { stopReason: "cancelled" }; } - // The replay resolved the promise, mark in this loop too, - // so we don't treat the next result as a background task's result. - promptReplayed = true; } else { session.input.push(userMessage); } session.promptRunning = true; let handedOff = false; + let stopReason: StopReason = "end_turn"; try { while (true) { @@ -543,7 +537,6 @@ export class ClaudeAcpAgent implements Agent { content: { type: "text", text: "\n\nCompacting completed." }, }, }); - promptReplayed = true; break; } case "local_command_output": { @@ -554,12 +547,11 @@ export class ClaudeAcpAgent implements Agent { content: { type: "text", text: message.content }, }, }); - promptReplayed = true; break; } case "session_state_changed": { if (message.state === "idle") { - return { stopReason: "end_turn", usage: sessionUsage(session) }; + return { stopReason, usage: sessionUsage(session) }; } break; } @@ -607,32 +599,19 @@ export class ClaudeAcpAgent implements Agent { }); } - // Check cancelled before promptReplayed — when a cancel races - // with the first result, promptReplayed is still false and the - // result would be consumed as a background task, blocking the - // loop forever (see #442). if (session.cancelled) { - return { stopReason: "cancelled" }; - } - - if (!promptReplayed) { - // This result is from a background task that finished after - // the previous prompt loop ended. Consume it and continue - // waiting for our own prompt's result. - this.logger.log(`Session ${params.sessionId}: consuming background task result`); + stopReason = "cancelled"; break; } - // Build the usage response - const usage = sessionUsage(session); - switch (message.subtype) { case "success": { if (message.result.includes("Please run /login")) { throw RequestError.authRequired(); } if (message.stop_reason === "max_tokens") { - return { stopReason: "max_tokens", usage }; + stopReason = "max_tokens"; + break; } if (message.is_error) { throw RequestError.internalError(undefined, message.result); @@ -655,7 +634,8 @@ export class ClaudeAcpAgent implements Agent { } case "error_during_execution": { if (message.stop_reason === "max_tokens") { - return { stopReason: "max_tokens", usage }; + stopReason = "max_tokens"; + break; } if (message.is_error) { throw RequestError.internalError( @@ -663,7 +643,8 @@ export class ClaudeAcpAgent implements Agent { message.errors.join(", ") || message.subtype, ); } - return { stopReason: "end_turn", usage: sessionUsage(session) }; + stopReason = "end_turn"; + break; } case "error_max_budget_usd": case "error_max_turns": @@ -674,7 +655,8 @@ export class ClaudeAcpAgent implements Agent { message.errors.join(", ") || message.subtype, ); } - return { stopReason: "max_turn_requests", usage }; + stopReason = "max_turn_requests"; + break; default: unreachable(message, this.logger); break; @@ -706,9 +688,6 @@ export class ClaudeAcpAgent implements Agent { // Check for prompt replay if (message.type === "user" && "uuid" in message && message.uuid) { if (message.uuid === promptUuid) { - // Our own prompt was replayed back — we're now processing - // our prompt's response (not a background task's). - promptReplayed = true; break; } diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index cc041839..2c39a25e 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1355,6 +1355,7 @@ describe("stop reason propagation", () => { const agent = createMockAgent(); injectSession(agent, [ createResultMessage({ subtype: "success", stop_reason: "max_tokens", is_error: false }), + { type: "system", subtype: "session_state_changed", state: "idle" }, ]); const response = await agent.prompt({ @@ -1374,6 +1375,7 @@ describe("stop reason propagation", () => { is_error: true, result: "Token limit reached", }), + { type: "system", subtype: "session_state_changed", state: "idle" }, ]); const response = await agent.prompt({ @@ -1393,6 +1395,7 @@ describe("stop reason propagation", () => { is_error: true, errors: ["some error"], }), + { type: "system", subtype: "session_state_changed", state: "idle" }, ]); const response = await agent.prompt({