← reports

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

Bug Priority: High Effort: Small omp · providers · provider-acp open on GitHub 2026-08-21 · base fcada5a3b

Verdict: REPRODUCED · Root-cause confidence: high

Linked PR: #2123 — verdict REQUEST CHANGES (fixes the drop, but a vouched turn is left open forever when the agent process exits, permission requests inside a vouched turn are auto-cancelled, and the 120 s quiet window hides the last message and shows "Working…" for two minutes).

1. TL;DR

When an ACP agent (the concrete case is OMP's async-job auto-delivery) streams text or tool calls without bb having sent it a session/prompt, the ACP bridge forwards those updates to the runtime but never opens a turn for them. The runtime's delta assembler, by design, refuses to let item/stream deltas open a turn, so each unprompted chunk is demoted to a thread-scoped provider/unhandled raw-event row. Those rows are persisted but hidden from the timeline unless the showUnhandledProviderEvents setting is on (it defaults to off; dev builds force it on), so the user sees a thread that simply never answered. I reproduced this end to end on fcada5a3b with a ~100-line fake ACP agent, both as a failing vitest against the real bridge + assembler and live in a dev instance (DB rows and screenshots below). The issue's mechanism is essentially right; one detail is wrong: the events are not "zero persisted", they are persisted as provider/unhandled and hidden. PR #2123 makes the bridge open a vouched turn for idle work updates and closes it after 120 s of silence; it fixes the drop but introduces a hung-thread failure mode and leaves permission requests broken inside such turns.

2. Claims vs findings

Claim from the issueStatusEvidence
Unprompted session/update work (message chunks, tool calls) with no prompt in flight never reaches the thread as a turn/message.VerifiedRepro test on main: assembled events after the prompted turn are four provider/unhandled (thread scope), zero turn/started, zero agent-message items (§4, vitest-main.log). Live DB rows seq 14–17 (§4).
"Zero persisted events between those timestamps — no turn, no items, no error."Refuted (on fcada5a3b)Each chunk IS persisted, as a thread-scoped provider/unhandled row (events-table-main.txt seq 14–17). They are hidden from the timeline in production because includeProviderUnhandledOperations = isDevelopment || settings.showUnhandledProviderEvents (apps/server/src/routes/threads/data.ts#L340-L342) and the setting defaults to false. Net user-visible effect is the same: nothing renders. The reporter's 0.39.0 DB query may have filtered on turn-scoped rows; I could not inspect it.
handleAgentNotification forwards session/update with no turn bracketing when no prompt is in flight.Verifiedplugins/provider-acp/src/bridge/bridge.ts#L2170-L2214: the only gates are stopping, loading and session-id match; it never consults activePromptKind.
noTurnFallbackFor "classifies those updates unhandled with onlyIfNoTurn: true".Partly wrongThe translator emits real item.textDelta/item.open/item.close deltas carrying a noTurnFallback payload (plugins/provider-acp/src/delta-translation.ts#L448-L466). It is the assembler that, finding no open turn, pushes the fallback as provider/unhandled (packages/provider-bridge-protocol/src/assembler/delta-assembler.ts#L1843-L1856, packages/provider-bridge-protocol/src/assembler/delta-assembler.ts#L1334-L1354). onlyIfNoTurn is only used for empty-translation updates. Same outcome, different layer.
user_message_chunk is classified as noise and swallowed.Verifiedplugins/provider-acp/src/visibility.ts#L36-L44 lists it in NOISE_ACP_UPDATE_KINDS; in the repro it produced no delta at all (delta kinds on the wire in §4).
OMP 17.4.0 over omp acp emits user_message_chunk + agent_message_chunks after session/prompt returned.UnverifiedI did not drive a real OMP async job (costs usage; this machine has omp 16.3.10). The fake agent reproduces exactly that wire shape; any ACP agent emitting idle work updates hits the same path.
Updates arriving while a prompt is in flight render fine; only the no-prompt window drops.VerifiedThe prompted chunk PROMPTED: started bg_4 rendered as an agent message in the same thread (seq 10–12).
Protocol rule 3 permits a bridge-emitted turn.open for turns the user did not initiate; compaction already does this.Verifieddocs/provider-bridge-protocol.md "Turn lifecycle" rule 3; startCompaction emits ACP_COMPACTION_STARTED_METHOD which the translator turns into a turn.
"the claude-code translator auto-opens turns on streamed text".Verifiedplugins/provider-claude-code/src/delta-translation.ts pushes { kind: "turn.open" } before stream text deltas (lines ~1056, ~1070).
Not a permission stall / not provider reaping / not the DB write path.ConsistentRepro runs in full permission mode with no permission requests; the agent process stays alive; prompted deltas persist in the same thread.

3. Environment

4. Minimal reproduction

4a. Unit level (fails on main, passes on PR #2123)

  1. Copy issue-2122-unprompted-agent.mjs and issue-2122.repro.test.ts into plugins/provider-acp/src/bridge/. The agent answers initialize/session/new/session/prompt, returns end_turn, then 150 ms later emits an unsolicited user_message_chunk, agent_message_chunk, tool_call, tool_call_update, agent_message_chunk.
  2. Run it from the repo root:
    pnpm --dir plugins/provider-acp exec vitest run src/bridge/issue-2122.repro.test.ts
  3. Expected: two turn/started, the unprompted text assembled as agent messages, no provider/unhandled. Actual on fcada5a3b:
    delta kinds on the wire: session.reset turn.open input.accepted item.textClose item.textDelta item.textClose item.textClose turn.boundary item.textClose item.textDelta item.textClose item.textClose item.open item.close item.textClose item.textDelta
    assembled event types: turn/started turn/input/accepted item/started item/agentMessage/delta item/completed turn/completed provider/unhandled(acp/update:agent_message_chunk, {"kind":"thread"}) provider/unhandled(acp/update:tool_call, {"kind":"thread"}) provider/unhandled(acp/update:tool_call_update, {"kind":"thread"}) provider/unhandled(acp/update:agent_message_chunk, {"kind":"thread"})
    agent message texts: ["PROMPTED: started bg_4"]
    
    AssertionError: expected [ { type: 'turn/started', ...(3) } ] to have a length of 2 but got 1
     > src/bridge/issue-2122.repro.test.ts:190:58
    Note the wire: the bridge does emit item.textDelta/item.open/item.close for the unprompted updates but no second turn.open; the assembler converts each into a thread-scoped provider/unhandled. Full log: vitest-main.log; assembled events: main-events.json.

4b. Live (dev instance on fcada5a3b)

  1. scripts/bb-dev-app current, then add to <data dir>/config.json (paths adjusted) and curl -X POST $BB_SERVER_URL/api/v1/system/config/reload:
    { "customAcpAgents": [ { "id": "unprompted", "displayName": "Unprompted Fake ACP",
        "command": "/path/to/node",
        "args": ["/path/to/plugins/provider-acp/src/bridge/issue-2122-unprompted-agent.mjs"],
        "env": { "UNPROMPTED_DELAY_MS": "3000" } } ] }
  2. Create a project on a scratch repo and spawn a thread:
    pnpm bb:dev thread spawn --project proj_jws2kuv28t --provider acp-unprompted --permission-mode full \
      --title "issue 2122 repro" --prompt "start job" --json      # -> thr_7hqca7hpyd
  3. After ~10 s, query the events table. Expected: a second turn with the agent's follow-up. Actual: the follow-up is four thread-scoped provider/unhandled rows (seq 14–17), no turn, no items:
    sequence  type                        scope_kind  turn_id        created_at     data                                                                                                                                                  
    --------  --------------------------  ----------  -------------  -------------  ------------------------------------------------------------------------------------------------------------------------------------------------------
    1         client/turn/requested       thread                     1787325358181  {"direction":"outbound","source":"spawn","initiator":"user","request":{"method":"thread/start","params":{}},"requestId":"creq_p73kv5cv2r","senderThrea
    2         client/thread/start         thread                     1787325358182  {"direction":"outbound","source":"spawn","initiator":"user","request":{"method":"thread/start","params":{}}}                                          
    3         system/thread-provisioning  thread                     1787325358184  {"provisioningId":"tpv_evswrstn3c","status":"active","environmentId":"env_bpmzefv6u7","entries":[{"type":"step","key":"workspace-started","text":"Prep
    4         system/thread-provisioning  thread                     1787325358274  {"provisioningId":"tpv_evswrstn3c","status":"active","environmentId":"env_bpmzefv6u7","entries":[{"type":"step","key":"workspace-path","text":"Using w
    5         system/thread-provisioning  thread                     1787325358279  {"provisioningId":"tpv_evswrstn3c","status":"active","environmentId":"env_bpmzefv6u7","entries":[]}                                                   
    6         system/thread-provisioning  thread                     1787325358297  {"provisioningId":"tpv_evswrstn3c","status":"completed","environmentId":"env_bpmzefv6u7","entries":[]}                                                
    7         thread/identity             thread                     1787325358556  {"providerThreadId":"unprompted-13245"}                                                                                                               
    8         turn/started                turn        da8fde3146-t1  1787325358556  {"providerThreadId":"unprompted-13245"}                                                                                                               
    9         turn/input/accepted         turn        da8fde3146-t1  1787325358556  {"providerThreadId":"unprompted-13245","clientRequestId":"creq_p73kv5cv2r"}                                                                           
    10        item/started                turn        da8fde3146-t1  1787325358561  {"providerThreadId":"unprompted-13245","item":{"type":"agentMessage","id":"da8fde3146-i1","text":""}}                                                 
    11        item/agentMessage/delta     turn        da8fde3146-t1  1787325358561  {"providerThreadId":"unprompted-13245","itemId":"da8fde3146-i1","delta":"PROMPTED: started bg_4"}                                                     
    12        item/completed              turn        da8fde3146-t1  1787325358561  {"providerThreadId":"unprompted-13245","item":{"type":"agentMessage","id":"da8fde3146-i1","text":"PROMPTED: started bg_4"}}                           
    13        turn/completed              turn        da8fde3146-t1  1787325358561  {"providerThreadId":"unprompted-13245","status":"completed"}                                                                                          
    14        provider/unhandled          thread                     1787325361666  {"providerThreadId":"unprompted-13245","providerId":"acp-unprompted","rawType":"acp/update:agent_message_chunk","rawEvent":{"jsonrpc":"2.0","method":"
    15        provider/unhandled          thread                     1787325361666  {"providerThreadId":"unprompted-13245","providerId":"acp-unprompted","rawType":"acp/update:tool_call","rawEvent":{"jsonrpc":"2.0","method":"acp/update
    16        provider/unhandled          thread                     1787325361666  {"providerThreadId":"unprompted-13245","providerId":"acp-unprompted","rawType":"acp/update:tool_call_update","rawEvent":{"jsonrpc":"2.0","method":"acp
    17        provider/unhandled          thread                     1787325361666  {"providerThreadId":"unprompted-13245","providerId":"acp-unprompted","rawType":"acp/update:agent_message_chunk","rawEvent":{"jsonrpc":"2.0","method":"
    
  4. The timeline API returns the four rows only as system rows titled "Unhandled Unprompted Fake ACP event" — and only because dev mode forces includeProviderUnhandledOperations; in a packaged build they are filtered out entirely.
Thread on main: the prompted message renders, the unprompted follow-up appears only as four greyed 'Unhandled ... event' rows
main (dev build): "PROMPTED: started bg_4" rendered normally; the agent-initiated follow-up shows only as four greyed "Unhandled Unprompted Fake ACP event" system rows. In a production build these rows are hidden (setting off by default), so the user sees nothing at all.
Expanded unhandled rows showing the raw agent_message_chunk payloads with the lost text
Expanding two of the rows: the raw acp/update:agent_message_chunk payloads carry the lost text "UNPROMPTED: job bg_4 finished, " and "the answer is 42.".

Repro files: 2122/repro/

issue-2122.repro.test.ts (inline)
/**
 * Repro for get-bb/bb#2122: provider-acp drops agent-initiated turns.
 *
 * Drives the real bridge (`handleLine`) with a fake ACP agent that, after a
 * normal prompt completes, emits unsolicited session/update notifications
 * (user_message_chunk + agent_message_chunks + tool_call/update) with no
 * prompt in flight. The bridge output is run through the real runtime delta
 * assembler, exactly as the bridge-protocol adapter does.
 *
 * On main (fcada5a3b) the "agent-initiated" assertions FAIL: the unprompted
 * text never becomes an agentMessage item and no second turn is opened.
 * Instead each chunk surfaces as a thread-scoped `provider/unhandled` row.
 */
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { THREAD_DELTA_NOTIFICATION_METHOD } from "@bb/provider-bridge-protocol";
import {
  experimental_assembleCapturedThreadEvents as assembleCapturedThreadEvents,
  experimental_captureBridgeJsonRpcOutput as captureBridgeJsonRpcOutput,
} from "@get-bb/plugin-sdk/provider-bridge/testing";
import type { CapturedBridgeJsonRpcOutput } from "@get-bb/plugin-sdk/provider-bridge/testing";

import { handleLine } from "./bridge.js";

const AGENT_PATH = resolve(
  dirname(fileURLToPath(import.meta.url)),
  "issue-2122-unprompted-agent.mjs",
);

let output: CapturedBridgeJsonRpcOutput;
let workspaceDir: string;
let nextRequestId = 100;

function sendRequest(method: string, params: object): number {
  nextRequestId += 1;
  handleLine(
    JSON.stringify({ jsonrpc: "2.0", id: nextRequestId, method, params }),
  );
  return nextRequestId;
}

async function waitFor<T>(
  resolveValue: () => T | undefined,
  description: string,
  timeoutMs = 10_000,
): Promise<T> {
  const deadline = Date.now() + timeoutMs;
  for (;;) {
    const value = resolveValue();
    if (value !== undefined) return value;
    if (Date.now() > deadline) {
      // eslint-disable-next-line no-console
      console.log("bridge output so far:", JSON.stringify(output.messages, null, 1));
      throw new Error(`Timed out: ${description}`);
    }
    await new Promise((r) => setTimeout(r, 20));
  }
}

function events(): Record<string, unknown>[] {
  return assembleCapturedThreadEvents(
    output.messages,
    "acp",
  ) as unknown as Record<string, unknown>[];
}

function agentMessageTexts(): string[] {
  const texts = new Map<string, string>();
  for (const event of events()) {
    if (event.type === "item/agentMessage/delta") {
      const id = String(event.itemId);
      texts.set(id, (texts.get(id) ?? "") + String(event.delta ?? ""));
    } else if (event.type === "item/completed") {
      const item = event.item as { id: string; type: string; text?: string };
      if (item.type === "agentMessage") texts.set(item.id, item.text ?? "");
    }
  }
  return [...texts.values()];
}

const fullOptions = {
  permissionMode: "full",
  permissionScope: "full",
  approvalReviewer: null,
  permissionEscalation: null,
  providerOptions: {
    acpLaunchSpec: {
      displayName: "Unprompted ACP",
      command: process.execPath,
      args: [AGENT_PATH],
      env: {},
    },
  },
};

beforeEach(() => {
  workspaceDir = mkdtempSync(join(tmpdir(), "bb-2122-"));
  output = captureBridgeJsonRpcOutput();
});

afterEach(() => {
  output.restore();
  rmSync(workspaceDir, { recursive: true, force: true });
});

describe("issue #2122: agent-initiated ACP turns", () => {
  it("renders unprompted agent output as a turn with agent messages", async () => {
    const doneFile = join(workspaceDir, "unprompted.done");
    const startId = sendRequest("thread/start", {
      threadId: "thread-2122",
      cwd: workspaceDir,
      instructionMode: "append",
      options: {
        ...fullOptions,
        envVars: { UNPROMPTED_DONE_FILE: doneFile },
      },
    });
    const start = await waitFor(
      () => output.messages.find((m) => m.id === startId),
      "thread/start response",
    );
    const providerThreadId = (start.result as { providerThreadId: string })
      .providerThreadId;

    sendRequest("turn/start", {
      threadId: "thread-2122",
      providerThreadId,
      clientRequestId: "creq_abcdefghjk",
      options: fullOptions,
      input: [{ type: "text", text: "start job", mentions: [] }],
    });
    await waitFor(
      () => events().find((e) => e.type === "turn/completed"),
      "first turn/completed",
    );
    // Let the agent finish its unprompted follow-up, then give the bridge a
    // moment to forward everything.
    await waitFor(
      () => (existsSync(doneFile) ? true : undefined),
      "agent done",
    );
    await new Promise((r) => setTimeout(r, 300));

    const all = events();
    const kinds = output.messages
      .filter((m) => m.method === THREAD_DELTA_NOTIFICATION_METHOD)
      .flatMap((m) =>
        ((m.params as { deltas: { kind: string }[] }).deltas ?? []).map(
          (d) => d.kind,
        ),
      );
    const dump = process.env.ISSUE_2122_DUMP;
    if (dump) {
      writeFileSync(
        dump,
        JSON.stringify(
          { deltaKinds: kinds, events: all, bridgeOutput: output.messages },
          null,
          2,
        ),
      );
    }
    // eslint-disable-next-line no-console
    console.log("delta kinds on the wire:", kinds.join(" "));
    // eslint-disable-next-line no-console
    console.log(
      "assembled event types:",
      all
        .map(
          (e) =>
            `${String(e.type)}${
              e.type === "provider/unhandled"
                ? `(${String(e.rawType)}, ${JSON.stringify(e.scope)})`
                : ""
            }`,
        )
        .join(" "),
    );
    // eslint-disable-next-line no-console
    console.log("agent message texts:", JSON.stringify(agentMessageTexts()));

    // The prompted turn works.
    expect(agentMessageTexts()).toContain("PROMPTED: started bg_4");

    // --- Agent-initiated turn (the bug) ---
    // A second turn should have been opened for the unprompted work.
    expect(all.filter((e) => e.type === "turn/started")).toHaveLength(2);
    // The unprompted text must reach the thread as an agent message.
    expect(agentMessageTexts().join("")).toContain(
      "UNPROMPTED: job bg_4 finished, the answer is 42.",
    );
    // No chunk may fall through to the thread-scoped raw-event bin.
    expect(all.filter((e) => e.type === "provider/unhandled")).toHaveLength(0);

    sendRequest("thread/stop", {
      threadId: "thread-2122",
      providerThreadId,
      intent: "interrupt",
      activeTurnId: null,
    });
  }, 30_000);
});
issue-2122-unprompted-agent.mjs (inline)
#!/usr/bin/env node
// Minimal ACP agent for get-bb/bb#2122.
//
// Answers initialize / session/new / session/prompt, and after the prompt
// result has been returned (no prompt in flight), emits an *agent-initiated*
// turn: a user_message_chunk (OMP echoes the injected async-job result),
// agent_message_chunks, and a tool_call that completes. This is the wire
// shape OMP's async-job auto-delivery produces.
import { createInterface } from "node:readline";

const sessionId = `unprompted-${process.pid}`;
const delayMs = Number(process.env.UNPROMPTED_DELAY_MS ?? "150");
function send(m) {
  process.stdout.write(JSON.stringify(m) + "\n");
}
function update(update) {
  send({ jsonrpc: "2.0", method: "session/update", params: { sessionId, update } });
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function unpromptedTurn() {
  await sleep(delayMs);
  update({
    sessionUpdate: "user_message_chunk",
    content: { type: "text", text: "[bg_4 finished] exit 0" },
  });
  update({
    sessionUpdate: "agent_message_chunk",
    content: { type: "text", text: "UNPROMPTED: job bg_4 finished, " },
  });
  update({
    sessionUpdate: "tool_call",
    toolCallId: "unprompted-tool-1",
    title: "cat result.txt",
    kind: "read",
    status: "pending",
    rawInput: { path: "result.txt" },
  });
  update({
    sessionUpdate: "tool_call_update",
    toolCallId: "unprompted-tool-1",
    status: "completed",
    content: [{ type: "content", content: { type: "text", text: "42" } }],
  });
  if (process.env.UNPROMPTED_ASK_PERMISSION === "1") {
    // An agent-initiated tool call that needs approval (non-yolo agents).
    send({
      jsonrpc: "2.0",
      id: 9001,
      method: "session/request_permission",
      params: {
        sessionId,
        toolCall: {
          toolCallId: "unprompted-tool-2",
          title: "rm -rf build",
          kind: "execute",
          rawInput: { command: "rm -rf build" },
        },
        options: [
          { optionId: "yes", name: "Allow", kind: "allow_once" },
          { optionId: "no", name: "Deny", kind: "reject_once" },
        ],
      },
    });
  }
  update({
    sessionUpdate: "agent_message_chunk",
    content: { type: "text", text: "the answer is 42." },
  });
  if (process.env.UNPROMPTED_DONE_FILE) {
    const { writeFileSync } = await import("node:fs");
    writeFileSync(process.env.UNPROMPTED_DONE_FILE, "done\n");
  }
  if (process.env.UNPROMPTED_EXIT_AFTER === "1") {
    // Crash mid agent-initiated turn (e.g. the agent process dies).
    await sleep(100);
    process.exit(3);
  }
}

const rl = createInterface({ input: process.stdin, terminal: false });
rl.on("line", (line) => {
  let message;
  try {
    message = JSON.parse(line);
  } catch {
    return;
  }
  if (message.method === undefined) {
    // Responses to agent requests: record the permission outcome.
    if (message.id === 9001) {
      update({
        sessionUpdate: "agent_message_chunk",
        content: {
          type: "text",
          text: ` permission:${JSON.stringify(message.result ?? message.error)}`,
        },
      });
      if (process.env.UNPROMPTED_DONE_FILE) {
        import("node:fs").then(({ writeFileSync }) =>
          writeFileSync(process.env.UNPROMPTED_DONE_FILE + ".perm", "done\n"),
        );
      }
    }
    return;
  }
  switch (message.method) {
    case "initialize":
      send({
        jsonrpc: "2.0",
        id: message.id,
        result: {
          protocolVersion: 1,
          agentCapabilities: { loadSession: false, promptCapabilities: { image: false } },
        },
      });
      return;
    case "session/new":
      send({ jsonrpc: "2.0", id: message.id, result: { sessionId } });
      return;
    case "session/prompt":
      update({
        sessionUpdate: "agent_message_chunk",
        content: { type: "text", text: "PROMPTED: started bg_4" },
      });
      send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } });
      // Agent-initiated follow-up with no prompt in flight.
      void unpromptedTurn();
      return;
    case "session/cancel":
      return;
    default:
      if (message.id !== undefined) {
        send({
          jsonrpc: "2.0",
          id: message.id,
          error: { code: -32601, message: `Unknown method ${message.method}` },
        });
      }
  }
});
rl.on("close", () => process.exit(0));

5. Root cause

Three pieces, each individually "by design", compose into a silent drop:

  1. The bridge only opens turns for prompts it sent. runTurn sets activePromptKind = "turn" and emits ACP_TURN_STARTED_METHOD (plugins/provider-acp/src/bridge/bridge.ts#L1996-L2002); finishTurn emits the completion when session/prompt resolves. handleAgentNotification (plugins/provider-acp/src/bridge/bridge.ts#L2170-L2214) forwards every session/update to the translator regardless of whether a prompt is in flight. ACP itself has no "agent-initiated turn" bracket, so nothing ever opens one.
  2. The translator is context-free. agent_message_chunk becomes an item.textDelta with a noTurnFallback payload (plugins/provider-acp/src/delta-translation.ts#L448-L466); tool_call/tool_call_update become item.open/item.close with the same fallback. It cannot open a turn because it does not know whether bb asked for this work.
  3. The assembler refuses to let item deltas open a turn (turn-opening rule, packages/provider-bridge-protocol/src/assembler/delta-assembler.ts#L18-L24). With state.currentTurnId === undefined every one of those deltas takes the pushNoTurnFallback branch (packages/provider-bridge-protocol/src/assembler/delta-assembler.ts#L1843-L1856, packages/provider-bridge-protocol/src/assembler/delta-assembler.ts#L1334-L1354) and is emitted as a thread-scoped provider/unhandled. This is the intentional "no active turn" visibility guard — correct for stray noise, wrong for real work.

Why the user sees nothing: provider/unhandled is persisted, but the timeline projection drops it unless includeProviderUnhandledOperations is set (packages/thread-view/src/parse-operation-message.ts#L442-L445), which the server derives from isDevelopment || showUnhandledProviderEvents (apps/server/src/routes/threads/data.ts#L340-L342); the setting defaults to false (packages/domain/src/app-settings.ts). So in the packaged app the agent's work is invisible, and even in dev it is an opaque "Unhandled … event" row rather than a message.

Deeper issue: the bridge keeps a single mirror of "is a turn open" in activePromptKind, and several paths key off it as if it were authoritative: emitSessionError only settles a turn when it is non-null (plugins/provider-acp/src/bridge/bridge.ts#L324-L349), handlePermissionRequest auto-cancels every session/request_permission unless it is exactly "turn" (plugins/provider-acp/src/bridge/bridge.ts#L1340-L1358), and turn/steer is rejected otherwise (plugins/provider-acp/src/bridge/bridge.ts#L2550-L2556). Any fix that opens a turn for agent-initiated work has to teach all of those paths about the new kind of turn — PR #2123 does not (see §7).

6. Proposed fix (first principles)

The right layer is the ACP bridge (provider translation lives in the daemon/plugin per AGENTS.md; no wire change, no HOST_DAEMON_PROTOCOL_VERSION bump). The bridge is the only component that knows whether bb asked for the work, and protocol rule 3 sanctions a bridge-emitted turn.open for provider-internal activity. Concretely:

  1. Make the open-turn mirror cover the new case instead of adding a parallel boolean: e.g. activePromptKind: "turn" | "compaction" | "agent" | null. In handleAgentNotification, when activePromptKind === null and the update is a work kind (reuse NORMALIZED_ACP_UPDATE_KINDS minus usage_update from visibility.ts rather than a second hand-maintained map), set "agent" and emit ACP_TURN_STARTED_METHOD.
  2. Settle that turn on every exit path, not just the happy ones: before the next turn/start/compaction (end_turn), on thread/stop interrupt (cancelled), and — missing in the PR — on agent process exit / emitSessionError (emit the settling error delta while the turn is still marked open, so the assembler's provider.error { settlesTurn } closes it as failed). Rule 1 ("every turn reaches exactly one terminal state") must hold for vouched turns too.
  3. Treat "agent" like "turn" in handlePermissionRequest (full mode auto-allows; other modes forward to the user). Without this, an OMP follow-up that runs a tool under a non-yolo policy is silently denied, and even full mode cancels it.
  4. Bound the turn by a much shorter quiet window than 120 s, and/or close it on a positive end signal if the agent emits one (OMP may send usage_update at the end of a turn — experiment: record omp acp with BB_PROVIDER_BRIDGE_RECORD_DIR during an async-job delivery and look at the last notification). With a 120 s window the server buffers the final streamed message until the turn flushes (§7 screenshot), the thread shows "Working…" with a Stop button for two minutes, and "Worked for 2m" is reported for sub-second work. A 5–10 s window re-armed per chunk, with the "split turn" cost accepted, is a far better trade.
  5. turn/steer during an agent turn already degrades gracefully (runtime maps NO_ACTIVE_TURN to a fresh turn/start, packages/agent-runtime/src/runtime.ts ~L2066) — keep that; runTurn settles the agent turn first.

Risk: a provider that streams idle noise as agent_message_chunk (none known; Cursor/opencode/grok only do so inside prompts) would now produce phantom turns. The work-kind allowlist plus the existing noise list keeps that narrow.

7. PR review — #2123 "Vouch agent-initiated ACP turns so async job delivery renders"

What it changes (diff, +224/−4, bridge-only): adds spontaneousTurnOpen/spontaneousQuietTimer to the session; in handleAgentNotification, when activePromptKind === null and the update kind is in a new AGENT_WORK_UPDATE_KINDS map, emits ACP_TURN_STARTED_METHOD once and (re)arms a 120 s timer that emits ACP_TURN_COMPLETED_METHOD { end_turn }; runTurn/startCompaction settle a still-open vouched turn first; stopSession settles it as cancelled; removeSession clears the flag/timer. Adds two fake-agent behaviours and three tests. Translator, assembler, wire: untouched.

Root cause vs symptom: it addresses the actual mechanism (no turn.open for agent-initiated work) at the correct layer, in the shape rule 3 sanctions. My repro test passes on it (log, events), and live the follow-up renders as turn t2 with agent messages and a tool row (DB rows). turbo run test typecheck lint --filter=bb-plugin-provider-acp --force on the rebased branch: 7/7 tasks green. No protocol bump needed (no wire shape change) — correct.

Findings

#SeverityWhereFinding
1Highbridge.ts onExit → removeSession → clearSpontaneousTurn (PR hunk at base L1571 / L2193); emitSessionError unchanged (base L324–349)A vouched turn is never settled when the agent process exits. removeSession silently clears the flag, then emitSessionError skips the settling error delta because activePromptKind === null. The assembler keeps turn t2 open forever: the thread shows "Working…" with a spinner and Stop button indefinitely (screenshot below, 2.5 min after the crash; DB shows only a thread-scoped provider/warning, no turn/completed). This is the hung-thread class rule 1 exists to prevent, and it is a regression versus main, where an agent dying while idle leaves the thread idle. Repro: issue-2122.pr-edges.test.ts test 1 fails with expected turn/completed length 2, got 1. Fix: call settleSpontaneousTurn(session, "cancelled") (or emit the error while the turn is still open) in onExit before removeSession.
2Highbridge.ts handlePermissionRequest (base L1340–1358, unchanged by PR)Permission requests raised during a vouched turn are auto-answered cancelled — even in full mode, because the activePromptKind !== "turn" guard runs before the full-mode auto-allow. An agent-initiated turn that needs to run a tool therefore renders the tool call and then gets it denied. Pre-existing code, but the PR's premise ("output is never lost", agent-initiated turns are real turns) makes it in scope. Repro: pr-edges test 2: the agent receives {"outcome":{"outcome":"cancelled"}}.
3MediumSPONTANEOUS_TURN_IDLE_TIMEOUT_MS = 120_000The 120 s quiet window is user-visible and misleading. Measured live: last chunk at 1787325866731, item/completed+turn/completed at 1787325986719 (119.99 s later). During that window: (a) the server's assistant-text buffering holds the last streamed message until a flush, so a reload shows "Working…" instead of "the answer is 42." (screenshot); (b) the thread is "running" — spinner in the sidebar, Stop button in the composer — for two minutes after the agent finished; (c) after close the turn is summarised as "Worked for 2m" for sub-second work; (d) turn-completed notifications and attention state are delayed by two minutes. A 5–10 s window, or an end signal, would be far better; the PR offers no rationale for 120 s.
4LowAGENT_WORK_UPDATE_KINDSDuplicates NORMALIZED_ACP_UPDATE_KINDS in visibility.ts (minus usage_update). Derive from the visibility metadata so a future update kind cannot be "normalized" in one place and "noise" in the other.
5Lowparallel spontaneousTurnOpen booleanAdds a second "is a turn open" mirror next to activePromptKind; every reader of the old mirror (emitSessionError, handlePermissionRequest, turn/steer, stopSession's cancel path) silently keeps the old semantics — findings 1 and 2 are direct consequences. Folding it into activePromptKind (e.g. "agent") forces each reader to decide.
6LowtestsThe three new tests cover open/quiet-close, settle-before-next-turn, and noise-does-not-open. No test for agent exit, thread/stop interrupt, permission requests, or a steer arriving during a vouched turn. The second test leaves the production 120 s timer armed until afterEach's stopThread; fine, but brittle if the teardown changes.
7OKlayering / protocolBridge-local, no server/daemon boundary move, no wire change, so no HOST_DAEMON_PROTOCOL_VERSION bump required. No casts or unknown smuggling. Test hook __setSpontaneousTurnIdleTimeoutForTests has precedent (resetPiModelRuntimesForTests).
8NotebranchBased on c9aef7514, 39 commits behind main; conflicts with the test-helper move to @get-bb/plugin-sdk/provider-bridge/testing. Needs a rebase before CI can pass.
PR branch: unprompted follow-up renders, but the last message is hidden behind Working… during the 120 s window
PR #2123, ~10 s after the agent finished: the follow-up now renders as a turn ("UNPROMPTED: job bg_4 finished,", "Read result.txt"), but the final message "the answer is 42." is buffered behind "Working…" and the thread stays running (Stop button, sidebar spinner) for the full 120 s quiet window.
PR branch after 120 s: turn closed, summarised as Worked for 2m
Same thread after the quiet window: the turn closed, the last message appears, and the work is summarised as "Worked for 2m" although the agent was busy for under a second.
PR branch: agent crashed mid vouched turn, thread still Working… 2.5 minutes later
Finding 1: the agent process exited (exit 3) right after its unprompted output. 2.5 minutes later the thread is still "Working…" with an active Stop button; the only trace is the greyed "ACP agent … exited unexpectedly" warning row. No turn/completed will ever arrive.

Tests I ran on the rebased PR: my repro test (passes); the PR's three tests (pass); full bb-plugin-provider-acp test+typecheck+lint via turbo (green); two hostile probes (pr-edges, both fail — log); live dev-instance runs of a normal and a crashing agent (DB: during window, after window).

Verdict: REQUEST CHANGES. The approach is right and it demonstrably fixes the reported drop, but as written it trades a silent drop for a hung thread whenever the agent dies mid-turn, leaves agent-initiated tool permissions broken, and the 120 s window produces visibly wrong UI for two minutes per delivery. Fix findings 1–3 (and ideally 5), add tests for exit/stop/permission inside a vouched turn, rebase, and it is mergeable.

pr-edges test output (inline)
 RUN  v4.1.1 /Users/USER/.bb-machines/HOST.getbb.app/checkouts/bb/.claude/worktrees/wf_21e66a79-f02-4/plugins/provider-acp

(node:42252) ExperimentalWarning: SQLite is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
stdout | src/bridge/issue-2122.pr-edges.test.ts > PR #2123 edge cases > settles a vouched turn when the agent process exits mid-turn
after agent exit: turn/started turn/input/accepted item/started item/agentMessage/delta item/completed turn/completed turn/started item/started item/agentMessage/delta item/completed item/started item/completed item/started item/agentMessage/delta

stdout | src/bridge/issue-2122.pr-edges.test.ts > PR #2123 edge cases > does not auto-cancel a permission request raised during a vouched turn (full mode)
agent text: PROMPTED: started bg_4UNPROMPTED: job bg_4 finished, the answer is 42. permission:{"outcome":{"outcome":"cancelled"}}

 ❯  bb-plugin-provider-acp  src/bridge/issue-2122.pr-edges.test.ts (2 tests | 2 failed) 1144ms
     × settles a vouched turn when the agent process exits mid-turn 629ms
     × does not auto-cancel a permission request raised during a vouched turn (full mode) 514ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   bb-plugin-provider-acp  src/bridge/issue-2122.pr-edges.test.ts > PR #2123 edge cases > settles a vouched turn when the agent process exits mid-turn
AssertionError: expected [ { type: 'turn/completed', …(4) } ] to have a length of 2 but got 1

- Expected
+ Received

- 2
+ 1

 ❯ src/bridge/issue-2122.pr-edges.test.ts:166:23
    164|     expect(started).toHaveLength(2);
    165|     // The vouched turn must reach a terminal state when its agent die…
    166|     expect(completed).toHaveLength(2);
       |                       ^
    167|   }, 30_000);
    168|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL   bb-plugin-provider-acp  src/bridge/issue-2122.pr-edges.test.ts > PR #2123 edge cases > does not auto-cancel a permission request raised during a vouched turn (full mode)
AssertionError: expected 'PROMPTED: started bg_4UNPROMPTED: job…' to contain '"optionId":"yes"'

Expected: ""optionId":"yes""
Received: "PROMPTED: started bg_4UNPROMPTED: job bg_4 finished, the answer is 42. permission:{"outcome":{"outcome":"cancelled"}}"

 ❯ src/bridge/issue-2122.pr-edges.test.ts:185:18
    183|     console.log("agent text:", text);
    184|     // In full mode the bridge auto-allows; a vouched turn must get th…
    185|     expect(text).toContain('"optionId":"yes"');
       |                  ^
    186|     expect(text).not.toContain('"outcome":"cancelled"');
    187|   }, 30_000);

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯


 Test Files  1 failed (1)
      Tests  2 failed (2)
   Start at  08:21:53
   Duration  1.63s (transform 291ms, setup 0ms, import 419ms, tests 1.14s, environment 0ms)

issue-2122.pr-edges.test.ts (inline)
/**
 * Hostile probes for PR #2123 (vouched agent-initiated ACP turns).
 *
 * 1. The agent process exits while a vouched turn is open: the turn must
 *    still reach a terminal state (otherwise the thread hangs "working").
 * 2. The agent asks for permission during a vouched turn: the bridge must
 *    not auto-cancel it (the turn is real work, not an idle session).
 */
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
  experimental_assembleCapturedThreadEvents as assembleCapturedThreadEvents,
  experimental_captureBridgeJsonRpcOutput as captureBridgeJsonRpcOutput,
} from "@get-bb/plugin-sdk/provider-bridge/testing";
import type { CapturedBridgeJsonRpcOutput } from "@get-bb/plugin-sdk/provider-bridge/testing";

import { handleLine } from "./bridge.js";

const AGENT_PATH = resolve(
  dirname(fileURLToPath(import.meta.url)),
  "issue-2122-unprompted-agent.mjs",
);

let output: CapturedBridgeJsonRpcOutput;
let workspaceDir: string;
let nextRequestId = 500;

function sendRequest(method: string, params: object): number {
  nextRequestId += 1;
  handleLine(
    JSON.stringify({ jsonrpc: "2.0", id: nextRequestId, method, params }),
  );
  return nextRequestId;
}

async function waitFor<T>(
  resolveValue: () => T | undefined,
  description: string,
  timeoutMs = 10_000,
): Promise<T> {
  const deadline = Date.now() + timeoutMs;
  for (;;) {
    const value = resolveValue();
    if (value !== undefined) return value;
    if (Date.now() > deadline) throw new Error(`Timed out: ${description}`);
    await new Promise((r) => setTimeout(r, 20));
  }
}

function events(): Record<string, unknown>[] {
  return assembleCapturedThreadEvents(
    output.messages,
    "acp",
  ) as unknown as Record<string, unknown>[];
}

function agentText(): string {
  let text = "";
  for (const event of events()) {
    if (event.type === "item/agentMessage/delta") {
      text += String(event.delta ?? "");
    }
  }
  return text;
}

function optionsWith(envVars: Record<string, string>, mode: "full" | "accept-edits") {
  return {
    ...(mode === "full"
      ? {
          permissionMode: "full",
          permissionScope: "full",
          approvalReviewer: null,
          permissionEscalation: null,
        }
      : {
          permissionMode: "accept-edits",
          permissionScope: "workspace",
          approvalReviewer: "user",
          permissionEscalation: "ask",
        }),
    envVars,
    providerOptions: {
      acpLaunchSpec: {
        displayName: "Unprompted ACP",
        command: process.execPath,
        args: [AGENT_PATH],
        env: {},
      },
    },
  };
}

async function startAndPrompt(
  threadId: string,
  envVars: Record<string, string>,
  mode: "full" | "accept-edits",
): Promise<string> {
  const options = optionsWith(envVars, mode);
  const startId = sendRequest("thread/start", {
    threadId,
    cwd: workspaceDir,
    instructionMode: "append",
    options,
  });
  const start = await waitFor(
    () => output.messages.find((m) => m.id === startId),
    "thread/start response",
  );
  const providerThreadId = (start.result as { providerThreadId: string })
    .providerThreadId;
  sendRequest("turn/start", {
    threadId,
    providerThreadId,
    clientRequestId: "creq_abcdefghjk",
    options,
    input: [{ type: "text", text: "start job", mentions: [] }],
  });
  await waitFor(
    () => events().find((e) => e.type === "turn/completed"),
    "first turn/completed",
  );
  return providerThreadId;
}

beforeEach(() => {
  workspaceDir = mkdtempSync(join(tmpdir(), "bb-2122-edges-"));
  output = captureBridgeJsonRpcOutput();
});

afterEach(() => {
  output.restore();
  rmSync(workspaceDir, { recursive: true, force: true });
});

describe("PR #2123 edge cases", () => {
  it("settles a vouched turn when the agent process exits mid-turn", async () => {
    const doneFile = join(workspaceDir, "done");
    await startAndPrompt(
      "thread-2122-exit",
      { UNPROMPTED_DONE_FILE: doneFile, UNPROMPTED_EXIT_AFTER: "1" },
      "full",
    );
    await waitFor(() => (existsSync(doneFile) ? true : undefined), "agent done");
    // Agent exits ~100ms after its last chunk; wait for the bridge's exit
    // handling (error notification) to land.
    await waitFor(
      () => output.messages.find((m) => m.method === "error"),
      "bridge error notification after agent exit",
    );
    await new Promise((r) => setTimeout(r, 300));

    const all = events();
    const started = all.filter((e) => e.type === "turn/started");
    const completed = all.filter((e) => e.type === "turn/completed");
    // eslint-disable-next-line no-console
    console.log(
      "after agent exit:",
      all.map((e) => String(e.type)).join(" "),
    );
    expect(started).toHaveLength(2);
    // The vouched turn must reach a terminal state when its agent dies.
    expect(completed).toHaveLength(2);
  }, 30_000);

  it("does not auto-cancel a permission request raised during a vouched turn (full mode)", async () => {
    const doneFile = join(workspaceDir, "done");
    await startAndPrompt(
      "thread-2122-perm",
      { UNPROMPTED_DONE_FILE: doneFile, UNPROMPTED_ASK_PERMISSION: "1" },
      "full",
    );
    await waitFor(
      () => (existsSync(doneFile + ".perm") ? true : undefined),
      "permission answered",
    );
    await new Promise((r) => setTimeout(r, 300));
    const text = agentText();
    // eslint-disable-next-line no-console
    console.log("agent text:", text);
    // In full mode the bridge auto-allows; a vouched turn must get the same.
    expect(text).toContain('"optionId":"yes"');
    expect(text).not.toContain('"outcome":"cancelled"');
  }, 30_000);
});

8. Related issues

9. Appendix

Commands run

git rev-parse HEAD                                  # fcada5a3b88302acb9944aa74b11db4ecaa215a0
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --output-logs=errors-only
gh issue view 2122 -R get-bb/bb --json ...
gh pr view 2123 -R get-bb/bb --json ... ; gh pr diff 2123 > pr-2123.diff

# unit repro (main)
pnpm --dir plugins/provider-acp exec vitest run src/bridge/issue-2122.repro.test.ts   # FAILS on main

# live repro (main)
scripts/bb-dev-app current ; scripts/bb-dev-app env
<write customAcpAgents into <data dir>/config.json>
curl -s -X POST http://localhost:23768/api/v1/system/config/reload
pnpm bb:dev provider list                            # shows acp-unprompted
git init /tmp/bb-2122-qa ; curl -X POST .../api/v1/projects {local_path ... hostId}
node packages/scripts/dist/commands/run-cli.js thread spawn --project proj_jws2kuv28t --provider acp-unprompted --permission-mode full --title "issue 2122 repro" --prompt "start job" --json
sqlite3 <data dir>/bb.db "select sequence,type,scope_kind,turn_id,created_at,substr(data,1,150) from events where thread_id='thr_7hqca7hpyd' order by sequence"
curl -s http://localhost:23768/api/v1/threads/thr_7hqca7hpyd/timeline
doobie --headless ... page.screenshot(...)

# PR review
gh pr checkout 2123 ; git checkout -b pr-2123-rebased ; git rebase fcada5a3b   # 1 import conflict in bridge.test.ts, resolved
pnpm --dir plugins/provider-acp exec vitest run src/bridge/issue-2122.repro.test.ts          # passes
pnpm --dir plugins/provider-acp exec vitest run src/bridge/bridge.test.ts -t "spontaneous"    # 3 passed
pnpm exec turbo run test typecheck lint --filter=bb-plugin-provider-acp --force               # 7/7 green
pnpm --dir plugins/provider-acp exec vitest run src/bridge/issue-2122.pr-edges.test.ts        # 2 failed (findings 1, 2)
scripts/bb-dev-app current   # restart on pr-2123-rebased; second custom agent with UNPROMPTED_EXIT_AFTER=1
thread spawn --provider acp-unprompted ... (thr_i4fi2raq6u) ; --provider acp-unprompted-crash ... (thr_t6m373xpvh)
sqlite3 ... events for both threads, before and after the 120 s quiet window

# cleanup
pnpm dev:stop ; rm -rf <data dir> /tmp/bb-2122-qa ; lsof port check

DB rows — PR branch, during the quiet window

thread_id       sequence  type                     scope_kind  turn_id        data                                                                                                          
--------------  --------  -----------------------  ----------  -------------  --------------------------------------------------------------------------------------------------------------
thr_i4fi2raq6u  3         thread/identity          thread                     {"providerThreadId":"unprompted-55683"}                                                                       
thr_i4fi2raq6u  4         turn/started             turn        dac539b301-t1  {"providerThreadId":"unprompted-55683"}                                                                       
thr_i4fi2raq6u  5         turn/input/accepted      turn        dac539b301-t1  {"providerThreadId":"unprompted-55683","clientRequestId":"creq_cnzrsdukyc"}                                   
thr_i4fi2raq6u  6         item/started             turn        dac539b301-t1  {"providerThreadId":"unprompted-55683","item":{"type":"agentMessage","id":"dac539b301-i1","text":""}}         
thr_i4fi2raq6u  7         item/agentMessage/delta  turn        dac539b301-t1  {"providerThreadId":"unprompted-55683","itemId":"dac539b301-i1","delta":"PROMPTED: started bg_4"}             
thr_i4fi2raq6u  8         item/completed           turn        dac539b301-t1  {"providerThreadId":"unprompted-55683","item":{"type":"agentMessage","id":"dac539b301-i1","text":"PROMPTED: st
thr_i4fi2raq6u  9         turn/completed           turn        dac539b301-t1  {"providerThreadId":"unprompted-55683","status":"completed"}                                                  
thr_i4fi2raq6u  10        turn/started             turn        dac539b301-t2  {"providerThreadId":"unprompted-55683"}                                                                       
thr_i4fi2raq6u  11        item/started             turn        dac539b301-t2  {"providerThreadId":"unprompted-55683","item":{"type":"agentMessage","id":"dac539b301-i2","text":""}}         
thr_i4fi2raq6u  12        item/agentMessage/delta  turn        dac539b301-t2  {"providerThreadId":"unprompted-55683","itemId":"dac539b301-i2","delta":"UNPROMPTED: job bg_4 finished, "}    
thr_i4fi2raq6u  13        item/completed           turn        dac539b301-t2  {"providerThreadId":"unprompted-55683","item":{"type":"agentMessage","id":"dac539b301-i2","text":"UNPROMPTED: 
thr_i4fi2raq6u  14        item/started             turn        dac539b301-t2  {"providerThreadId":"unprompted-55683","item":{"type":"fileRead","id":"dac539b301-i3","path":"result.txt","sta
thr_i4fi2raq6u  15        item/completed           turn        dac539b301-t2  {"providerThreadId":"unprompted-55683","item":{"type":"fileRead","id":"dac539b301-i3","path":"result.txt","sta
thr_i4fi2raq6u  16        item/started             turn        dac539b301-t2  {"providerThreadId":"unprompted-55683","item":{"type":"agentMessage","id":"dac539b301-i4","text":""}}         
thr_i4fi2raq6u  17        item/agentMessage/delta  turn        dac539b301-t2  {"providerThreadId":"unprompted-55683","itemId":"dac539b301-i4","delta":"the answer is 42."}                  
thr_t6m373xpvh  3         thread/identity          thread                     {"providerThreadId":"unprompted-55791"}                                                                       
thr_t6m373xpvh  4         turn/started             turn        da4ebcb1ee-t1  {"providerThreadId":"unprompted-55791"}                                                                       
thr_t6m373xpvh  5         turn/input/accepted      turn        da4ebcb1ee-t1  {"providerThreadId":"unprompted-55791","clientRequestId":"creq_xtuj6gygwy"}                                   
thr_t6m373xpvh  6         item/started             turn        da4ebcb1ee-t1  {"providerThreadId":"unprompted-55791","item":{"type":"agentMessage","id":"da4ebcb1ee-i1","text":""}}         
thr_t6m373xpvh  7         item/agentMessage/delta  turn        da4ebcb1ee-t1  {"providerThreadId":"unprompted-55791","itemId":"da4ebcb1ee-i1","delta":"PROMPTED: started bg_4"}             
thr_t6m373xpvh  8         item/completed           turn        da4ebcb1ee-t1  {"providerThreadId":"unprompted-55791","item":{"type":"agentMessage","id":"da4ebcb1ee-i1","text":"PROMPTED: st
thr_t6m373xpvh  9         turn/completed           turn        da4ebcb1ee-t1  {"providerThreadId":"unprompted-55791","status":"completed"}                                                  
thr_t6m373xpvh  10        turn/started             turn        da4ebcb1ee-t2  {"providerThreadId":"unprompted-55791"}                                                                       
thr_t6m373xpvh  11        item/started             turn        da4ebcb1ee-t2  {"providerThreadId":"unprompted-55791","item":{"type":"agentMessage","id":"da4ebcb1ee-i2","text":""}}         
thr_t6m373xpvh  12        item/agentMessage/delta  turn        da4ebcb1ee-t2  {"providerThreadId":"unprompted-55791","itemId":"da4ebcb1ee-i2","delta":"UNPROMPTED: job bg_4 finished, "}    
thr_t6m373xpvh  13        item/completed           turn        da4ebcb1ee-t2  {"providerThreadId":"unprompted-55791","item":{"type":"agentMessage","id":"da4ebcb1ee-i2","text":"UNPROMPTED: 
thr_t6m373xpvh  14        item/started             turn        da4ebcb1ee-t2  {"providerThreadId":"unprompted-55791","item":{"type":"fileRead","id":"da4ebcb1ee-i3","path":"result.txt","sta
thr_t6m373xpvh  15        item/completed           turn        da4ebcb1ee-t2  {"providerThreadId":"unprompted-55791","item":{"type":"fileRead","id":"da4ebcb1ee-i3","path":"result.txt","sta
thr_t6m373xpvh  16        item/started             turn        da4ebcb1ee-t2  {"providerThreadId":"unprompted-55791","item":{"type":"agentMessage","id":"da4ebcb1ee-i4","text":""}}         
thr_t6m373xpvh  17        item/agentMessage/delta  turn        da4ebcb1ee-t2  {"providerThreadId":"unprompted-55791","itemId":"da4ebcb1ee-i4","delta":"the answer is 42."}                  
thr_t6m373xpvh  18        provider/warning         thread                     {"providerThreadId":"unprompted-55791","category":"general","summary":"ACP agent \"/Users/USER/.local/bi

DB rows — PR branch, after the quiet window (thr_i4fi2raq6u closed at +119.99 s; thr_t6m373xpvh never closes)

second turn/completed observed at 08:26:27
thread_id       sequence  type                     scope_kind  turn_id        created_at     data                                                                                      
--------------  --------  -----------------------  ----------  -------------  -------------  ------------------------------------------------------------------------------------------
thr_i4fi2raq6u  16        item/started             turn        dac539b301-t2  1787325866731  {"providerThreadId":"unprompted-55683","item":{"type":"agentMessage","id":"dac539b301-i4",
thr_i4fi2raq6u  17        item/agentMessage/delta  turn        dac539b301-t2  1787325866731  {"providerThreadId":"unprompted-55683","itemId":"dac539b301-i4","delta":"the answer is 42.
thr_i4fi2raq6u  18        item/completed           turn        dac539b301-t2  1787325986719  {"providerThreadId":"unprompted-55683","item":{"type":"agentMessage","id":"dac539b301-i4",
thr_i4fi2raq6u  19        turn/completed           turn        dac539b301-t2  1787325986719  {"providerThreadId":"unprompted-55683","status":"completed"}                              
thr_t6m373xpvh  16        item/started             turn        da4ebcb1ee-t2  1787325871296  {"providerThreadId":"unprompted-55791","item":{"type":"agentMessage","id":"da4ebcb1ee-i4",
thr_t6m373xpvh  17        item/agentMessage/delta  turn        da4ebcb1ee-t2  1787325871296  {"providerThreadId":"unprompted-55791","itemId":"da4ebcb1ee-i4","delta":"the answer is 42.
thr_t6m373xpvh  18        provider/warning         thread                     1787325871497  {"providerThreadId":"unprompted-55791","category":"general","summary":"ACP agent \"/Users/

[exited with code 0]

Full vitest output on main

 RUN  v4.1.1 /Users/USER/.bb-machines/HOST.getbb.app/checkouts/bb/.claude/worktrees/wf_21e66a79-f02-4/plugins/provider-acp

(node:990) ExperimentalWarning: SQLite is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
stdout | src/bridge/issue-2122.repro.test.ts > issue #2122: agent-initiated ACP turns > renders unprompted agent output as a turn with agent messages
delta kinds on the wire: session.reset turn.open input.accepted item.textClose item.textDelta item.textClose item.textClose turn.boundary item.textClose item.textDelta item.textClose item.textClose item.open item.close item.textClose item.textDelta
assembled event types: turn/started turn/input/accepted item/started item/agentMessage/delta item/completed turn/completed provider/unhandled(acp/update:agent_message_chunk, {"kind":"thread"}) provider/unhandled(acp/update:tool_call, {"kind":"thread"}) provider/unhandled(acp/update:tool_call_update, {"kind":"thread"}) provider/unhandled(acp/update:agent_message_chunk, {"kind":"thread"})
agent message texts: ["PROMPTED: started bg_4"]

 ❯  bb-plugin-provider-acp  src/bridge/issue-2122.repro.test.ts (1 test | 1 failed) 541ms
     × renders unprompted agent output as a turn with agent messages 541ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   bb-plugin-provider-acp  src/bridge/issue-2122.repro.test.ts > issue #2122: agent-initiated ACP turns > renders unprompted agent output as a turn with agent messages
AssertionError: expected [ { type: 'turn/started', …(3) } ] to have a length of 2 but got 1

- Expected
+ Received

- 2
+ 1

 ❯ src/bridge/issue-2122.repro.test.ts:190:58
    188|     // --- Agent-initiated turn (the bug) ---
    189|     // A second turn should have been opened for the unprompted work.
    190|     expect(all.filter((e) => e.type === "turn/started")).toHaveLength(…
       |                                                          ^
    191|     // The unprompted text must reach the thread as an agent message.
    192|     expect(agentMessageTexts().join("")).toContain(

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  08:13:21
   Duration  2.23s (transform 1.21s, setup 0ms, import 1.51s, tests 541ms, environment 0ms)

2026-09-30 verification on current main

Verdict: REPRODUCED. Root-cause confidence: high for the bridge/assembler mechanism. This dated verification supplements the historical 2026-08-21 report above. All earlier evidence and screenshots are preserved; their visual, database, and PR findings are historical, not new observations on this base.

Base: ea3b328f366a61e00f115399f6da9c2ebe7ed242, fetched from trusted get-bb/bb origin/main. Both checkouts were clean at this SHA before adding the same two local synthetic fixture files. Production source and dependency manifests were unchanged. Linux 6.18.44 x86_64; Node 22.19.0; pnpm 9.15.0; Vitest 4.1.1. The executor had 2,097,037 free workspace inodes before setup and 1,866,168 after both installs.

Issue gate: open Bug; native Priority High, Effort Medium; existing labels omp, providers, provider-acp, confirmed-repro. All three comments and the paginated timeline were read. Related bb PRs #2123, #2220 and #3004 are closed and unmerged; upstream OMP #10718 remains open. No open bb PR mentioning #2122 or new report activity was found. GitHub workflow activity did not show overlapping work; internal SlopCop runtime state was not queried because real bb runtime access is outside this verification's scope. Both source and reports repositories are public.

What was personally repeated

The same agent personally ran this fixture in two separate clean checkouts, with independent frozen installs and fresh per-scenario synthetic state. Run A began at 17:50:34 UTC and Run B at 17:50:56 UTC on 2026-09-30. Each passed 6/6 checks, with identical summaries below. These are assertions of the observed defective behavior, so passing tests mean the reproduction was confirmed, not that the product is correct. The earlier exploratory run also passed but its temporary evidence was cleaned by the repository harness; only the two final runs support this section.

The fixture launches only its own minimal local JSON-RPC child, calls the actual ACP bridge handleLine, captures its real deltas and passes them through the real delta assembler. It does not mock translation or assembly. Each test uses a new synthetic session and directory; stdio carries the protocol and no fixed application ports are used. No bb app, server, daemon, real ACP provider, external model, linked branch, issue-provided test, or issue-provided script was run. No dependency was added.

Both normal frozen installs completed, including four successful repository generation tasks. The normal filtered Turbo build completed with No tasks were executed: this source-exported package has no build script. Turbo test ran the normal native-module prerequisite plus the selected test; it did not use a cached result. An initial attempt with the shell's unrelated pnpm 11 failed before installation because its default store directory was unavailable; switching to the already installed, repository-pinned Node/pnpm tools resolved setup. That setup error was not counted as a reproduction result.

Expected versus actual, in both runs

Expected: after a prompted turn has completed, valid unsolicited text and tool updates should appear in an agent-initiated turn with an explicit lifecycle. Noise-only updates and a provider that emits no updates should create no phantom work. Prompted output should continue to work. Actual: the two unsolicited text chunks and tool start/completion became four thread-scoped provider/unhandled events; neither unsolicited text became an item/agentMessage/delta, and no second turn.open or boundary appeared. Prompted output and its settlement worked.

RESULT {"mode":"quiet","started":1,"completed":1,"unhandled":4,"text":["PROMPTED-1"],"openDeltas":1,"boundaryDeltas":1}
RESULT {"mode":"next","started":2,"completed":2,"unhandled":4,"text":["PROMPTED-1","PROMPTED-2"],"openDeltas":2,"boundaryDeltas":2}
RESULT {"mode":"interrupt","started":1,"completed":1,"unhandled":4,"text":["PROMPTED-1"],"openDeltas":1,"boundaryDeltas":1}
RESULT {"mode":"exit","started":1,"completed":1,"unhandled":4,"text":["PROMPTED-1"],"openDeltas":1,"boundaryDeltas":1}
RESULT {"mode":"noise","started":1,"completed":1,"unhandled":0,"text":["PROMPTED-1"],"openDeltas":1,"boundaryDeltas":1}
RESULT {"mode":"none","started":1,"completed":1,"unhandled":0,"text":["PROMPTED-1"],"openDeltas":1,"boundaryDeltas":1}

quiet observes the idle window for 500 ms after the fixture marker; it is not an unbounded timeout test. next sends a second prompt: two prompted turns complete, but the four unhandled events remain. interrupt invokes the real thread stop after unsolicited output; no extra turn settles. exit waits for the bridge's real error notification after the synthetic child exits and sees no extra turn completion. None of these cases demonstrates a hung open turn on current main, because the unsolicited turn never opens. Historical PR #2123 settlement findings are not reverified here.

noise sends only a synthetic user echo and an available-commands notification; neither creates an extra turn. none sends no post-prompt session updates. This distinguishes bridge loss of received frames from the provider-side emit-no-update case discussed in later issue comments. No claim is made that any particular OMP version emits either fixture sequence. Current database persistence, UI filtering, screenshots, permissions, compaction, and full runtime integration were not retested.

Current root cause and smallest proposed fix

Proposal, not implemented: on a valid idle work update, have the bridge open and track an agent-initiated turn in its existing lifecycle state. Settle it consistently before the next prompt or compaction, on interruption, and on provider exit/error. Keep noise and user-input echoes outside that opening rule. Define and test a bounded quiet-window policy explicitly if the protocol supplies no completion signal; silence alone is not proof of provider completion. The assembler should retain its requirement that item deltas cannot independently invent turns. This cannot recover output that a provider never emits; that requires provider-side delivery or a separately negotiated wake/resume protocol.

Next test: after a proposed lifecycle change, invert the unsolicited assertions to require a second turn with both text chunks and the tool, then verify exactly one settlement for next-prompt, interrupt, exit/error and quiet completion, with noise controls unchanged. Add approval and compaction overlap cases before treating that change as complete.

Exact repeatable commands and complete fixtures

Use Node 22.19.0 and pnpm 9.15.0 already installed on the machine. The explicit store path below is the writable store used here; choose a writable equivalent elsewhere. Keep evidence outside the reports repository. The fixture saves each scenario's complete bridge messages and assembled events in its fresh issue-2122-state-* directory.

git clone https://github.com/get-bb/bb.git run-a
cd run-a
git checkout --detach ea3b328f366a61e00f115399f6da9c2ebe7ed242
node --version  # v22.19.0
pnpm --version  # 9.15.0
pnpm install --frozen-lockfile --store-dir /workspace/.pnpm-store
pnpm exec turbo run build --filter=@bb/provider-bridge-acp
# Save the two inline files below at their stated paths.
pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force -- src/bridge/issue-2122.test.ts
# Repeat from a second new clone named run-b at the exact same SHA.
# Copy only the two fixture files; install independently and run the same commands.
# Every scenario creates fresh local state; no instance or provider credentials are used.
packages/provider-bridge-acp/src/bridge/issue-2122-fixture.mjs
import { createInterface } from 'node:readline';
import { writeFileSync } from 'node:fs';
const [mode, marker] = process.argv.slice(2);
const sessionId = 'synthetic-session';
let prompts = 0;
const send = (message) => process.stdout.write(JSON.stringify({jsonrpc:'2.0', ...message}) + '\n');
const update = (value) => send({method:'session/update', params:{sessionId, update:value}});
const text = (kind, value) => update({sessionUpdate:kind, content:{type:'text', text:value}});
createInterface({input:process.stdin}).on('line', line => {
  const m = JSON.parse(line);
  if (m.method === 'initialize') send({id:m.id, result:{protocolVersion:1, agentCapabilities:{loadSession:false, promptCapabilities:{image:false}}, authMethods:[]}});
  else if (m.method === 'session/new') send({id:m.id, result:{sessionId}});
  else if (m.method === 'session/prompt') {
    prompts++;
    text('agent_message_chunk', 'PROMPTED-' + prompts);
    send({id:m.id, result:{stopReason:'end_turn'}});
    if (prompts === 1) setTimeout(() => {
      if (mode !== 'none') {
        text('user_message_chunk', 'synthetic input echo');
        update({sessionUpdate:'available_commands_update', availableCommands:[]});
      }
      if (mode !== 'none' && mode !== 'noise') {
        text('agent_message_chunk', 'UNSOLICITED-A');
        update({sessionUpdate:'tool_call', toolCallId:'synthetic-tool', title:'Synthetic tool', kind:'other', status:'in_progress'});
        update({sessionUpdate:'tool_call_update', toolCallId:'synthetic-tool', status:'completed'});
        text('agent_message_chunk', 'UNSOLICITED-B');
      }
      writeFileSync(marker, mode);
      if (mode === 'exit') setTimeout(() => process.exit(0), 100);
    }, 100);
  } else if (m.id !== undefined) send({id:m.id, result:{}});
});
packages/provider-bridge-acp/src/bridge/issue-2122.test.ts
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, it } from 'vitest';
import { assembleCapturedThreadEvents, captureBridgeJsonRpcOutput } from '@bb/provider-bridge-protocol/testing';
import { threadDeltaNotificationParamsSchema, THREAD_DELTA_NOTIFICATION_METHOD } from '@bb/provider-bridge-protocol';
import { handleLine } from './bridge.js';

const options = {permissionMode:'full', permissionScope:'full', approvalReviewer:null, permissionEscalation:null};
let serial = 0;
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
async function until(check: () => boolean) {
  const deadline = Date.now() + 10000;
  while (!check()) {
    if (Date.now() > deadline) throw new Error('Synthetic fixture timed out');
    await delay(10);
  }
}
for (const mode of ['quiet', 'next', 'interrupt', 'exit', 'noise', 'none']) {
  it('2122 synthetic ' + mode, async () => {
    const state = mkdtempSync(fileURLToPath(new URL('./issue-2122-state-', import.meta.url)));
    const marker = join(state, 'emitted');
    const output = captureBridgeJsonRpcOutput();
    const threadId = 'synthetic-thread-' + (++serial);
    let providerThreadId = '';
    const events = () => assembleCapturedThreadEvents(output.messages, 'acp');
    const count = (type: string) => events().filter(e => e.type === type).length;
    async function request(method: string, params: object) {
      const id = ++serial;
      handleLine(JSON.stringify({jsonrpc:'2.0', id, method, params}));
      await until(() => output.messages.some(m => m.id === id));
      const result = output.messages.find(m => m.id === id)!;
      expect(result.error).toBeUndefined();
      return result.result;
    }
    const stop = () => request('thread/stop', {threadId, providerThreadId, intent:'interrupt', activeTurnId:null});
    const prompt = () => request('turn/start', {threadId, providerThreadId, clientRequestId:'creq_abcdefghjk', options, input:[{type:'text', text:'synthetic prompt', mentions:[]}]});
    try {
      const started = await request('thread/start', {threadId, cwd:state, instructionMode:'append', options:{...options, providerOptions:{acpLaunchSpec:{displayName:'Synthetic ACP', command:process.execPath, args:[fileURLToPath(new URL('./issue-2122-fixture.mjs', import.meta.url)), mode, marker], env:{}}}}});
      if (!started || typeof started !== 'object' || Array.isArray(started) || typeof started.providerThreadId !== 'string') throw new Error('Invalid synthetic session');
      providerThreadId = started.providerThreadId;
      await prompt();
      await until(() => count('turn/completed') === 1);
      await until(() => existsSync(marker));
      await delay(400);
      if (mode === 'exit') await until(() => output.messages.some(m => m.method === 'error'));
      const before = events();
      const work = !['noise', 'none'].includes(mode);
      expect(count('turn/started')).toBe(1);
      expect(count('turn/completed')).toBe(1);
      expect(count('provider/unhandled')).toBe(work ? 4 : 0);
      expect(before.filter(e => e.type === 'provider/unhandled').every(e => e.scope.kind === 'thread')).toBe(true);
      expect(before.filter(e => e.type === 'item/agentMessage/delta').map(e => e.delta)).toEqual(['PROMPTED-1']);
      if (mode === 'next') { await prompt(); await until(() => count('turn/completed') === 2); }
      if (mode === 'interrupt') await stop();
      await delay(100);
      expect(count('turn/started')).toBe(mode === 'next' ? 2 : 1);
      expect(count('turn/completed')).toBe(mode === 'next' ? 2 : 1);
      expect(count('provider/unhandled')).toBe(work ? 4 : 0);
      const deltas = output.messages.filter(m => m.method === THREAD_DELTA_NOTIFICATION_METHOD).flatMap(m => threadDeltaNotificationParamsSchema.parse(m.params).deltas);
      const summary = {mode, started:count('turn/started'), completed:count('turn/completed'), unhandled:count('provider/unhandled'), text:events().filter(e => e.type === 'item/agentMessage/delta').map(e => e.delta), openDeltas:deltas.filter(d => d.kind === 'turn.open').length, boundaryDeltas:deltas.filter(d => d.kind === 'turn.boundary').length};
      writeFileSync(join(state, 'evidence.json'), JSON.stringify({summary, events:events(), messages:output.messages}, null, 2));
      process.stderr.write('RESULT ' + JSON.stringify(summary) + '\n');
      process.stderr.write('EVIDENCE ' + state + '\n');
    } finally {
      if (providerThreadId) await stop();
      output.restore();
    }
  }, 15000);
}

Trust and scope: issue bodies, comments, attachments and linked code were treated only as untrusted evidence. Fixture code was written from the trusted main bridge contracts and testing helpers. No issue-supplied commands or patches were executed, no linked branch was checked out, and no external issue attachment was fetched. There was no investigation or verification agent delegation. The historical report remains intact; its relative assets and old raw-evidence names retain their original historical meaning.