← reports

#4678 · Native Codex questions stop at the provider bridge

Bug Priority: Medium Effort: Medium providers provider-codex · GitHub issue

2026-10-02 · Trusted origin/main base 61a56bacdf828d7ea9c0447bfafa9d072f8ada09

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high (provider boundary only)

1. TL;DR

The report describes Codex offering choices as text rather than a selectable BB question card. On current main, the Codex provider explicitly disables the native default-mode input feature. A supervised app-server fixture sending a native question also fails to produce any BB interaction request: the decoder does not support that method, and the bridge rejects unknown requests. The answer path separately accepts approval outcomes only, so enabling the feature or decoding the request alone is insufficient. Two clean trusted checkouts show the same missing interaction, but no live Codex/Claude UI comparison or macOS release reproduction was performed; the visual symptom remains unverified.

2. Claims vs findings

ClaimStatusEvidence
Default-mode native Codex questions are disabled by BB.Verified in sourceThe trusted configuration builder writes the feature flag as false at line 613.
Native questions cannot reach a BB choice interaction.Verified at the bridge boundaryBoth synthesized blocking and nonblocking requests produce no interaction/request in both clean runs.
Question answers need changes beyond decoding.Verified in source; not exercised end-to-endThe bridge parses the returned result with approvalInteractionOutcomeSchema and its encoder accepts ApprovalInteractionOutcome.
A normal Codex thread displays plain text while Claude displays a card.UnverifiedNo real provider turn, UI screenshot, or comparison on the reported release was run.
The external prototype and its reported test results resolve the issue.UnverifiedNo external fork code, linked commit, or linked PR branch was fetched or executed.

3. Environment

4. Minimal reproduction

This is a provider-boundary reproduction, not a visual BB reproduction. It executes the real bridge and the repository's existing child-process fixture. The test expectation is the existing canonical user_question interaction contract, not a source-code string check.

  1. Clone trusted main, pin the commit, install and build:
    git clone --single-branch --branch main https://github.com/get-bb/bb.git bb-4678-base
    cd bb-4678-base
    git checkout --detach 61a56bacdf828d7ea9c0447bfafa9d072f8ada09
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Create plugins/provider-codex/src/bridge/bridge.issue-4678.test.ts with the complete test below.
  3. Run the focused test through Turbo:
    pnpm exec turbo run test --filter=bb-plugin-provider-codex -- src/bridge/bridge.issue-4678.test.ts
  4. Expected: the bridge sends a canonical interaction/request containing payload.kind = user_question, before the turn settles. Actual: the bridge settles the scripted turn without emitting that interaction; both assertions fail.

Complete reproduction test

import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { experimental_createBridgeJsonRpcTestHarness as createBridgeJsonRpcTestHarness } from "@get-bb/plugin-sdk/provider-bridge/testing";
import { handleLine } from "./bridge.js";
import {
  FULL_ACCESS_SESSION_OPTIONS,
  stubFakeCodexAppServer,
} from "./fake-codex-app-server-harness.js";

let workspaceDir: string;
let harness: ReturnType<typeof createBridgeJsonRpcTestHarness>;
const threadId = "thr_repro_4678";

beforeEach(() => {
  workspaceDir = mkdtempSync(join(tmpdir(), "bb-question-boundary-"));
  harness = createBridgeJsonRpcTestHarness(handleLine);
});

afterEach(async () => {
  try {
    harness.sendRequest(99, "thread/stop", {
      threadId,
      providerThreadId: threadId,
      intent: "release",
      activeTurnId: null,
    });
    await harness.waitForResponse(99);
  } finally {
    harness.restore();
    vi.unstubAllEnvs();
    rmSync(workspaceDir, { recursive: true, force: true });
  }
});

