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 <benjamin.j.brandt@gmail.com>
This commit is contained in:
Steffen Deusch
2026-03-26 14:57:40 +01:00
committed by GitHub
parent 2bf75c1a9f
commit 23b3073025
2 changed files with 15 additions and 33 deletions

View File

@@ -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;
}

View File

@@ -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({