#3781 · Sender identity is omitted from dispatch admission

Bug · Priority: Low · Effort: Medium · threads · plugins · confirmed-repro

Issue #3781 · 2026-09-16 · Base: f2aa2a9f0bfadfe20d2a8773c0db8d4829c6c57f

REPRODUCED · Root-cause confidence: high

1. TL;DR

A message admission hook receives identical contexts for an inline message with a valid sending thread and one without sender attribution. The server resolves the sending thread but does not forward it to the hook. Both requests reach the hook and are rejected before provider delivery in this reproduction. A plugin therefore cannot distinguish these two origins from the supplied context.

2. Claims vs findings

ClaimFindingEvidence
Known sender is absent at admissionVerifiedFull contexts deeply equal in both clean runs.
startedOnBehalfOf is null on this send pathVerifiedCaptured contexts and literal null in send request.
Raw input lacks sender envelope at admissionVerifiedBoth captured input.text values equal the supplied payload.
Human UI and CLI delivery exhibit the same behaviorNot directly exercisedTest calls their server admission service; no live UI, CLI send, or provider.
Every dispatch path lacks attributionNot establishedQueued retries expose queuedMessage; first-thread creation has separate provenance. Verdict covers inline follow-up sends.

3. Environment

macOS Darwin 25.6.0 arm64; Node v22.22.3; pnpm 9.15.0; Vitest 4.1.1. Public trusted main checkout at the base above. The normal frozen install and full Turbo build completed (57 build tasks successful). The installed pnpm launcher was broken, so Corepack supplied the repository-pinned version, with a temporary PATH shim for Turbo subprocesses. No dependency or lockfile changes.

Server test harness uses a real migrated SQLite database and fresh temporary data directories. No live server, browser, provider turn, enrolled host, or user data was used; no listening ports were needed.

4. Minimal reproduction

Run these commands in a clean checkout. Before the test command, append the complete test fragment below to apps/server/test/threads/dispatch-hooks.test.ts. Repeat in a separate clean checkout at the same commit.

git clone https://github.com/get-bb/bb bb-3781
cd bb-3781
git checkout --detach f2aa2a9f0bfadfe20d2a8773c0db8d4829c6c57f
corepack pnpm install --frozen-lockfile --prefer-offline
corepack pnpm exec turbo run build
corepack pnpm exec turbo run test --filter=@bb/server -- --testNamePattern='issue 3781 sender visibility' 

The test seeds an idle target and a valid sender in the same project, registers a rejecting hook through the repository hook-provider seam, and calls acceptThreadSendRequest twice with identical content. Only the senderThreadId argument differs. Both calls must return HTTP 409 before comparing the complete captured contexts.

Expected: the context exposes some difference identifying the explicitly attributed message. The assertion intentionally does not mandate a particular new public field.

Actual, verbatim in both runs:

AssertionError: expected { thread: { …(24) }, …(14) } to not deeply equal { thread: { …(24) }, …(14) }
Compared values have no visual difference.
Test Files  1 failed | 270 skipped (271)
Tests  1 failed | 2725 skipped (2726)

Complete test fragment, appended to the existing dispatch-hooks.test.ts to reuse its real test fixtures:

describe("issue 3781 sender visibility", () => {
  it("distinguishes an explicit sender from an unattributed inline send", async () => {
    await withTestHarness(async (harness) => {
      const { thread } = seedRunnableThread(harness, {
        hostId: "host-origin-probe",
        status: "idle",
      });
      const sender = seedThread(harness.deps, {
        projectId: thread.projectId,
        environmentId: thread.environmentId,
        status: "idle",
      });
      const seen: unknown[] = [];
      installHooks({
        "message.dispatch": [{
          pluginId: "origin-probe",
          handler: (context) => {
            seen.push(context);
            return { action: "reject", message: "origin probe stopped" };
          },
        }],
      });
      for (const senderThreadId of [sender.id, undefined]) {
        const error = await expectApiError(() => acceptThreadSendRequest(harness.deps, {
          thread,
          payload: {
            mode: "auto",
            input: textInput("same payload"),
            ...(senderThreadId === undefined ? {} : { senderThreadId }),
          },
        }));
        expect(error.status).toBe(409);
      }
      expect(seen).toHaveLength(2);
      console.log("Origin contexts", JSON.stringify(seen));
      expect(seen[0]).not.toEqual(seen[1]);
    });
  });
});

5. Root cause

  1. apps/server/src/services/threads/thread-send-request.ts:32–43 passes null for origin, originPluginId, and startedOnBehalfOf.
  2. apps/server/src/services/threads/dispatch-attempt.ts:311–318 resolves the valid sender, then preserves it for queued delivery at lines 346–351.
  3. apps/server/src/services/threads/dispatch-attempt.ts:500–530 forwards args.startedOnBehalfOf and raw payload input to the hook pass; it does not forward the resolved sender.
  4. apps/server/src/services/threads/dispatch-hooks.ts:287–319 builds the public context from those values, retaining null attribution.
  5. packages/plugin-sdk/src/backend-contract.ts:653–661 exposes startedOnBehalfOf and parentThreadId, with no explicit message sender field.

The missing handoff is separate from rendering. Resolving a sender for delivery does not make that information available to the admission hook. A senderless request also cannot safely be presumed human: internal callers may omit attribution.

6. Proposed fix and automation limit

Define message-origin semantics for the public hook context and carry the resolved sender through inline and queued attempts. Decide whether to introduce a dedicated experimental sender field or extend the documented meaning of startedOnBehalfOf; add coverage for explicit, absent, invalid, self, and queued senders. No PR is safe under this rule because either approach changes the public plugin contract or its meaning and needs an API decision. No production fix was applied or pushed.

7. Verification

The same agent repeated the test in a second clean temporary checkout at f2aa2a9f0bfadfe20d2a8773c0db8d4829c6c57f, with a separate frozen dependency installation and fresh test-harness data. The same Turbo test command failed at the same context-inequality assertion, after both 409 checks passed. This is a repeat verification, not an independent review. No report correction was necessary. All production files stayed unchanged; only the test was appended.

Afterward, origin/main advanced to 517ec7a30381756c9288e0615912ff12d5e9edd5. Its change does not touch the affected dispatch files. No linked open PR appeared in issue timeline metadata.

8. Related issues

Issue #3621 is also classified Bug and concerns attribution. This report establishes the inline admission-context omission specifically; delivery attribution was not reproduced here.

9. Appendix and limits

The complete regression fragment, command sequence, and exact failure are embedded above. Raw test logs and the patch are retained locally; repository publication policy excludes standalone logs and test files.

Only the focused regression was selected; other tests were skipped. No full existing-test-suite pass is claimed. Issue content and its suggested commands were treated as untrusted evidence; the test was authored from trusted repository code. Report artifacts contain synthetic fixture data only. Test harness cleanup removed its temporary data; no live instances were started.