[GH-ISSUE #306] bug: ACP agents (cursor, cline, gemini, grok) never finish a turn; the reply only appears on stop #219

Open
opened 2026-10-18 14:03:54 +00:00 by bsakaguchi · 2 comments
Owner

Originally created by @ciberick on GitHub (Sep 24, 2026).
Original GitHub issue: https://github.com/open-webui/computer/issues/306

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

  1. Configure a cursor agent profile.
  2. Ask it anything that makes it run a tool and then answer.
  3. The first deltas and the tool updates stream normally, then the turn stops progressing.
  4. 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.
Originally created by @ciberick on GitHub (Sep 24, 2026). Original GitHub issue: https://github.com/open-webui/computer/issues/306 ## 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 1. Configure a `cursor` agent profile. 2. Ask it anything that makes it run a tool and then answer. 3. The first deltas and the tool updates stream normally, then the turn stops progressing. 4. Press **stop** — the full reply appears at once. ## Root cause `acp_event_stream` waits on an empty queue forever: ```python # 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**: ```python # 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. ```python 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. ```diff --- 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: ```diff - 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.
Author
Owner

@owui-terminator[bot] commented on GitHub (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:

  1. 🟢 #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

  2. 🟢 #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

  3. 🟢 #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.

<!-- gh-comment-id:5820335854 --> @owui-terminator[bot] commented on GitHub (Sep 24, 2026): <!-- terminator-bot:related-issues-reply --> 🔍 **Related Issues Found** I found some existing issues that might be related. Please check if any of these are duplicates or contain helpful solutions: 1. 🟢 [#213](https://github.com/open-webui/computer/issues/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* 2. 🟢 [#220](https://github.com/open-webui/computer/issues/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* 3. 🟢 [#224](https://github.com/open-webui/computer/issues/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.
Author
Owner

@ciberick commented on GitHub (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_stream is used only
by cursor.py, cline.py, gemini.py and grok.py). There the agent genuinely stalls — a tool
part stuck in running after a failed spawn — so no terminal event is ever produced, and #213's ask
is 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_task only flushes the reply on
AgentDone or on cancellation, so whatever the cause, the transcript is stranded until the user
interrupts.

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:

  • A watchdog from #213 would mask this bug rather than fix it: every ACP turn would finish, but
    only after the timeout, so a normal reply would arrive seconds or minutes late.
  • Fixing this one removes a concrete cause from the class #213 describes, but leaves genuine stalls
    such as #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

  • #220 (Claude Code spawning a background agent) — the chat ends immediately rather than
    hanging, on a different agent and code path.
  • #224 (Gemini tool calls) — that is Gemini through the OpenAI-compatible completions path
    and stream_openai_completions(), a parsing crash. This issue's gemini.py is the separate ACP
    path. Same provider name, different code; I think the match was on the word "Gemini".
<!-- gh-comment-id:5820403984 --> @ciberick commented on GitHub (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_stream` is used only by `cursor.py`, `cline.py`, `gemini.py` and `grok.py`). There the **agent genuinely stalls** — a tool part stuck in `running` after a failed spawn — so no terminal event is ever produced, and #213's ask is 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_task` only flushes the reply on `AgentDone` or on cancellation, so whatever the cause, the transcript is stranded until the user interrupts. 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: - A watchdog from #213 would **mask** this bug rather than fix it: every ACP turn would finish, but only after the timeout, so a normal reply would arrive seconds or minutes late. - Fixing this one removes a concrete cause from the class #213 describes, but leaves genuine stalls such as #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 - **#220** (Claude Code spawning a background agent) — the chat *ends* immediately rather than hanging, on a different agent and code path. - **#224** (Gemini tool calls) — that is Gemini through the **OpenAI-compatible completions** path and `stream_openai_completions()`, a parsing crash. This issue's `gemini.py` is the separate ACP path. Same provider name, different code; I think the match was on the word "Gemini".
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
open-webui/computer#219
No description provided.