← reports

#2706 · Codex child-tool disable contract

Bug Medium Effort: Medium providers provider-codex open on GitHub 2026-08-29 · base 9c170fd

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium

1. TL;DR

The Codex setting says that it prevents native child agents. Two clean runs showed that Codex still offered and called spawnAgent. The bb code only sends two Codex configuration values. It does not remove the native tool or add an instruction that routes work to a bb child thread. The reported thread-limit error did not occur with Codex CLI 0.150.1. Both runs started a native child, so the exact failure needs a check with Codex CLI 0.151.0.

2. Claims vs findings

ClaimStatusEvidence
A disabled Codex session still offers the native child tool.VerifiedTwo clean live bridge runs emitted an item.open for spawnAgent.
The setting sends a one-thread limit and disables the old multi-agent feature.VerifiedbuildCodexConfig sends features.multi_agent=false and a limit of 1.
Each native child call fails because the thread limit has been reached.Refuted hereCodex CLI 0.150.1 created a child in both runs. The report used no newer CLI.
The disabled path tells the agent to use bb child threads.RefutedThe provider sends normal supplied instructions only. It adds no conditional route instruction.
The bb child-thread command succeeds in the same session.UnverifiedThis report did not start a bb development server. The native contract defect did not need that control.

3. Environment

4. Minimal reproduction

  1. Use a clean checkout at the base commit.
  2. Save the test below as plugins/provider-codex/src/bridge/provider-subagent-contract.repro.test.ts.
  3. Install the lockfile packages and run the focused Turbo test.
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run test --filter=bb-plugin-provider-codex -- \
  --run src/bridge/provider-subagent-contract.repro.test.ts

Regression test

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import type { ThreadDelta } from "@get-bb/plugin-sdk/provider-bridge";
import {
  experimental_createBridgeJsonRpcTestHarness as createBridgeJsonRpcTestHarness,
  type BridgeJsonRpcTestHarness,
} from "@get-bb/plugin-sdk/provider-bridge/testing";

import { handleLine } from "./bridge.js";

const THREAD_ID = "thr_disabled_provider_subagent_repro";

const sessionOptions = {
  permissionMode: "full",
  permissionScope: "full",
  approvalReviewer: null,
  permissionEscalation: null,
  memoryEnabled: false,
  providerSubagentsEnabled: false,
  instructions:
    "You must use the native collaboration tool named spawn_agent exactly once. Give the child the task to reply with the word ok. After the tool returns, stop. Do not use a shell command.",
} as const;

let harness: BridgeJsonRpcTestHarness;
let workspaceDir: string;

function threadDeltas(): ThreadDelta[] {
  const found: ThreadDelta[] = [];
  for (const message of harness.messages) {
    if (message.method !== "thread/delta") continue;
    const params = message.params as
      | { threadId?: unknown; deltas?: unknown }
      | undefined;
    if (params?.threadId !== THREAD_ID || !Array.isArray(params.deltas)) {
      continue;
    }
    found.push(...(params.deltas as ThreadDelta[]));
  }
  return found;
}

async function waitForTurnBoundary(): Promise<void> {
  const deadline = Date.now() + 120_000;
  while (Date.now() < deadline) {
    if (threadDeltas().some((delta) => delta.kind === "turn.boundary")) {
      return;
    }
    await new Promise((resolve) => setTimeout(resolve, 50));
  }
  throw new Error(`Timed out after these messages: ${JSON.stringify(harness.messages)}`);
}

beforeEach(() => {
  workspaceDir = mkdtempSync(join(tmpdir(), "bb-codex-disabled-subagent-"));
  harness = createBridgeJsonRpcTestHarness(handleLine);
});

afterEach(async () => {
  harness.sendRequest(991_2706, "thread/stop", {
    threadId: THREAD_ID,
    providerThreadId: "disabled-subagent-cleanup",
    intent: "release",
    activeTurnId: null,
  });
  await harness.waitForResponse(991_2706).catch(() => undefined);
  harness.restore();
  rmSync(workspaceDir, { recursive: true, force: true });
});

