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 against main, currently f9d1d8c)
- Agent:
cursor (agent) via the ACP adapter
- The root cause below is read directly from
main; the standalone reproduction runs the real
acp_event_stream with a stub client, so it needs no agent binary and no cptr dependencies.
Steps to reproduce
- Configure a
cursor agent 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:
break
That 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
CancelledError handler (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:
+ return
and, 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, gemini and grok share the identical loop, so the same analysis applies to them, but I
have not exercised them directly.
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
cursoragent: the first tokens and the tool activity appear live, then the turnhangs. Pressing stop immediately renders the complete result.
codexis unaffected, because it doesnot go through the ACP adapter.
The cause is in
acp_event_stream, and it affects all four ACP agents: cursor, cline, gemini andgrok.
Environment
0.9.21(reproduced againstmain, currentlyf9d1d8c)cursor(agent) via the ACP adaptermain; the standalone reproduction runs the realacp_event_streamwith a stub client, so it needs no agent binary and no cptr dependencies.Steps to reproduce
cursoragent profile.Root cause
acp_event_streamwaits on an empty queue forever:and the adapters check for completion inside the loop body:
That
if prompt_task.done()is only reachable when another event arrives. If the agent's last eventlands before
session/promptresolves — which is what a normal turn looks like — the generatorparks on
events.get(),prompt_taskcompletes unnoticed, andAgentDoneis 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_taskaccumulates the reply in memory and flushes and persists it in exactly two places:AgentDone(cptr/utils/chat_task.py:1823), andCancelledErrorhandler (cptr/utils/chat_task.py:2777).Since
AgentDonenever arrives, cancelling is the only path that writes out what was alreadystreamed — which is precisely what pressing stop does.
Reproduction
This drives the real
acp_event_streamwith a stub client. Run it from the repository root; itneeds no dependencies, because
acp.pyimports only the standard library at module level.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.geton timeout is safe: the item stays queued andgethands the wakeup to thenext getter, so no event is dropped between polls.
and, in each of the four adapters, pass the prompt task:
in
cptr/utils/agents/cursor.py,cline.py,gemini.pyandgrok.py. Their existingpost-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):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/prompthas resolved is dropped.ACP_IDLE_POLL_SECONDSis set to 0.25s, the samebudget 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()againstprompt_taskwithasyncio.wait(..., return_when=FIRST_COMPLETED)wouldalso 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/pullsreturns 404 for an outsideaccount — so the change is offered here instead. Happy to submit it any way you prefer.
Notes
test. Glad to add one if you would like tests introduced.
cursor;cline,geminiandgrokshare the identical loop, so the same analysis applies to them, but Ihave not exercised them directly.