[BUG] claude-agent-acp: streaming loop hangs on missing session_state_changed:idle (stop button stuck) — UPDATED with proper bounded-drain fix
Affected package: @agentclientprotocol/claude-agent-acp (verified on v0.30.0, v0.31.0, v0.31.3, v0.31.4, v0.33.1)
Affected client: Zed (any recent version using the ACP integration)
Update on the simplereturn-on-result fix in my earlier report: on v0.33+ that fix introduces a render-delay regression — the finalstream_eventchunks arrive afterresult, so returning immediately cuts off the tail of the response (it renders on the next user turn instead of finishing the current one). The proper fix is a bounded post-result drain described below.
Summary
The streaming loop in acp-agent.js only exits the user-turn via session_state_changed: { state: "idle" }. The result message — the actual semantic end of a Claude Code turn — falls through to a bare break; and the loop keeps waiting. When idle lags or never arrives, session.promptRunning stays true indefinitely.
Symptom (in Zed)
- Stop button stays active after Claude finishes responding.
- User has to click Stop manually before sending the next message (clicking Stop forces the stream closed, which finally lets the loop exit).
- Sound alert (Zed's
AcpThreadEvent::Stopped) is delayed by the same mechanism — it fires on stream close, not onresult.
Why the obvious fix isn't enough
The first thing one tries — replacing break; at end of case "result": with return { stopReason, usage: sessionUsage(session) }; — does fix the stuck stop button. But on v0.33+ it introduces a render-delay regression: the final stream_event chunks (the last text deltas of the response) arrive in the queue after result. Returning immediately on result means those chunks never get drained, so the user sees the response truncated until they send the next message (which triggers a UI re-render). Net effect: stop button works, but the last paragraph of every reply is hidden.
Proper fix: bounded post-result drain
Keep the loop alive after result so straggler stream_events drain naturally. Race the iterator's await against a 3-second timeout that's only armed once result has been seen. If idle arrives within the 3s window (common case) → existing case "session_state_changed": path returns cleanly with no render delay. If idle never arrives → timeout fires → force-return → no stuck stop button.
Two diffs against node_modules/@agentclientprotocol/claude-agent-acp/dist/acp-agent.js:
(1) At the top of the streaming while (true) loop, set up the drain-timeout state and race the await:
// [PATCH] Bounded post-result drain.
let resultSeenAt = 0;
const POST_RESULT_DRAIN_MS = 3000;
try {
while (true) {
const messageP = session.query.next();
let result;
if (resultSeenAt > 0) {
const remaining = Math.max(50, POST_RESULT_DRAIN_MS - (Date.now() - resultSeenAt));
const timeoutP = new Promise((resolve) => setTimeout(() => resolve({ __acpTimeout: true }), remaining));
result = await Promise.race([messageP, timeoutP]);
} else {
result = await messageP;
}
if (result && result.__acpTimeout) {
return { stopReason, usage: sessionUsage(session) };
}
const { value: message, done } = result;
// ...rest of loop unchanged
(2) Inside case "result": — after the existing switch on message.subtype, before the break; — arm the timeout:
// [PATCH] Mark result-seen for drain-timeout race. Only the user-turn's
// result arms it; task-notification followups don't (they're autonomous;
// the user turn can keep running waiting for its own result).
if (!isTaskNotification && resultSeenAt === 0) {
resultSeenAt = Date.now();
}
break;
(The !isTaskNotification guard exists in v0.33+; on v0.31 and earlier, drop the guard.)
Why this shape
- Common case (
idlearrives within 3s): the existingcase "session_state_changed":return-path runs first. Drain completes naturally. No behavior change for happy-path turns. - Failure case (
idlenever arrives): timeout fires after the drain window. Loop returns the same shape it would onidle. Stop button releases. No truncated response. - Task notifications (v0.33+): the
!isTaskNotificationguard prevents an autonomous followupresultfrom arming the timeout. Only the user-turn's ownresultdoes.
Repro
- Zed + Claude Code ACP agent.
- Send any prompt that takes more than a few seconds (especially one that launches background tasks).
- Observe: stop button stays active after the response finishes streaming; sound alert delayed; with the simple
return-on-result fix, observe truncated final paragraph instead.
Why this matters
Every Zed + Claude Code user hits this. It reads like a Zed bug to the user ("Zed's stop button is broken") but the fix lives in the Anthropic-published bridge. The simple fix is tempting but introduces a regression on current versions; the bounded-drain shape is what works in production.
Not a duplicae.
Not a duplicate of #50665. That issue is in the Claude Code TUI (area:tui, Windows-specific) and involves the inability to interrupt while Claude is executing tool calls — a different code path. This issue is specifically in @agentclientprotocol/claude-agent-acp/dist/acp-agent.js, where the streaming loop hangs after Claude finishes (waiting for a session_state_changed: idle that doesn't arrive). The fix is in the ACP bridge package, not Claude Code itself. Affects every Zed-via-ACP user. Diff and proper bounded-drain fix are in the issue body above.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