Repository navigation
bug: ACP agents (cursor, cline, gemini, grok) never finish a turn; the reply only appears on stop #306
Description
Summary
An ACP-backed agent can stream its output and then never finish the turn. The chat stays in its
"working" state indefinitely, and the reply only appears once stop is pressed.
Observed with the cursor agent: the first tokens and the tool activity appear live, then the turn
hangs. Pressing stop immediately renders the complete result. codex is unaffected, because it does
not go through the ACP adapter.
The cause is in acp_event_stream, and it affects all four ACP agents: cursor, cline, gemini and
grok.
Environment
- cptr version:
0.9.21(reproduced againstmain, currentlyf9d1d8c) - Agent:
cursor(agent) via the ACP adapter - The root cause below is read directly from
main; the standalone reproduction runs the real
acp_event_streamwith a stub client, so it needs no agent binary and no cptr dependencies.
Steps to reproduce
- Configure a
cursoragent profile. - Ask it anything that makes it run a tool and then answer.
- The first deltas and the tool updates stream normally, then the turn stops progressing.
- Press stop — the full reply appears at once.
Root cause
acp_event_stream waits on an empty queue forever:
# cptr/utils/agents/acp.py:413
async def acp_event_stream(client: AcpClient) -> AsyncIterator[dict[str, Any]]:
while True:
yield await client.events.get()and the adapters check for completion inside the loop body:
# cptr/utils/agents/cursor.py:86
async for event in acp_event_stream(client):
...
if prompt_task.done():
try:
next_event = await asyncio.wait_for(client.events.get(), timeout=0.25)
except asyncio.TimeoutError:
breakThat if prompt_task.done() is only reachable when another event arrives. If the agent's last event
lands before session/prompt resolves — which is what a normal turn looks like — the generator
parks on events.get(), prompt_task completes unnoticed, and AgentDone is never yielded.
It is a race, which is why it is intermittent: the turn completes only when the agent happens to
emit one more event after the prompt has already resolved.
Why the reply appears on stop
chat_task accumulates the reply in memory and flushes and persists it in exactly two places:
AgentDone(cptr/utils/chat_task.py:1823), and- the
CancelledErrorhandler (cptr/utils/chat_task.py:2777).
Since AgentDone never arrives, cancelling is the only path that writes out what was already
streamed — which is precisely what pressing stop does.
Reproduction
This drives the real acp_event_stream with a stub client. Run it from the repository root; it
needs no dependencies, because acp.py imports only the standard library at module level.
import asyncio, importlib.util, sys
spec = importlib.util.spec_from_file_location("acp", "cptr/utils/agents/acp.py")
acp = importlib.util.module_from_spec(spec); sys.modules["acp"] = acp; spec.loader.exec_module(acp)
class FakeClient:
def __init__(self): self.events = asyncio.Queue()
async def drive(events, prompt_delay):
client = FakeClient()
async def prompt():
await asyncio.sleep(prompt_delay)
async def feed():
for delay, event in events:
await asyncio.sleep(delay); await client.events.put(event)
prompt_task = asyncio.create_task(prompt()); feeder = asyncio.create_task(feed())
seen = []
async def consume():
# The adapter's loop body, including its post-completion drain.
async for event in acp.acp_event_stream(client):
seen.append(event)
if prompt_task.done():
try:
seen.append(await asyncio.wait_for(client.events.get(), timeout=0.25))
except asyncio.TimeoutError:
break
await prompt_task
return "finished"
try:
outcome = await asyncio.wait_for(consume(), timeout=3.0)
except asyncio.TimeoutError:
outcome = "HUNG"
finally:
feeder.cancel()
if not prompt_task.done(): prompt_task.cancel()
print(f" {outcome:9} events: {seen}")
async def main():
print("last event lands before the prompt resolves, then the agent goes quiet:")
await drive([(0.1, "delta:hello"), (0.1, "tool:read_file"), (0.1, "delta:the answer")], 0.6)
print("a trailing event happens to arrive after the prompt resolves:")
await drive([(0.1, "delta:hello"), (0.5, "delta:trailing")], 0.2)
asyncio.run(main())last event lands before the prompt resolves, then the agent goes quiet:
HUNG events: ['delta:hello', 'tool:read_file', 'delta:the answer']
a trailing event happens to arrive after the prompt resolves:
finished events: ['delta:hello', 'delta:trailing']
The second case is the one that works today, and it works only by accident.
Suggested fix
Bound the stream by the prompt task. Once the prompt has resolved, keep draining while events still
arrive and stop on the first quiet window — so a trailing event is not lost to wherever the poll
boundary happened to fall, and a long silent tool run mid-turn is not cut short, because the turn is
still live.
Cancelling a Queue.get on timeout is safe: the item stays queued and get hands the wakeup to the
next getter, so no event is dropped between polls.
--- a/cptr/utils/agents/acp.py
+++ b/cptr/utils/agents/acp.py
@@
from typing import Any, AsyncIterator
+# How long to wait for the next event before re-checking whether the turn has ended. Matches the
+# drain window the adapters already allow for a trailing event after the prompt resolves.
+ACP_IDLE_POLL_SECONDS = 0.25
+
+
class AcpClient:
@@
-async def acp_event_stream(client: AcpClient) -> AsyncIterator[dict[str, Any]]:
+async def acp_event_stream(
+ client: AcpClient, until: asyncio.Task[Any] | None = None
+) -> AsyncIterator[dict[str, Any]]:
+ """Yield ACP events, stopping once the turn is over.
+
+ ``until`` is the in-flight ``session/prompt`` task. Without it this waits on an empty queue
+ forever: a turn whose last event arrives *before* the prompt resolves leaves the caller parked
+ here, so its completion check never runs and the chat is never finished.
+
+ Cancelling a ``Queue.get`` on timeout is safe -- the item stays queued and ``get`` hands the
+ wakeup to the next getter -- so no event is lost between polls.
+ """
while True:
- yield await client.events.get()
+ try:
+ yield await asyncio.wait_for(client.events.get(), timeout=ACP_IDLE_POLL_SECONDS)
+ continue
+ except asyncio.TimeoutError:
+ if until is None or not until.done():
+ continue
+
+ # The turn is over. Keep draining while events still arrive, and stop on the first
+ # quiet window, so a trailing event is not lost to wherever the poll boundary fell.
+ while True:
+ try:
+ yield await asyncio.wait_for(client.events.get(), timeout=ACP_IDLE_POLL_SECONDS)
+ except asyncio.TimeoutError:
+ returnand, in each of the four adapters, pass the prompt task:
- async for event in acp_event_stream(client):
+ async for event in acp_event_stream(client, until=prompt_task):in cptr/utils/agents/cursor.py, cline.py, gemini.py and grok.py. Their existing
post-completion drain block becomes redundant but stays harmless, so it can be left in place to keep
the change small.
Verified against the patched module, with the reproduction above changed to call
acp_event_stream(client, until=prompt_task):
| Case | today | with the patch |
|---|---|---|
| Last event before the prompt resolves, then silence | hangs | finishes |
| Trailing event ~0.2s after the prompt resolves | finishes | finishes |
| 2.5s silent tool run mid-turn | finishes | finishes, not cut short |
| Trailing event ~0.4s after the prompt resolves | finishes | event dropped |
The trade-off is in that last row and is worth stating plainly. Today an arbitrarily late
trailing event is still delivered, because the loop waits for it forever — which is the same
property that causes the hang. Bounding the wait means an event arriving more than one drain window
after session/prompt has resolved is dropped. ACP_IDLE_POLL_SECONDS is set to 0.25s, the same
budget the adapters already chose for exactly this, so in practice this only affects events that
arrive after the turn has been reported complete. If you would rather not lose those at all, racing
events.get() against prompt_task with asyncio.wait(..., return_when=FIRST_COMPLETED) would
also fix the hang without a polling window — it is a larger change, and I went with the smaller one.
The branch is here if it is useful: https://github.com/ciberick/computer/tree/fix/acp-stream-never-ends
I could not open a pull request — POST /repos/open-webui/computer/pulls returns 404 for an outside
account — so the change is offered here instead. Happy to submit it any way you prefer.
Notes
- There is no test suite in the repository, so the reproduction is given inline rather than as a
test. Glad to add one if you would like tests introduced. - Verified by reading and driving the code. I have run a full end-to-end turn only with
cursor;
cline,geminiandgrokshare the identical loop, so the same analysis applies to them, but I
have not exercised them directly.
Activity
owui-terminator commentedon Sep 24, 2026
🔍 Related Issues Found
I found some existing issues that might be related. Please check if any of these are duplicates or contain helpful solutions:
🟢 #213 bug: a stalled agent session hangs the chat task forever, and the whole transcript is lost if you kill it
Related because it is another hang-at-end-of-turn problem in the agent pipeline. It describes the chat task waiting forever when no terminal event arrives, which matches the same failure mode of never reaching completion and only recovering when interrupted.
by ThalynLabs
🟢 #220 bug: Chat ends when Claude Code agent spawns a background agent and never resumes
Related at a higher level because it also involves agent sessions that do not resume/finish correctly after background activity. While the mechanism differs, it is in the same area of agent turn lifecycle management and post-tool completion.
by JKGeovision
🟢 #224 bug: Gemini streaming tool calls crash and lose thought signatures
Only loosely related through Gemini agent behavior, but it is a genuine ACP-adjacent Gemini bug that affects streaming/tool-call handling. It is not the same hang, so it is a lower-confidence relation.
by owahltinez
💡 If your issue is a duplicate, please close it and add any additional details to the existing issue instead.
This comment was generated automatically. React with 👍 if helpful, 👎 if not.
ciberick commentedon Sep 24, 2026
Thanks — I read all three. None is a duplicate, but #213 is closely adjacent and worth reading
alongside this one, so here is how I think they fit together.
#213 — same symptom, different cause; complementary rather than duplicate
#213 is about
opencode, which does not go through the ACP adapter (acp_event_streamis used onlyby
cursor.py,cline.py,gemini.pyandgrok.py). There the agent genuinely stalls — a toolpart stuck in
runningafter a failed spawn — so no terminal event is ever produced, and #213's askis structural: a watchdog, a bounded HTTP timeout, keeping stderr, and persisting incrementally.
This issue needs no stall at all. The agent finishes its turn correctly and emits everything it
should; the adapter simply never notices, because the completion check sits inside a loop body that
only runs when another event arrives. A healthy turn hangs.
The two meet in one place, which #213 already documents well:
chat_taskonly flushes the reply onAgentDoneor on cancellation, so whatever the cause, the transcript is stranded until the userinterrupts.
Worth noting in #213's favour: it argues the timeout gap is "structural rather than adapter-specific",
and this issue is evidence for that — the same end state is reachable with no misbehaving agent
anywhere.
They also do not substitute for each other:
only after the timeout, so a normal reply would arrive seconds or minutes late.
such as bug: a stalled agent session hangs the chat task forever, and the whole transcript is lost if you kill it #213's own unresolved.
Happy to fold this into #213 if you would rather track it as one piece of work — it just seemed more
useful as a separate, small, self-contained fix.
#220 and #224 — unrelated as far as I can tell
hanging, on a different agent and code path.
and
stream_openai_completions(), a parsing crash. This issue'sgemini.pyis the separate ACPpath. Same provider name, different code; I think the match was on the word "Gemini".