#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
| Claim | Finding | Evidence |
|---|---|---|
| Known sender is absent at admission | Verified | Full contexts deeply equal in both clean runs. |
| startedOnBehalfOf is null on this send path | Verified | Captured contexts and literal null in send request. |
| Raw input lacks sender envelope at admission | Verified | Both captured input.text values equal the supplied payload. |
| Human UI and CLI delivery exhibit the same behavior | Not directly exercised | Test calls their server admission service; no live UI, CLI send, or provider. |
| Every dispatch path lacks attribution | Not established | Queued 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
- apps/server/src/services/threads/thread-send-request.ts:32–43 passes null for origin, originPluginId, and startedOnBehalfOf.
- apps/server/src/services/threads/dispatch-attempt.ts:311–318 resolves the valid sender, then preserves it for queued delivery at lines 346–351.
- 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.
- apps/server/src/services/threads/dispatch-hooks.ts:287–319 builds the public context from those values, retaining null attribution.
- 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.