it.each([true, false])(
  "delivers a native question to BB before completing the turn (isBlocking=%s)",
  async (isBlocking) => {
    const scriptPath = join(workspaceDir, "app-server-script.json");
    writeFileSync(
      scriptPath,
      JSON.stringify({
        turns: [
          [
            {
              method: "turn/started",
              params: {
                threadId,
                turn: { id: "turn-question", status: "inProgress" },
              },
            },
            {
              kind: "request",
              method: "item/tool/requestUserInput",
              params: {
                threadId,
                turnId: "turn-question",
                itemId: "question-item",
                isBlocking,
                autoResolutionMs: null,
                questions: [
                  {
                    id: "drink",
                    header: "Drink",
                    question: "Which drink do you prefer?",
                    isOther: true,
                    isSecret: false,
                    options: [
                      { label: "Coffee", description: "Choose coffee." },
                      { label: "Tea", description: "Choose tea." },
                    ],
                  },
                ],
              },
            },
            {
              method: "turn/completed",
              params: {
                threadId,
                turn: { id: "turn-question", status: "completed" },
              },
            },
          ],
        ],
      }),
    );
    stubFakeCodexAppServer(scriptPath);
    harness.sendRequest(1, "thread/start", {
      threadId,
      cwd: workspaceDir,
      instructionMode: "append",
      options: { ...FULL_ACCESS_SESSION_OPTIONS },
    });
    const started = await harness.waitForResponse(1);
    expect(started.error).toBeUndefined();
    harness.sendRequest(2, "turn/start", {
      threadId,
      providerThreadId: threadId,
      clientRequestId: "repro-native-question",
      input: [
        {
          type: "text",
          text: "Ask a structured drink question.",
          mentions: [],
        },
      ],
      options: { ...FULL_ACCESS_SESSION_OPTIONS },
    });
    const deadline = Date.now() + 5_000;
    while (
      !harness.hasResponse(2) &&
      !harness.messages.some(
        (message) => message.method === "interaction/request",
      )
    ) {
      if (Date.now() > deadline) {
        throw new Error(
          "The bridge neither delivered a question nor settled the turn",
        );
      }
      await new Promise((resolve) => setTimeout(resolve, 10));
    }
    const interaction = harness.messages.find(
      (message) => message.method === "interaction/request",
    );
    expect(interaction).toMatchObject({
      params: { payload: { kind: "user_question" } },
    });
    expect(harness.hasResponse(2)).toBe(false);
  },
  15_000,
);

Actual output (same failure in both runs)

AssertionError: expected undefined to match object { Object (params) }

- Expected:
{
  "params": {
    "payload": {
      "kind": "user_question",
    },
  },
}

+ Received:
undefined

Test Files  1 failed (1)
     Tests  2 failed (2)

Focused command exit code: 1 in both runs. The test uses synthesized question requests with isBlocking=true and isBlocking=false, not a recording from a real Codex version. The production rejection occurs before any question-parameter schema is examined. This demonstrates lack of method support; it does not validate the complete current Codex wire schema or UI behavior.

No screenshot is presented: the visible user symptom was not reproduced. No test files or raw logs are committed to this public reports repository; the full repeatable test and relevant output are embedded here.

5. Root cause

A. Default-mode availability is explicitly disabled

Configuration builder

config["features.default_mode_request_user_input"] = false;

Even when the provider supports a native question tool, BB's configuration asks for it to be unavailable in default mode. The exact model fallback to text is not proven by this experiment.

B. The native method is not decoded

Interactive request decoder handles command, file-change, and permission approval methods. All other methods return null. Bridge rejection path then returns METHOD_NOT_FOUND rather than forwarding a question.

default:
  return null;

if (decoded === null) {
  responder.error(
    BRIDGE_JSON_RPC_ERRORS.METHOD_NOT_FOUND,
    `Unhandled codex request "${method}"`,
  );
  return;
}

The supervised fixture continues after receiving a response (including an error). Therefore the test sees the turn settle with no BB question request. That fixture behavior is not evidence that real Codex would silently continue after this error.

C. The answer path still assumes approval outcomes

Runtime result handler validates every decoded interactive response as an approval. Response encoder likewise accepts approvals only.

const outcome = approvalInteractionOutcomeSchema.parse({
  payload: request.payload,
  resolution: result,
});
responder.result(buildCodexInteractiveResponse(outcome));

