Skip to content

Bracket agent-initiated ACP turns so unprompted work renders - #2220

Closed
SawyerHood wants to merge 4 commits into
mainfrom
fix/2122-acp-agent-initiated-turns
Closed

Bracket agent-initiated ACP turns so unprompted work renders#2220
SawyerHood wants to merge 4 commits into
mainfrom
fix/2122-acp-agent-initiated-turns

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

When an ACP agent streams work with no session/prompt in flight (the concrete case is OMP's async-job auto-delivery), handleAgentNotification in the provider-acp bridge forwarded every session/update to the translator without opening a turn. ACP has no turn bracket for agent-initiated work, the translator is context-free, and the runtime's delta assembler refuses to let item/stream deltas open a turn, so each unprompted chunk was demoted to a thread-scoped provider/unhandled row. Those rows are hidden unless showUnhandledProviderEvents is on, so the thread simply never answered. Root cause and live repro: https://get-bb.github.io/reports/issues/2122.html.

#2123 (thanks @bradhallett) correctly identified the layer and the shape (a bridge-emitted turn.open, per provider-bridge-protocol turn lifecycle rule 3). It was reviewed REQUEST CHANGES because it tracked the vouched turn in a parallel spontaneousTurnOpen boolean, so the existing readers of activePromptKind kept their old semantics: an agent exit left the turn open forever (thread hangs in Working), permission requests inside the turn were auto-cancelled even in full mode, and the 120 s quiet window buffered the last message and reported "Worked for 2m".

What changed

The bridge folds agent-initiated work into its open-turn mirror
(activePromptKind: "turn" | "compaction" | "agent" | null). A work-kind update
arriving idle opens a turn; a 5 s quiet window ends it; the next turn/start
settles it first; thread/stop interrupts it; an agent exit fails it through the
settling error. Permission requests inside an agent turn are handled like
prompted ones instead of auto-cancelled.

The window measures the agent's silence, and an agent running a tool it already
announced is silent by definition — a build, a test run, or an install sends
nothing until it has a result. So an unsettled tool call counts as activity, not
quiet: the translator answers hasOpenToolCalls(threadId) from its merge cache
(one entry per call, from the tool_call to the terminal tool_call_update),
and the quiet timer re-arms on it exactly as it already does for an unanswered
permission. The turn's other exits still bound it.

The stop reason is chosen where the turn is settled rather than at each call
site. end_turn claims the agent finished, which bb may only assert when the
agent owes the turn nothing; a turn cut short over a running call settles as
cancelled, so the turn and its open rows read interrupted. Without both, the
drain closed a running command as completed with no output, the agent's real
result was dropped by the assembler's settled-key dedup, and the thread flipped
idle mid-work — dispatching the next queued message into a busy agent.

The code lives in packages/provider-bridge-acp (@bb/provider-bridge-acp),
which is where #2325 moved the ACP bridge; the provider-acp plugin is now a
thin declaration layer over it.

How you verified

pnpm exec turbo run test typecheck lint --filter=@bb/provider-bridge-acp --force
— 17 files, 264 tests. Nine tests in bridge.test.ts cover the bracket
end to end against a fake ACP agent: it opens on unprompted work and closes on
quiet, stays shut for non-work updates, settles before the next user turn,
interrupts on thread/stop, fails on an agent exit, and handles a permission
request inside the turn in both full and ask mode. Two more pin the
running-tool cases: the turn stays open across a call that outlasts the quiet
window and the real output lands on its original row, and a user turn arriving
mid-call settles the agent turn and its command row as interrupted rather than
completed. Each of those two fails without its fix and passes with it.

Live in a dev app, using the scripted unprompted ACP agent from the #2122
report registered through the provider-acp customAgents setting. Real omp
16.3.10 and an isolated npm omp 17.4.0 were both driven directly by a
standalone ACP client and never emit session/update once a session/prompt
has settled — omp's ACP prompt handler returns early — so OMP's own async-job
delivery could not be reproduced on this machine and the scripted agent is the
live evidence for the wire shape the issue describes.

The unprompted stream renders as one turn (turn/started 2,
turn/completed 2, provider/unhandled 0, thread idle, closing 5008 ms after
the last update). With the tool left running 9 s, the row settled on the
agent's own result 9035 ms after it opened, carrying SLOW-TOOL-REAL-OUTPUT,
with no turn boundary in between. Sending a message through --mode auto 3 s
into that call settled the agent turn as interrupted with the command row
interrupted and no fabricated output, and the user's turn opened and
completed normally.

Fixes #2122

AGENT GENERATED: by Claude Opus 5

@SawyerHood
SawyerHood force-pushed the fix/2122-acp-agent-initiated-turns branch 2 times, most recently from 6804049 to 92bc8fe Compare August 24, 2026 23:40
SawyerHood and others added 4 commits August 24, 2026 18:08
When an ACP agent streams work with no session/prompt in flight (OMP's
async-job delivery), the bridge forwarded the updates without opening a
turn, so the runtime assembler demoted each one to a hidden thread-scoped
provider/unhandled row and the user saw nothing.

The bridge now folds agent-initiated work into its open-turn mirror
(activePromptKind: "turn" | "compaction" | "agent" | null). A work-kind
update arriving idle opens a turn; a 5 s quiet window ends it; the next
turn/start settles it first; thread/stop interrupts it; an agent exit fails
it through the settling error. Permission requests inside an agent turn are
handled like prompted ones instead of auto-cancelled.

Co-Authored-By: Claude <noreply@anthropic.com>
The ACP bridge change ships inside the published SDK (dist/provider-bridge-acp.js), and 0.4.16 is already on npm, so the npm version guard requires a new version. PLUGIN_SDK_VERSION moves in lockstep.

Co-Authored-By: Claude <noreply@anthropic.com>
The agent-turn quiet window measured silence, and an agent running a tool
it already announced is silent by definition: it streams nothing until the
tool produces a result. A build, a test run, or an install that outlasts
the 5 s window let the window fire mid-execution.

Settling there was not just an early split. `settleAgentTurn("end_turn")`
reaches `drainOpenToolCalls`, which closes every unsettled call with the
turn's own status, so the timeline showed the command completed with no
output while the agent was still running it. The agent's real
`tool_call_update` then landed in a fresh turn whose `turn.open` had
cleared the merge cache, and the assembler dropped the repeated close for
an already-settled `providerItemId` — the actual result never became an
event. On the server a completed root turn also flips the thread idle and
dispatches the next queued message into a busy agent.

The merge cache already tracks exactly the calls the agent owes a result
for, so the translator now answers `hasOpenToolCalls(threadId)` and the
quiet timer re-arms on it, like it already does for an unanswered
permission. The turn's other exits are unchanged, so it stays bounded:
`thread/stop`, an agent exit, and the next `turn/start` all still end it.

Co-Authored-By: Claude <noreply@anthropic.com>
`turn/start` closes an open agent turn before the user's prompt goes out,
and it asked for `end_turn` unconditionally. With a tool call still
running that is a claim bb has no basis for: `end_turn` maps to a
completed turn, the drain closes the running command as `completed` with
no output, and the agent's real result is then dropped by the assembler's
settled-key dedup. The user saw a command finish that was still running,
with a result that never arrived.

The stop reason is now chosen where the turn is settled rather than at
each call site, so the invariant holds for every caller: an agent turn
that ends over a call the agent never settled ends as `cancelled`, which
the translator maps to an interrupted turn and interrupted rows. That is
what actually happened — the user's input cut the work off.

This is reachable on the ordinary send path, not only through a raw
`turn/start`: an active thread resolves mode `auto`, the daemon steers,
the bridge rejects the steer because the open turn is an agent one, and
the daemon falls back to `turn/start`.

Co-Authored-By: Claude <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the fix/2122-acp-agent-initiated-turns branch from 92bc8fe to aeb5d83 Compare August 25, 2026 01:10
@bradhallett

Copy link
Copy Markdown
Contributor

Two hardening suggestions before merge, both aimed at the real-omp case rather than the scripted agent:

  1. Treat the busy rejection as a signal, not an error. When session/prompt returns the Agent is already processing shape, queue-and-retry with backoff instead of settling the turn failed. The rejection guarantees the prompt never started, so retry is always safe — and this defends bb against any latch-holding agent regardless of what updates it emits, including after omp fixes its side.
  2. Classify acp/fs/write as agent-work activity for the vouched-turn bracket. omp emits file writes after a prompt settles but no session/update work kinds in that window (ACP: async-job auto-delivery turns reach clients unbracketed; allowAgentInitiatedTurns never enabled can1357/oh-my-pi#9157 tracks the omission), so this PR's bracket never opens for the busiest real agent. File writes are the agent working.

The 120s quiet-window close is the right fallback either way — keep it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

provider-acp silently drops agent-initiated turns (unprompted session updates, e.g. OMP async-job delivery)

2 participants