#3397 · Missing plugin prompt announcement

Bug · Priority: Medium · Effort: Medium · plugins
2026-09-10 · Base: 40ed14cc7765b7e2d4c37b660eadbf982e5bac5b · Issue

REPRODUCED · Root-cause confidence: high

TL;DR

A plugin can create a pending prompt that is available to readers while subscribers receive no pending event. The plugin creation path persists the interaction and notifies the UI, but omits the separate plugin event emitter. The provider path calls that emitter. A focused test reproduced the omission in two clean checkouts; adding the missing call makes the test pass.

Claims vs findings

ClaimFindingEvidence
Plugin prompts are pending without a pending announcementVerifiedPending-list assertion passes; emitter call count is zero in both runs.
Provider path emits the eventVerified in sourceCreated-row branch calls the emitter at line 451.
Plans, push delivery, sidebar presentation and MCP reconciliationUnverifiedNo real plugins, providers or browser session were exercised; the reproduction targets their shared server lifecycle seam.
Idle-thread unread timestamp does not advanceUnverified at runtimeOutside the event regression and fix; requires a separate attention-state test.

Environment

Trusted public get-bb/bb main at the commit above; Darwin arm64, Node v22.22.3, pnpm 9.15.0 through Corepack, Vitest 4.1.1. Two detached worktrees were created at the same commit. The real server test harness uses SQLite and migrations, with fresh temporary harness data; no provider session, HTTP listener, imported store or user runtime data was used.

The default pnpm launcher was broken on this host. A temporary PATH shim delegated pnpm to Corepack. Frozen installs and targeted server builds succeeded in both checkouts. The initial full build stopped at the broken launcher; the server build was rerun successfully after selecting Corepack.

Minimal reproduction

  1. Clone the trusted repository and select the recorded commit.
  2. Install the frozen lockfile and build the server through Turbo.
  3. Apply the test-only patch embedded below.
  4. Run the lifecycle suite. The test creates a plugin interaction through the same lifecycle method used by the plugin runtime, verifies its identity and pending status, and spies on the real plugin-service emitter.
git clone https://github.com/get-bb/bb bb-3397
cd bb-3397
git checkout 40ed14cc7765b7e2d4c37b660eadbf982e5bac5b
corepack pnpm install --frozen-lockfile --prefer-offline
corepack pnpm exec turbo run build --filter=@bb/server
git apply /path/to/regression.patch
corepack pnpm exec turbo run test --filter=@bb/server -- test/services/pending-interactions.test.ts

Expected: one announcement with the thread and interaction, with no extra announcements from reads. Actual in both trusted-base runs:

AssertionError: expected "emitInteractionPending" to be called once with arguments: [ …(2) ]
Number of calls: 0
Test Files  1 failed (1)
Tests  1 failed | 30 passed (31)

First run (local evidence) · Second run (local evidence)

Applicable test-only patch

