Bracket agent-initiated ACP turns so unprompted work renders - #2220
Closed
SawyerHood wants to merge 4 commits into
Closed
Bracket agent-initiated ACP turns so unprompted work renders#2220SawyerHood wants to merge 4 commits into
SawyerHood wants to merge 4 commits into
Conversation
SawyerHood
force-pushed
the
fix/2122-acp-agent-initiated-turns
branch
2 times, most recently
from
August 24, 2026 23:40
6804049 to
92bc8fe
Compare
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
force-pushed
the
fix/2122-acp-agent-initiated-turns
branch
from
August 25, 2026 01:10
92bc8fe to
aeb5d83
Compare
Contributor
|
Two hardening suggestions before merge, both aimed at the real-omp case rather than the scripted agent:
The 120s quiet-window close is the right fallback either way — keep it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
When an ACP agent streams work with no
session/promptin flight (the concrete case is OMP's async-job auto-delivery),handleAgentNotificationin the provider-acp bridge forwarded everysession/updateto 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-scopedprovider/unhandledrow. Those rows are hidden unlessshowUnhandledProviderEventsis 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 parallelspontaneousTurnOpenboolean, so the existing readers ofactivePromptKindkept 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 updatearriving idle opens a turn; a 5 s quiet window ends it; the next
turn/startsettles it first;
thread/stopinterrupts it; an agent exit fails it through thesettling 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_callto the terminaltool_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_turnclaims the agent finished, which bb may only assert when theagent 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, thedrain closed a running command as
completedwith no output, the agent's realresult 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-acpplugin is now athin 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.tscover the bracketend 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 permissionrequest 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-acpcustomAgentssetting. Real omp16.3.10 and an isolated npm omp 17.4.0 were both driven directly by a
standalone ACP client and never emit
session/updateonce asession/prompthas 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 afterthe 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 auto3 sinto that call settled the agent turn as
interruptedwith the command rowinterruptedand no fabricated output, and the user's turn opened andcompleted normally.
Fixes #2122