claude-code - 💡(How to fix) Fix [BUG] claude-agent-acp: streaming loop hangs on missing session_state_changed:idle (stop button stuck) — UPDATED with proper bounded-drain fix [1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#57919Fetched 2026-05-11 03:21:57
View on GitHub
Comments
0
Participants
1
Timeline
2
Reactions
0
Timeline (top)
labeled ×2

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.

Root Cause

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.

Fix Action

Fix / Workaround

// [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
// [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;

Code Example

// [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

---

// [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;
RAW_BUFFERClick to expand / collapse

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 simple return-on-result fix in my earlier report: on v0.33+ that fix introduces a render-delay regression — the final stream_event chunks arrive after result, 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 on result.

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 (idle arrives within 3s): the existing case "session_state_changed": return-path runs first. Drain completes naturally. No behavior change for happy-path turns.
  • Failure case (idle never arrives): timeout fires after the drain window. Loop returns the same shape it would on idle. Stop button releases. No truncated response.
  • Task notifications (v0.33+): the !isTaskNotification guard prevents an autonomous followup result from arming the timeout. Only the user-turn's own result does.

Repro

  1. Zed + Claude Code ACP agent.
  2. Send any prompt that takes more than a few seconds (especially one that launches background tasks).
  3. 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.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

claude-code - 💡(How to fix) Fix [BUG] claude-agent-acp: streaming loop hangs on missing session_state_changed:idle (stop button stuck) — UPDATED with proper bounded-drain fix [1 participants]