diff --git a/apps/server/test/services/pending-interactions.test.ts b/apps/server/test/services/pending-interactions.test.ts
index 0f1d9546f..56dc20fdd 100644
--- a/apps/server/test/services/pending-interactions.test.ts
+++ b/apps/server/test/services/pending-interactions.test.ts
@@ -80,6 +80,43 @@ function requestPluginInteraction(
 }
 
 describe("pending interaction lifecycle", () => {
+  it("announces each committed plugin prompt once without read duplicates", async () => {
+    await withTestHarness(async (harness) => {
+      const thread = seedPluginInteractionThread(harness.deps, "pending-event");
+      const emit = vi.spyOn(
+        harness.pluginService.events,
+        "emitInteractionPending",
+      );
+      const controller = new AbortController();
+      try {
+        const pending = requestPluginInteraction(harness.deps, {
+          threadId: thread.id,
+          signal: controller.signal,
+        });
+        const [interaction] =
+          harness.deps.pendingInteractions.listPendingThreadInteractions(
+            thread.id,
+          );
+        expect(interaction).toMatchObject({
+          origin: { kind: "plugin" },
+          status: "pending",
+          threadId: thread.id,
+        });
+        expect(emit).toHaveBeenCalledExactlyOnceWith(thread, interaction);
+        harness.deps.pendingInteractions.listPendingThreadInteractions(
+          thread.id,
+        );
+        harness.deps.pendingInteractions.listThreadInteractions(thread.id);
+        expect(emit).toHaveBeenCalledTimes(1);
+        controller.abort();
+        await expect(pending).resolves.toMatchObject({ outcome: "cancelled" });
+      } finally {
+        controller.abort();
+        emit.mockRestore();
+      }
+    });
+  });
+
   it("returns a plugin response only through memory and persists metadata only", async () => {
     await withTestHarness(async (harness) => {
       const thread = seedPluginInteractionThread(harness.deps, "memory-only");

Regression test

Inserted in the existing pending-interactions test file, using its existing helpers. Test excerpt (local evidence); the patch above is directly applicable.

  it("announces each committed plugin prompt once without read duplicates", async () => {
    await withTestHarness(async (harness) => {
      const thread = seedPluginInteractionThread(harness.deps, "pending-event");
      const emit = vi.spyOn(
        harness.pluginService.events,
        "emitInteractionPending",
      );
      const controller = new AbortController();
      try {
        const pending = requestPluginInteraction(harness.deps, {
          threadId: thread.id,
          signal: controller.signal,
        });
        const [interaction] =
          harness.deps.pendingInteractions.listPendingThreadInteractions(
            thread.id,
          );
        expect(interaction).toMatchObject({
          origin: { kind: "plugin" },
          status: "pending",
          threadId: thread.id,
        });
        expect(emit).toHaveBeenCalledExactlyOnceWith(thread, interaction);
        harness.deps.pendingInteractions.listPendingThreadInteractions(
          thread.id,
        );
        harness.deps.pendingInteractions.listThreadInteractions(thread.id);
        expect(emit).toHaveBeenCalledTimes(1);
        controller.abort();
        await expect(pending).resolves.toMatchObject({ outcome: "cancelled" });
      } finally {
        controller.abort();
        emit.mockRestore();
      }
    });
  });

Root cause

requestInput delegates to requestInteraction, and the runtime delegates to requestPluginInteraction. In the plugin lifecycle method, the transaction commits a row, the waiter is installed, and timeline/UI notification runs at lines 528–534. There is no plugin event emission before returning the pending promise. In contrast, the provider created-row branch ends with:

emitPluginInteractionPending(thread, pendingInteraction);

The existing emitter dispatches interaction.pending with the thread DTO and interaction. The existing SDK contract already promises this event after a pending row is committed and accepts the PendingInteraction union. No public API or protocol change is required.

Proposed fix and validation

Add the existing emitter call after the plugin creation notifications. Keep it on the creation path so list/reconciliation reads cannot duplicate it. The local fix changes 38 lines total (38 additions, zero deletions) across the lifecycle module and its existing test file. It does not change timestamp policy, stored data or the event contract.

corepack pnpm exec turbo run test --filter=@bb/server -- test/services/pending-interactions.test.ts test/services/plugins/plugin-background.test.ts
Test Files  2 passed (2)
Tests  47 passed (47)

corepack pnpm exec turbo run typecheck --filter=@bb/server
Tasks: 5 successful, 5 total

git diff --check
# exit 0

Passing test log (local evidence) · Typecheck log (local evidence). Existing tests include provider registration, plugin cancellation, setup failure, resolution and plugin background lifecycle behavior.

Verification

The same agent repeated the test in a second clean detached checkout at the recorded commit, with a separate frozen install, server build and temporary test harness data. The final regression failed at the same missing-event assertion in both runs; the 30 existing lifecycle tests passed in both. This was a second execution, not a cached test result or an independent reviewer. During test authoring, an incorrect assertion used the database field name originKind; it was corrected to the public origin.kind shape before the final runs. No production code was changed for either reproduction.

Related issues

No linked open pull request was present in issue timeline metadata or the open-PR search at investigation time. No separate related issue was established.

Appendix and scope

The report embeds the complete test-only patch and expected-versus-actual evidence. Raw logs remain in local evidence storage and are not published, per the reports repository rules. Root-cause links were checked against the recorded trusted commit. Issue text was treated only as untrusted claims; no issue-supplied script, patch, URL or instruction was executed. No screenshots are needed for this server event defect. End-to-end notification delivery and unread timestamp behavior remain outside this report's verified scope.