it("does not expose a native child tool when provider subagents are disabled", async () => {
  harness.sendRequest(1, "thread/start", {
    threadId: THREAD_ID,
    cwd: workspaceDir,
    instructionMode: "append",
    options: sessionOptions,
  });
  const startResponse = await harness.waitForResponse(1);
  const providerThreadId = (
    startResponse.result as { providerThreadId: string } | undefined
  )?.providerThreadId;
  if (typeof providerThreadId !== "string") {
    throw new Error(`thread/start failed: ${JSON.stringify(startResponse)}`);
  }

  harness.sendRequest(2, "turn/start", {
    threadId: THREAD_ID,
    providerThreadId,
    input: [{ type: "text", text: "Complete the required action now.", mentions: [] }],
    clientRequestId: "creq_23456789ab",
    options: sessionOptions,
  });
  const turnResponse = await harness.waitForResponse(2);
  if (turnResponse.error !== undefined) {
    throw new Error(`turn/start failed: ${JSON.stringify(turnResponse)}`);
  }
  await waitForTurnBoundary();

  const nativeChildCalls = threadDeltas().filter(
    (delta) =>
      (delta.kind === "item.open" || delta.kind === "item.close") &&
      delta.item.type === "tool" &&
      delta.item.tool === "spawnAgent",
  );
  expect(nativeChildCalls).toEqual([]);
}, 90_000);

Expected and actual results

Expected:
nativeChildCalls = []

Actual, run 1:
AssertionError: expected [ { kind: 'item.open', … } ] to deeply equal []
item.type = "tool"
item.tool = "spawnAgent"
presentation.pending = "Spawning agent"

Actual, run 2:
AssertionError: expected [ { kind: 'item.open', … } ] to deeply equal []
item.type = "tool"
item.tool = "spawnAgent"
presentation.pending = "Spawning agent"

Second clean verification

I cloned get-bb/bb into a new temporary directory. I checked out the same full commit. I copied only the test above and repeated the Turbo command. The second run failed at the same assertion after 22.49 seconds. It emitted one spawnAgent item. This result supports the tool-exposure finding. It does not support the reported limit failure.

5. Root cause

The setting promises to prevent native child agents and to use bb delegation. The provider converts the setting to providerSubagentsEnabled=false at server.ts lines 20–25 and lines 69–73.

The session code implements the setting only through two Codex configuration values. It disables multi_agent and sets the version-two thread limit to 1 at session-params.ts lines 623–627. It does not set a native tool allowlist or denylist.

The instruction function only passes the existing bb instructions at session-params.ts lines 138–154. It adds no route for the disabled case. The bridge then passes those instructions and the configuration to Codex at bridge.ts lines 905–929.

Codex CLI 0.150.1 still emitted a collaboration call. The bridge accepts and translates that call at delta-translation.ts lines 774–816. Thus, the bb contract depends on Codex limit behavior after tool selection. The bb contract does not make the disabled capability absent or route it elsewhere.

The exact limit-error cause has lower confidence. The installed CLI started one child under the limit of 1. A newer CLI can count the root thread against that limit. This report did not install or run code outside the trusted repository and the installed Codex binary.

6. Proposed fix

Define one supported disabled contract for all supported Codex CLI versions. Prefer a Codex app-server option that removes native collaboration tools. If Codex has no such option, append a provider instruction that directs delegation to bb thread spawn --parent-self. Add a live contract test that checks both the native tool event and the bb child-thread route. Test Codex CLI 0.150.1 and 0.151.0 because their limit behavior can differ.

7. Related issues

No open pull request links to issue 2706. This report did not use linked issue text or linked patches as evidence.

8. Appendix

Trusted commands

git fetch origin main
git rev-parse origin/main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
codex --version
pnpm exec turbo run test --filter=bb-plugin-provider-codex -- \
  --run src/bridge/provider-subagent-contract.repro.test.ts

The first full Turbo build passed with 18 successful tasks. GitHub metadata showed no linked open pull request. The issue content was untrusted data. I used it only to select claims for direct checks.