From beb78f064d926577e2bd6078a29472fea3992181 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 21 Aug 2026 09:56:33 -0700 Subject: [PATCH 1/4] Bracket agent-initiated ACP turns so unprompted work renders (#2122) 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 --- .../src/bridge/bridge.test.ts | 193 ++++++++++++++++++ .../provider-bridge-acp/src/bridge/bridge.ts | 117 ++++++++++- .../src/bridge/fake-acp-agent.mjs | 74 +++++++ .../provider-bridge-acp/src/visibility.ts | 13 +- 4 files changed, 391 insertions(+), 6 deletions(-) diff --git a/packages/provider-bridge-acp/src/bridge/bridge.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.test.ts index 5a999a3d3e..447971533a 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.test.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.test.ts @@ -2202,6 +2202,199 @@ describe("acp bridge", () => { startedProviderThreadIds.pop(); }, 15_000); + describe("agent-initiated turns (#2122)", () => { + /** + * Runs one prompted turn whose agent then streams unprompted work, and + * waits until that work has fully arrived (the closing chunk is on the + * wire) so each test observes the agent turn at a known point. + */ + async function promptThenAwaitAgentWork( + variant: string, + args?: StartThreadArgs, + ): Promise<{ bbThreadId: string; providerThreadId: string }> { + const thread = await startThread(args); + const turnId = sendTurnRequest("turn/start", thread.providerThreadId, { + input: [ + { type: "text", text: `agent-initiated${variant}`, mentions: [] }, + ], + }); + await waitForResponse(turnId); + await waitForTurnCompleted(); + await waitFor( + () => + agentMessageTexts().some((text) => text.includes("the answer is 42.")) + ? true + : undefined, + "agent-initiated work to arrive", + ); + return thread; + } + + it("brackets unprompted agent work as a turn and ends it when the agent goes quiet", async () => { + await promptThenAwaitAgentWork(""); + + // The work is a real turn with real items, not hidden raw-event rows. + expect(threadEventsOfType("turn/started")).toHaveLength(2); + expect(threadEventsOfType("provider/unhandled")).toHaveLength(0); + expect(agentMessageTexts().join("")).toContain( + "agent-initiated:job bg_4 finished, the answer is 42.", + ); + const toolItems = threadEventsOfType("item/started").filter( + (event) => + (event.item as { type: string }).type === "toolCall" || + (event.item as { type: string }).type === "commandExecution" || + (event.item as { type: string }).type === "fileRead", + ); + expect(toolItems.length).toBeGreaterThan(0); + // The echoed job result stays noise: one accepted input (the user's), + // no phantom user row. + expect( + emittedDeltaKinds().filter((kind) => kind === "input.accepted"), + ).toHaveLength(1); + + // No end-of-turn signal exists; the quiet window closes it as completed. + const completed = await waitFor(() => { + const events = threadEventsOfType("turn/completed"); + return events.length === 2 ? events[1] : undefined; + }, "agent turn to close after the quiet window"); + expect(completed).toMatchObject({ status: "completed" }); + }, 20_000); + + it("does not open a turn for unprompted non-work updates", async () => { + const { providerThreadId } = await startThread(); + const turnId = sendTurnRequest("turn/start", providerThreadId, { + input: [{ type: "text", text: "agent-initiated:noise", mentions: [] }], + }); + await waitForResponse(turnId); + await waitForTurnCompleted(); + await waitFor( + () => + emittedDeltaKinds().includes("contextWindow") ? true : undefined, + "idle usage_update to be processed", + ); + + expect(threadEventsOfType("turn/started")).toHaveLength(1); + }); + + it("settles the agent turn before the next user turn opens", async () => { + const { providerThreadId } = await promptThenAwaitAgentWork(""); + expect(threadEventsOfType("turn/completed")).toHaveLength(1); + + const nextId = sendTurnRequest("turn/start", providerThreadId, { + input: [{ type: "text", text: "hello there", mentions: [] }], + }); + const response = await waitForResponse(nextId); + expect(response.error).toBeUndefined(); + await waitFor( + () => + threadEventsOfType("turn/completed").length === 3 ? true : undefined, + "all three turns to settle", + ); + + expect(threadEventsOfType("turn/started")).toHaveLength(3); + expect( + threadEventsOfType("turn/completed").map((event) => event.status), + ).toEqual(["completed", "completed", "completed"]); + expect(agentMessageTexts().at(-1)).toBe("echo:hello there"); + }); + + it("interrupts the agent turn on thread/stop", async () => { + const { providerThreadId } = await promptThenAwaitAgentWork(""); + + const stopId = sendRequest("thread/stop", { + threadId: bbThreadIdFor(providerThreadId), + providerThreadId, + intent: "interrupt", + activeTurnId: null, + }); + const stopResponse = await waitForResponse(stopId); + expect(stopResponse.result).toEqual({ ok: true }); + + const completed = threadEventsOfType("turn/completed"); + expect(completed).toHaveLength(2); + expect(completed[1]).toMatchObject({ status: "interrupted" }); + startedProviderThreadIds.pop(); + }); + + it("fails the agent turn when the agent process exits mid-turn", async () => { + const { bbThreadId } = await promptThenAwaitAgentWork(":exit"); + + const errors = await waitFor(() => { + const errorNotifications = notifications("error"); + return errorNotifications.length > 0 ? errorNotifications : undefined; + }, "agent exit error notification"); + expect(errors).toHaveLength(1); + expect(errors[0]?.params).toMatchObject({ threadId: bbThreadId }); + + // The turn reaches a terminal state instead of hanging "working". + const completed = threadEventsOfType("turn/completed"); + expect(completed).toHaveLength(2); + expect(completed[1]).toMatchObject({ status: "failed" }); + startedProviderThreadIds.pop(); + }); + + it("auto-allows a permission request inside an agent turn in full mode", async () => { + await promptThenAwaitAgentWork(":permission", { permissionMode: "full" }); + + expect( + output.messages.filter( + (message) => message.method === "interaction/request", + ), + ).toHaveLength(0); + expect(agentMessageTexts().join("")).toContain("permission:yes "); + }); + + it("forwards a permission request inside an agent turn in ask mode", async () => { + const { bbThreadId, providerThreadId } = await startThread({ + permissionMode: "accept-edits", + permissionEscalation: "ask", + }); + const turnId = sendTurnRequest("turn/start", providerThreadId, { + input: [ + { type: "text", text: "agent-initiated:permission", mentions: [] }, + ], + }); + await waitForResponse(turnId); + await waitForTurnCompleted(); + + const forwarded = await waitFor( + () => + output.messages.find( + (message) => + message.method === "interaction/request" && + message.id !== undefined, + ), + "forwarded permission request", + ); + expect(forwarded.params).toMatchObject({ + threadId: bbThreadId, + providerThreadId, + payload: { + kind: "approval", + subject: expect.objectContaining({ command: "rm -rf build" }), + }, + }); + handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: forwarded.id, + result: { decision: "deny" }, + }), + ); + + await waitFor( + () => + agentMessageTexts().some((text) => text.includes("the answer is 42.")) + ? true + : undefined, + "agent-initiated work to finish after the decision", + ); + expect(agentMessageTexts().join("")).toContain("permission:no "); + // The whole exchange lives in the one agent turn. + expect(threadEventsOfType("turn/started")).toHaveLength(2); + }); + }); + it("forks an advertised ACP session with the target cwd and MCP servers", async () => { const forkLog = join(workspaceDir, "fork-params.json"); const forkId = sendRequest("thread/fork", { diff --git a/packages/provider-bridge-acp/src/bridge/bridge.ts b/packages/provider-bridge-acp/src/bridge/bridge.ts index d8a4ee9cca..824b2de06f 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.ts @@ -54,6 +54,7 @@ import { buildAcpPermissionInteractionPayload, resolveAcpPermissionDecision, } from "../interactions.js"; +import { isAgentWorkAcpUpdateKind } from "../visibility.js"; import { buildAcpModelListParams, buildAcpSessionParams, @@ -167,10 +168,14 @@ interface AcpThreadSession { policy: AcpSessionPolicy; pendingInstructions: string | undefined; /** - * Which agent prompt is in flight for this bb turn: an ordinary `"turn"`, - * the provider-local `"compaction"` maintenance prompt, or none. + * The bridge's mirror of the turn it has open for this bb thread: an + * ordinary `"turn"` (a session/prompt is in flight), the provider-local + * `"compaction"` maintenance prompt, an `"agent"`-initiated turn the bridge + * bracketed around unprompted agent work, or none. */ - activePromptKind: "turn" | "compaction" | null; + activePromptKind: "turn" | "compaction" | "agent" | null; + /** Re-armed per work update; closes an `"agent"` turn once the agent is quiet. */ + agentTurnQuietTimer: ReturnType | undefined; queuedInputs: AcpPendingTurnInput[]; /** True while a session/prompt request is outstanding. */ promptRequestPending: boolean; @@ -212,6 +217,14 @@ let dynamicToolBridgePromise: Promise | null = null; // this timeout forces disposal. Stop remains a best-effort success boundary. const THREAD_STOP_CANCEL_TIMEOUT_MS = 4_000; +// ACP has no end-of-turn signal for agent-initiated work (no session/prompt +// result arrives), so a still-open `"agent"` turn closes once the agent has +// been quiet this long. Kept short: while the turn is open the thread reads as +// working and the server holds the last streamed message until the turn +// flushes. A long pause (a slow agent-side tool) splits the work into two +// turns, which is the cheaper failure. +const AGENT_TURN_QUIET_WINDOW_MS = 5_000; + // --------------------------------------------------------------------------- // stdout helpers (bridge → runtime) // --------------------------------------------------------------------------- @@ -323,7 +336,7 @@ function emitSessionError(session: AcpThreadSession, message: string): void { // prompt in flight the error stays a runtime notification — a settling // error delta on an idle thread would surface a diagnostic for a turn bb // never accepted. `activePromptKind` mirrors the turn the bridge itself - // opened with `turn.open`. + // opened with `turn.open`, an agent-initiated one included. if (session.activePromptKind !== null) { emitForSession(session, "error", { threadId: session.bbThreadId, @@ -1432,10 +1445,14 @@ function handlePermissionRequest( return; } + // A permission request belongs to a turn the user can see: a prompted one + // or an agent-initiated one. Outside both (idle, or the compaction prompt) + // there is no turn to present it against. if ( session.stopping || session.cancelRequested || - session.activePromptKind !== "turn" + (session.activePromptKind !== "turn" && + session.activePromptKind !== "agent") ) { responder.result({ outcome: { outcome: "cancelled" } }); return; @@ -1798,6 +1815,7 @@ async function startAgentSession( onExit: (info) => { const wasCurrent = sessionsByBbThreadId.get(bbThreadId) === session; cancelPendingPermissions(session); + clearAgentTurnQuietTimer(session); removeSession(session); // An exit during construction fails the pending request, and that // rejection is the report; a released session owes nothing. @@ -1805,12 +1823,19 @@ async function startAgentSession( return; } void releaseCursorMcpApproval(session); + // Emitted while `activePromptKind` still names the open turn so the + // error settles it. A prompted turn clears its own mirror when the + // rejected prompt unwinds; an agent-initiated turn has no prompt, so it + // is cleared here. emitSessionError( session, `ACP agent "${agentLabel}" exited unexpectedly` + `${info.code !== null ? ` (code ${info.code})` : ""}` + `${info.stderrTail ? `: ${info.stderrTail}` : ""}`, ); + if (session.activePromptKind === "agent") { + session.activePromptKind = null; + } }, }); session = { @@ -1828,6 +1853,7 @@ async function startAgentSession( }, pendingInstructions: params.instructions, activePromptKind: null, + agentTurnQuietTimer: undefined, queuedInputs: [], promptRequestPending: false, cancelRequested: false, @@ -2039,6 +2065,9 @@ async function stopSession(session: AcpThreadSession): Promise { "ACP session stopped before the steer was sent", ); cancelPendingPermissions(session); + // An interrupt stops agent-initiated work too. There is no prompt to + // cancel, so the turn settles as interrupted and the agent is reaped. + settleAgentTurn(session, "cancelled"); if (session.activePromptKind !== null && !session.connection.exited) { session.connection.notify("session/cancel", { @@ -2095,6 +2124,12 @@ async function releaseSession(session: AcpThreadSession): Promise { "ACP session released before the steer was sent", ); cancelPendingPermissions(session); + // Like a released prompt turn, a released agent turn detaches without a + // fabricated terminal state. + clearAgentTurnQuietTimer(session); + if (session.activePromptKind === "agent") { + session.activePromptKind = null; + } session.connection.kill(); removeSession(session); await releaseCursorMcpApproval(session); @@ -2348,6 +2383,64 @@ function finishCompaction( session.turnSettled = undefined; } +// --------------------------------------------------------------------------- +// Agent-initiated turns +// --------------------------------------------------------------------------- + +/** + * Work the agent streams with no prompt in flight (OMP delivering an async + * job's result, for one) is a turn bb never asked for. ACP sends no bracket + * for it, so the bridge opens one itself — the sanctioned shape for + * provider-internal activity (provider-bridge-protocol.md, turn lifecycle + * rule 3) — and owns every exit path: a quiet window ends it, the next + * bb-initiated turn settles a still-open one first, `thread/stop` interrupts + * it, and an agent exit fails it through `emitSessionError`. Without the + * bracket the assembler demotes each update to a hidden thread-scoped + * `provider/unhandled` row and the user sees nothing (#2122). + */ +function openAgentTurn(session: AcpThreadSession): void { + session.activePromptKind = "agent"; + emitForSession(session, ACP_TURN_STARTED_METHOD, { + threadId: session.bbThreadId, + }); +} + +function clearAgentTurnQuietTimer(session: AcpThreadSession): void { + if (session.agentTurnQuietTimer !== undefined) { + clearTimeout(session.agentTurnQuietTimer); + session.agentTurnQuietTimer = undefined; + } +} + +function settleAgentTurn( + session: AcpThreadSession, + stopReason: z.infer, +): void { + if (session.activePromptKind !== "agent") { + return; + } + clearAgentTurnQuietTimer(session); + session.activePromptKind = null; + emitForSession(session, ACP_TURN_COMPLETED_METHOD, { + threadId: session.bbThreadId, + stopReason, + }); +} + +function armAgentTurnQuietTimer(session: AcpThreadSession): void { + clearAgentTurnQuietTimer(session); + session.agentTurnQuietTimer = setTimeout(() => { + session.agentTurnQuietTimer = undefined; + // A permission the user has not answered is not quiet: the agent is + // waiting on bb, and its answer belongs to this turn. + if (session.pendingPermissions.size > 0) { + armAgentTurnQuietTimer(session); + return; + } + settleAgentTurn(session, "end_turn"); + }, AGENT_TURN_QUIET_WINDOW_MS); +} + // --------------------------------------------------------------------------- // Agent inbound traffic // --------------------------------------------------------------------------- @@ -2435,12 +2528,23 @@ function handleAgentNotification( if (session.providerThreadId === "") { // Construction window: thread/identity has not gone out yet, so the // update waits behind it (and is dropped if it names another session). + // The agent-turn bracket waits too: a turn/started emitted before + // thread/identity would name a session bb has not been told about, and + // the session.reset that follows identity would drop it anyway. session.deferStartEmit?.(ACP_UPDATE_METHOD, update, parsed.data.sessionId); return; } if (parsed.data.sessionId !== session.providerThreadId) { return; } + if (isAgentWorkAcpUpdateKind(parsed.data.update.sessionUpdate)) { + if (session.activePromptKind === null) { + openAgentTurn(session); + } + if (session.activePromptKind === "agent") { + armAgentTurnQuietTimer(session); + } + } emitForSession(session, ACP_UPDATE_METHOD, update); } @@ -2797,6 +2901,9 @@ async function handleRequest( sendError(request.id, -32000, "No active ACP session"); return; } + // User input ends agent-initiated work: that turn settles before the + // requested one opens, so each reaches exactly one terminal state. + settleAgentTurn(session, "end_turn"); if (session.activePromptKind !== null) { sendError(request.id, -32000, "A turn is already active"); return; diff --git a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs index b348536846..e2f0192d1a 100755 --- a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs +++ b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs @@ -307,6 +307,70 @@ function captureMcpServers(message) { : []; } +async function streamAgentInitiatedWork(variant) { + if (variant === "noise") { + notifyUpdate({ sessionUpdate: "available_commands_update", commands: [] }); + notifyUpdate({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "[bg_4 finished] exit 0" }, + }); + // A usage_update last: a positive, observable sign the idle traffic was + // processed even though none of it may open a turn. + notifyUpdate({ sessionUpdate: "usage_update", used: 1_000, size: 128_000 }); + return; + } + notifyUpdate({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "[bg_4 finished] exit 0" }, + }); + notifyUpdate(messageChunk("agent-initiated:job bg_4 finished, ")); + notifyUpdate({ + sessionUpdate: "tool_call", + toolCallId: "agent-initiated-tool-1", + title: "cat result.txt", + kind: "read", + status: "pending", + rawInput: { path: "result.txt" }, + }); + await sleep(30); + notifyUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "agent-initiated-tool-1", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "42" } }], + }); + if (variant === "permission") { + let outcome = "cancelled"; + try { + const result = await requestClient("session/request_permission", { + sessionId: activeSessionId, + toolCall: { + toolCallId: "agent-initiated-tool-2", + title: "Run rm", + kind: "execute", + rawInput: { command: "rm -rf build" }, + }, + options: [ + { optionId: "yes", name: "Allow", kind: "allow_once" }, + { optionId: "no", name: "Deny", kind: "reject_once" }, + ], + }); + outcome = + result?.outcome?.outcome === "selected" + ? result.outcome.optionId + : "cancelled"; + } catch { + outcome = "error"; + } + notifyUpdate(messageChunk(`permission:${outcome} `)); + } + notifyUpdate(messageChunk("the answer is 42.")); + if (variant === "exit") { + await sleep(30); + process.exit(3); + } +} + async function handlePrompt(message) { activePromptId = message.id; const text = promptText(message.params?.prompt); @@ -415,6 +479,16 @@ async function handlePrompt(message) { } catch { notifyUpdate(messageChunk("write:denied")); } + } else if (text.includes("agent-initiated")) { + // OMP async-job delivery shape: once this prompt's result has gone out, + // the agent streams work with no session/prompt driving it — an echoed + // user_message_chunk (the injected job result), agent text, a tool call + // that completes, and a closing chunk. Variants ride the prompt text: + // agent-initiated:permission ask for permission mid-stream + // agent-initiated:exit exit(3) right after the stream + // agent-initiated:noise only non-work updates (must not open a turn) + const variant = text.match(/agent-initiated:(\w+)/)?.[1] ?? ""; + setTimeout(() => void streamAgentInitiatedWork(variant), 40); } else if (text.includes("hang")) { // Stay pending until the client sends session/cancel. return; diff --git a/packages/provider-bridge-acp/src/visibility.ts b/packages/provider-bridge-acp/src/visibility.ts index 1fedd5171e..7e37cb7c38 100644 --- a/packages/provider-bridge-acp/src/visibility.ts +++ b/packages/provider-bridge-acp/src/visibility.ts @@ -17,15 +17,26 @@ const NORMALIZED_ACP_METHODS = new Set([ ACP_WARNING_METHOD, ]); -const NORMALIZED_ACP_UPDATE_KINDS = new Set([ +// Update kinds that carry agent work: streamed text, thoughts, tool calls, +// and plans. Arriving with no prompt in flight they are an agent-initiated +// turn (e.g. OMP's async-job delivery), which the bridge brackets itself. +const AGENT_WORK_ACP_UPDATE_KINDS = new Set([ "agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update", "plan", +]); + +const NORMALIZED_ACP_UPDATE_KINDS = new Set([ + ...AGENT_WORK_ACP_UPDATE_KINDS, "usage_update", ]); +export function isAgentWorkAcpUpdateKind(updateKind: string): boolean { + return AGENT_WORK_ACP_UPDATE_KINDS.has(updateKind); +} + // Update kinds the agent may legitimately send but BB intentionally does not // render: replayed history, agent-side mode/command/config/session metadata. const NOISE_ACP_UPDATE_KINDS = new Set([ From 26f2646b93232f4384e230339d74f3ce24867986 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Mon, 24 Aug 2026 16:37:24 -0700 Subject: [PATCH 2/4] Bump @get-bb/plugin-sdk to 0.4.17 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 --- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/plugin-sdk/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index bcdb2e1abf..a982d899e6 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -16,7 +16,7 @@ // PLUGIN_SDK_MAJOR is 0, so the major-only artifact gate cannot distinguish // 0.x releases and is intentionally vacuous for them until a future 1.0. // Rebuildable artifacts still rebuild on the exact sdkVersion-differs trigger. -export const PLUGIN_SDK_VERSION = "0.4.16"; +export const PLUGIN_SDK_VERSION = "0.4.17"; /** Major of {@link PLUGIN_SDK_VERSION} — the plugin API compatibility number. */ export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 81cbcc7577..fe359072db 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.16", + "version": "0.4.17", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" From eed6e5cabdea4365099fa8971d4508719ae29873 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Mon, 24 Aug 2026 17:59:14 -0700 Subject: [PATCH 3/4] Count a running tool call as agent-turn activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/bridge/bridge.test.ts | 85 +++++++++++++++++++ .../provider-bridge-acp/src/bridge/bridge.ts | 16 +++- .../src/bridge/fake-acp-agent.mjs | 35 ++++++++ .../src/delta-translation.ts | 15 ++++ 4 files changed, 148 insertions(+), 3 deletions(-) diff --git a/packages/provider-bridge-acp/src/bridge/bridge.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.test.ts index 447971533a..2b3b883896 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.test.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.test.ts @@ -2260,6 +2260,91 @@ describe("acp bridge", () => { expect(completed).toMatchObject({ status: "completed" }); }, 20_000); + /** + * The bridge's agent-turn quiet window (`AGENT_TURN_QUIET_WINDOW_MS`). + * Mirrored here so a test can step over it deliberately. + */ + const QUIET_WINDOW_MS = 5_000; + + /** Assembled events that carry the slow tool call's real output. */ + function eventsCarryingSlowToolOutput( + type: string, + ): Record[] { + return threadEventsOfType(type).filter((event) => + JSON.stringify(event).includes("SLOW-TOOL-REAL-OUTPUT"), + ); + } + + /** Assembled events for the slow tool call's row, by event type. */ + function slowToolEvents(type: string): Record[] { + return threadEventsOfType(type).filter((event) => + JSON.stringify(event).includes("sleep 7"), + ); + } + + /** + * Opens an agent turn whose tool call keeps running past the quiet + * window, and returns once the tool row is on the timeline. + */ + async function promptThenAwaitRunningTool(): Promise<{ + bbThreadId: string; + providerThreadId: string; + }> { + const thread = await startThread(); + const turnId = sendTurnRequest("turn/start", thread.providerThreadId, { + input: [ + { type: "text", text: "agent-initiated:slowtool", mentions: [] }, + ], + }); + await waitForResponse(turnId); + await waitForTurnCompleted(); + await waitFor( + () => (slowToolEvents("item/started").length > 0 ? true : undefined), + "the agent-initiated tool call to open", + ); + return thread; + } + + it("keeps the agent turn open while an announced tool call still runs", async () => { + await promptThenAwaitRunningTool(); + expect(threadEventsOfType("turn/started")).toHaveLength(2); + + // Past the quiet window with the call still running. A busy agent + // streams nothing until its tool finishes, so silence alone must not + // end the turn: settling here would close the row as completed with no + // output and flip the thread idle while the agent works. + await new Promise((resolveTick) => + realSetTimeout(resolveTick, QUIET_WINDOW_MS + 1_000), + ); + expect(threadEventsOfType("turn/completed")).toHaveLength(1); + expect(slowToolEvents("item/completed")).toHaveLength(0); + + // The agent's own result settles the row, inside the turn that opened + // it — no second bracket, and the real output is not lost. + await waitFor( + () => + eventsCarryingSlowToolOutput("item/completed").length > 0 + ? true + : undefined, + "the tool call's real result", + ); + expect(slowToolEvents("item/completed")[0]?.item).toMatchObject({ + type: "commandExecution", + command: "sleep 7", + status: "completed", + aggregatedOutput: "SLOW-TOOL-REAL-OUTPUT", + }); + expect(threadEventsOfType("turn/started")).toHaveLength(2); + + // Only now is the agent quiet, so the window closes the one turn. + const completed = await waitFor(() => { + const events = threadEventsOfType("turn/completed"); + return events.length === 2 ? events[1] : undefined; + }, "agent turn to close once the tool finished"); + expect(completed).toMatchObject({ status: "completed" }); + expect(threadEventsOfType("turn/started")).toHaveLength(2); + }, 30_000); + it("does not open a turn for unprompted non-work updates", async () => { const { providerThreadId } = await startThread(); const turnId = sendTurnRequest("turn/start", providerThreadId, { diff --git a/packages/provider-bridge-acp/src/bridge/bridge.ts b/packages/provider-bridge-acp/src/bridge/bridge.ts index 824b2de06f..1e51c3baff 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.ts @@ -2431,9 +2431,19 @@ function armAgentTurnQuietTimer(session: AcpThreadSession): void { clearAgentTurnQuietTimer(session); session.agentTurnQuietTimer = setTimeout(() => { session.agentTurnQuietTimer = undefined; - // A permission the user has not answered is not quiet: the agent is - // waiting on bb, and its answer belongs to this turn. - if (session.pendingPermissions.size > 0) { + // Silence is not idleness. The window measures the gap between updates, + // but an agent running a tool it already announced owes this turn a + // result and sends nothing until it has one — a build, a test run, an + // install all outlast the window with no traffic. Settling over the call + // would drain it as completed with no output, hide the real result + // behind the assembler's settled-key dedup, and flip the thread idle + // mid-work. So a permission bb has not answered and a call the agent has + // not settled both re-arm instead: the same exits still bound the turn + // (`thread/stop`, an agent exit, the next `turn/start`). + if ( + session.pendingPermissions.size > 0 || + session.translator.hasOpenToolCalls(session.bbThreadId) + ) { armAgentTurnQuietTimer(session); return; } diff --git a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs index e2f0192d1a..143b76d9ff 100755 --- a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs +++ b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs @@ -307,7 +307,41 @@ function captureMcpServers(message) { : []; } +/** + * How long the `slowtool` variant keeps its tool call running. Longer than + * the bridge's 5 s agent-turn quiet window on purpose: the point of the + * variant is a call the agent is still executing when the window elapses. + */ +const SLOW_TOOL_RUN_MS = 6_500; + async function streamAgentInitiatedWork(variant) { + if (variant === "slowtool") { + notifyUpdate(messageChunk("agent-initiated:job bg_4 finished, ")); + notifyUpdate({ + sessionUpdate: "tool_call", + toolCallId: "agent-initiated-slow-tool", + title: "sleep 7", + kind: "execute", + status: "in_progress", + rawInput: { command: "sleep 7" }, + }); + // The silent stretch: a real build or test run streams nothing until it + // finishes, so no update reaches the bridge for longer than its window. + await sleep(SLOW_TOOL_RUN_MS); + notifyUpdate({ + sessionUpdate: "tool_call_update", + toolCallId: "agent-initiated-slow-tool", + status: "completed", + content: [ + { + type: "content", + content: { type: "text", text: "SLOW-TOOL-REAL-OUTPUT" }, + }, + ], + }); + notifyUpdate(messageChunk("the answer is 42.")); + return; + } if (variant === "noise") { notifyUpdate({ sessionUpdate: "available_commands_update", commands: [] }); notifyUpdate({ @@ -487,6 +521,7 @@ async function handlePrompt(message) { // agent-initiated:permission ask for permission mid-stream // agent-initiated:exit exit(3) right after the stream // agent-initiated:noise only non-work updates (must not open a turn) + // agent-initiated:slowtool a tool call that runs past the quiet window const variant = text.match(/agent-initiated:(\w+)/)?.[1] ?? ""; setTimeout(() => void streamAgentInitiatedWork(variant), 40); } else if (text.includes("hang")) { diff --git a/packages/provider-bridge-acp/src/delta-translation.ts b/packages/provider-bridge-acp/src/delta-translation.ts index 64fed5f363..0f5d4f6bb2 100644 --- a/packages/provider-bridge-acp/src/delta-translation.ts +++ b/packages/provider-bridge-acp/src/delta-translation.ts @@ -1188,9 +1188,24 @@ export function createAcpDeltaTranslator( return injectedToolBindings.get(callKey({ threadId }, toolCallId)); } + /** + * Whether the thread has a call the agent announced and never settled. + * + * The merge cache is the single record of that: an entry lives from the + * `tool_call` until the terminal `tool_call_update`, which is exactly the + * span in which the agent is running the call. The bridge asks before it + * closes a turn it opened itself, because closing over an unsettled call + * drains it with the turn's own status (`drainOpenToolCalls`) — a terminal + * state the agent never reported. + */ + function hasOpenToolCalls(threadId: string): boolean { + return threadCallEntries({ threadId }).length > 0; + } + return { configureInjectedTools, getInjectedToolBinding, + hasOpenToolCalls, noteDelegationReport, noteInjectedToolCall, notePermissionToolCall, From aeb5d8390039ba851f5f30a590daf321a4394b09 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Mon, 24 Aug 2026 18:00:11 -0700 Subject: [PATCH 4/4] Settle an interrupted agent turn as interrupted, not completed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../src/bridge/bridge.test.ts | 25 +++++++++++++++++++ .../provider-bridge-acp/src/bridge/bridge.ts | 20 ++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/provider-bridge-acp/src/bridge/bridge.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.test.ts index 2b3b883896..59a08cf0b0 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.test.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.test.ts @@ -2345,6 +2345,31 @@ describe("acp bridge", () => { expect(threadEventsOfType("turn/started")).toHaveLength(2); }, 30_000); + it("interrupts, not completes, an agent turn a user turn cuts short mid-tool", async () => { + const { providerThreadId } = await promptThenAwaitRunningTool(); + expect(threadEventsOfType("turn/completed")).toHaveLength(1); + + // The real user path lands here: the server sees the thread active and + // steers, the bridge rejects the steer because the open turn is an + // agent one, and the daemon falls back to turn/start. + const nextId = sendTurnRequest("turn/start", providerThreadId, { + input: [{ type: "text", text: "hello there", mentions: [] }], + }); + expect((await waitForResponse(nextId)).error).toBeUndefined(); + + // The agent turn ends because the user cut it off, not because the + // agent finished: neither it nor the command it left running may claim + // a result the agent never produced. + const settled = threadEventsOfType("turn/completed"); + expect(settled).toHaveLength(2); + expect(settled[1]).toMatchObject({ status: "interrupted" }); + expect(slowToolEvents("item/completed")[0]?.item).toMatchObject({ + command: "sleep 7", + status: "interrupted", + }); + expect(eventsCarryingSlowToolOutput("item/completed")).toHaveLength(0); + }, 20_000); + it("does not open a turn for unprompted non-work updates", async () => { const { providerThreadId } = await startThread(); const turnId = sendTurnRequest("turn/start", providerThreadId, { diff --git a/packages/provider-bridge-acp/src/bridge/bridge.ts b/packages/provider-bridge-acp/src/bridge/bridge.ts index 1e51c3baff..aee0f7ded4 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.ts @@ -2412,6 +2412,16 @@ function clearAgentTurnQuietTimer(session: AcpThreadSession): void { } } +/** + * End the open agent turn. + * + * `end_turn` is a claim that the agent finished, and it is only bb's to make + * when the agent owes the turn nothing. A call the agent announced and never + * settled is work still running, so an `end_turn` over it would drain the + * row as `completed` with no output — a result the agent never reported. + * Cutting a turn short over running work is an interruption, and that is + * what the turn and its open rows settle as. + */ function settleAgentTurn( session: AcpThreadSession, stopReason: z.infer, @@ -2419,11 +2429,16 @@ function settleAgentTurn( if (session.activePromptKind !== "agent") { return; } + const settledStopReason = + stopReason === "end_turn" && + session.translator.hasOpenToolCalls(session.bbThreadId) + ? "cancelled" + : stopReason; clearAgentTurnQuietTimer(session); session.activePromptKind = null; emitForSession(session, ACP_TURN_COMPLETED_METHOD, { threadId: session.bbThreadId, - stopReason, + stopReason: settledStopReason, }); } @@ -2913,6 +2928,9 @@ async function handleRequest( } // User input ends agent-initiated work: that turn settles before the // requested one opens, so each reaches exactly one terminal state. + // `settleAgentTurn` picks the honest one — a turn cut short over a + // call the agent is still running settles as interrupted, not + // completed. settleAgentTurn(session, "end_turn"); if (session.activePromptKind !== null) { sendError(request.id, -32000, "A turn is already active");