#4264 · Pi interaction failures become cancellations
Base: fdd3de3b19b97e6cd1ef7300cbb54711431249d3. Verdict: REPRODUCED. Root-cause confidence: high.
1. TL;DR
Pi can receive a cancellation even when no person answered a prompt. The provider permits 200 title characters while the core interaction schema permits 160. Invalid requests, synchronous transport exceptions, and runtime error responses all map to cancellation. A direct test of the production coordinator and core schema reproduces these failures twice; the installed desktop and external MCP adapter were not exercised.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| 160-character title works | Verified at contract boundary | Both schemas accept; injected successful answer returns Proceed. |
| 161–200-character titles fail | Verified | Provider forwards, core schema rejects, injected runtime error becomes cancellation. |
| Longer titles cancel before forwarding | Verified | 201 and 244 return cancellation; zero sends. |
| Forwarding exception becomes cancellation | Verified | Transport callback throws; coordinator returns cancellation. |
| Core rejection enters synchronous forwarding catch | Corrected | Runtime rejection returns asynchronously; handleRuntimeResponse converts missing result to cancellation. The synchronous catch is a separate defect. |
| Desktop rendering and external approval adapter behavior | Unverified end to end | No real Pi, MCP server, browser, or live user data was used. |
3. Environment
macOS / Darwin arm64; Node v22.22.3; Corepack pnpm 9.15.0; Vitest 4.1.1; repository Pi development dependency 0.84.0. Two new detached worktrees at the full base above, named first and second, each with its own frozen dependency install. No listening ports, runtime database, or provider sessions. No new dependency was added.
The host pnpm launcher initially failed with MODULE_NOT_FOUND. Corepack completed both frozen installs. The full Turbo build initially encountered the same launcher problem; a temporary pnpm shim delegates to Corepack. See build status in the appendix. Focused Vitest execution deliberately bypasses orchestration for this diagnostic test.
4. Minimal reproduction
- Fetch trusted get-bb/bb main and create a detached checkout at the recorded commit.
- Run
corepack pnpm install --frozen-lockfile --prefer-offlineandcorepack pnpm exec turbo run buildusing a working pnpm launcher. - Save the test as
plugins/provider-pi/issue-4264.test.ts. - From
plugins/provider-pi, runcorepack pnpm exec vitest run --config vitest.config.ts issue-4264.test.ts.
Expected: internal validation or transport failures must not report a user cancellation. Actual (both checkouts, exit 1):
Tests 4 failed | 1 passed (5)
AssertionError: expected [ { cancelled: true } ] to not deep equally contain { cancelled: true }
The passing observation test checks 160, 161, 200, 201 and 244 characters, transport failure, and a null-answer cancellation control. Runtime error delivery and user answers are injected; core validation and coordinator execution are real. The null-answer control tests the cancellation representation, not a human clicking a live dialog.
import { expect, it } from "vitest";
import { pendingInteractionPayloadSchema } from "../../packages/domain/src/pending-interactions.js";
import { createExtensionUiCoordinator } from "./src/bridge/extension-ui.js";
import { piExtensionUiRequestSchema, type InteractionUiRequest, type PiExtensionUiResponseFields } from "./src/extension-ui-contract.js";
function exercise(length: number, mode: "answer" | "throw" | "cancel" = "answer") {
const sent: InteractionUiRequest[] = [];
const replies: PiExtensionUiResponseFields[] = [];
const request = { id: "sample", method: "select", title: "T".repeat(length), options: ["Proceed", "Stop"] };
const coordinator = createExtensionUiCoordinator({ sendInteractionRequest(message) {
if (mode === "throw") throw new Error("Transport unavailable");
sent.push(message);
} });
coordinator.handle({ scope: {}, request, threadId: "thr_test", providerThreadId: "pi_test", respond(_id, fields) { replies.push(fields); } });
const forwarded = sent[0];
const coreAccepted = forwarded ? pendingInteractionPayloadSchema.safeParse(forwarded.params.payload).success : null;
if (forwarded) coordinator.handleRuntimeResponse(coreAccepted
? { id: forwarded.id, result: { kind: "request_answer", value: mode === "cancel" ? null : "Proceed" } }
: { id: forwarded.id, error: { message: "Unsupported provider request" } });
return { length, mode, providerAccepted: piExtensionUiRequestSchema.safeParse(request).success, forwarded: sent.length, coreAccepted, replies };
}
it("records title boundaries and failure paths", () => {
const rows = [160, 161, 200, 201, 244].map(length => exercise(length));
rows.push(exercise(160, "throw"), exercise(160, "cancel"));
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
expect(rows[0].replies).toEqual([{ value: "Proceed" }]);
expect(rows.slice(1).map(row => row.replies)).toEqual(Array.from({length: 6}, () => [{ cancelled: true }]));
expect(rows.map(row => row.coreAccepted)).toEqual([true, false, false, null, null, null, true]);
});
it.each([161, 200, 201])("regression: validation failure at %i must not claim user cancellation", length => {
expect(exercise(length).replies).not.toContainEqual({ cancelled: true });
});
it("regression: a transport exception must not claim user cancellation", () => {
expect(exercise(160, "throw").replies).not.toContainEqual({ cancelled: true });
});
5. Root cause
Provider limit defines PLUGIN_TITLE_MAX = 200. Core limit defines PLUGIN_INTERACTION_MAX_TITLE_LENGTH = 160, consumed by the extension interaction payload schema.
Invalid provider requests immediately receive cancellation. Synchronous send exceptions also receive cancellation. For requests accepted by Pi but rejected by core, runtime decoding returns null on schema failure and runtime dispatch returns an unsupported-request JSON-RPC error. The Pi response handler ignores the error field, parses the absent result, then responds with cancellation.
The current response union has only value, confirmed, and cancelled variants. This is a deeper semantic mismatch: the bridge has no distinct error outcome in that contract. Existing integration tests explicitly expect interaction errors to become cancellations.
6. Proposed fix and automation decision
Use the canonical interaction title bound, and deliberately design how validation, runtime, and transport errors are exposed without inventing a user decision or leaving Pi waiting indefinitely. Preserve approval context rather than truncating titles. Cover actual cancellation separately from provider teardown and malformed results.
No automatic fix PR: correcting the accepted schema and choosing bridge-error semantics fails the rule's no-schema-change/no-product-decision conditions. No production files were changed or branch pushed. No linked open PR was found in the issue timeline or open-PR search.
7. Verification
The same agent repeated the test in a second freshly created detached checkout at fdd3de3b19b97e6cd1ef7300cbb54711431249d3, with a separate frozen install. Only the newly authored diagnostic test was copied. Both runs exited 1 with four expected regression failures and one passing observation test. The checked production files remained unchanged. This is repeated verification, not independent review.
Logs: first run · second run. Correction after source tracing: downstream core rejection uses the runtime-response path, not the synchronous catch. No live UI claim is made.
8. Related issues
No related issue is required to establish this contract failure. The classification is based on a contained but meaningful provider defect with a short-title workaround.
9. Appendix
Read-only GitHub inspection confirmed a public target repository, missing classification, no comments, and no linked open PRs. Classification was applied through the SlopCop writer and read back. Each code link above was checked in the recorded base. Issue-supplied instructions and scripts were treated as untrusted; the test was authored from trusted source.
Commands: git fetch origin main; git fetch https://github.com/get-bb/bb main (same SHA); git worktree add --detach for each checkout; frozen install and Turbo build in each; focused Vitest command above; git diff --check; git status --short. No application was started, so no runtime cleanup was needed.
Both full Turbo builds passed after the temporary launcher workaround: 60 successful tasks in each checkout (54 cached in first; 58 in second).
> AGENT GENERATED