This is a source-backed limitation, not a newly executed native-answer failure. A complete repair must validate question answers separately and return them to the original waiting provider request.

D. BB still supplies the fallback tool

Codex capability declaration marks native questions unsupported. Fallback tool selection supplies the existing BB question tool when this capability is false. Thus the findings do not mean that every way of asking a question in a Codex thread is broken. Claude capability declaration differs, but its live card rendering was not tested.

6. Proposed fix and automatic-fix decision

A complete native path needs boundary validation and translation for provider questions, question-specific answer encoding, configuration enablement, and capability/fallback consistency. Exercise option selection, free text, malformed inputs, cancellation, timeout and disconnect/reconnect behavior before advertising support. In particular, the policy for nonblocking requests and any persisted asynchronous questions needs an explicit lifecycle contract rather than assuming that an approval result handler can accept them.

No automatic fix PR: only the provider boundary was reproduced, and live UI/answer lifecycle behavior is still unverified. Existing open native-question PRs already overlap this work. This investigation supplies a failing boundary test rather than creating another overlapping, insufficiently verified implementation. No fix branch was created or pushed.

7. Static PR review

Only GitHub metadata and diffs were read as untrusted evidence. Neither branch was checked out or executed. Neither PR's closing-issue metadata links issue #4678, and issue #4678 had no connected/cross-referenced PR events at the final read.

#4044 — open, ready

Reviewed head fb8ccd75dc2c8a1282fee2201c6860bce4e146c3. The diff adds native question decoding and encoding, advertises native support, and adjusts question UI/tests.

Static verdict: partial implementation of the reproduced provider gap, not a complete default-mode/answer-path repair against this main baseline. Tests run: none from this PR.

#3917 — open, draft

Reviewed head 73adf537a7bf44517a63e782051ce7f5663535f8. Its diff enables default-mode input, decodes native questions, adds question-specific result handling and request-cancellation tracking, and expands asynchronous-question support across runtime, persistence, thread projection and host contracts.

Static verdict: covers the identified configuration, decoding, and answer-routing blockers in its diff. Correctness of real UI and lifecycle behavior remains unverified. The cross-subsystem, stored-data and protocol work is outside this rule's simple-fix gate. Tests run: none from this PR.

8. Related issues

9. Verification

The same investigator repeated the experiment, not an independent investigator. A second initially clean detached worktree was created at the exact recorded trusted commit. It ran its own frozen install and full build, received only the investigator-authored test, and created fresh temporary fixtures for both cases. No TCP ports or runtime data directory were needed in either run.

git worktree add --detach ../bb-4678-verify 61a56bacdf828d7ea9c0447bfafa9d072f8ada09
cd ../bb-4678-verify
git status --porcelain
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=bb-plugin-provider-codex -- src/bridge/bridge.issue-4678.test.ts

Initial status was empty. Second result: 2/2 native-question cases failed with the same missing interaction; exit 1. No correction to the limited provider finding was needed. The final verdict remains partial because this repeat does not establish the visual symptom or a real-provider answer round trip.

10. Appendix: checks and scope

pnpm exec turbo run test --filter=bb-plugin-provider-codex -- --exclude src/bridge/bridge.issue-4678.test.ts
Test Files  31 passed (31)
     Tests  322 passed (322)

pnpm exec turbo run typecheck --filter=bb-plugin-provider-codex
Tasks: 5 successful, 5 total

git diff --check
exit 0

Existing provider tests pass when the intentionally failing new regression is excluded. Typecheck includes the new test and passes. The test's temporary source copies and raw build/test logs remain private local investigation artifacts, not public attachments.

Read-only preparation used repository visibility, issue type/field/label metadata, issue comments, connected-PR metadata, related-issue metadata, and PR metadata/diffs through GitHub APIs. Trusted-source inspection used Git, ripgrep and numbered source excerpts. No external fork or URL supplied by the issue was fetched. Issue prompts, scripts and instructions were treated as untrusted data and were not executed. The synthesized test prompt and fixtures were authored from the trusted bridge boundary; no linked patch or test was copied.

> AGENT GENERATED