← reports

#2062 · ACP bridge drops model descriptions, so the model picker shows indistinguishable duplicate rows

Bug Priority: Low Effort: Low providers provider-acp ui open on GitHub 2026-08-21 · base fcada5a3b

Verdict: REPRODUCED (live, with a fake ACP agent and with the real omp 16.3.10 installed on this machine) · Root-cause confidence: high

Linked PR: #2063 — verdict REQUEST CHANGES: it fixes the bridge drop site correctly, but its desktop rendering change regresses the model picker for every provider (all rows become horizontally centered and Claude Code / Codex rows grow a second line of marketing text). See §8.

1. TL;DR

When an ACP agent such as omp (oh-my-pi) advertises its models through the ACP configOptions "model" select, each option carries value (for example openai-codex/gpt-5.1), a display name ("GPT-5.1") and a description (omp puts the provider/model id there). bb's ACP bridge keeps value and name but hard-codes description: "" when it builds the model catalog. The desktop model picker renders only the display name (the hover tooltip is also just the name), so two models that share a display name — omp exposes 15 such collisions today, e.g. github-copilot/gpt-5.1 and openai-codex/gpt-5.1 — appear as two identical "GPT-5.1" rows. The user cannot tell which row runs which backend. Selection itself is correct (the rows have different values); the defect is purely that the distinguishing information is discarded at the bridge and, independently, never rendered by the desktop picker. The linked PR fixes the bridge drop site but its UI change has a layout regression that makes it unmergeable as-is.

2. Claims vs findings

Claim from the issueStatusEvidence
buildModelCatalogFromConfigOptions hard-codes description: ""Verifiedmodel-catalog.ts#L283; unit repro fails on base with expected [ '', '' ] to deeply equal [ 'zai/glm-4.7', 'openrouter/glm-4.7' ] (log).
The sibling session-models path already maps model.description ?? ""Verifiedmodel-catalog.ts#L313.
Wire schema types only value/name; description passes through untypedVerifiedwire.ts#L233-L238 (.passthrough()). The repro test parses through this schema and the value survives to the catalog builder — the builder simply ignores it.
omp sends { value: "provider/modelId", name, description: "provider/modelId" }Verified (live)Real omp acp 16.3.10 probed over stdio: 72 model options, every one with description equal to its value (probe log).
omp advertises duplicate display names in practice (example: "GLM 4.7")Verified (different names here)On this machine omp exposes 15 colliding display names (GPT-5, GPT-5.1, GPT-5.1 Codex, GPT-5.2, GPT-5.4, GPT-5.5, GPT-4o ×5, …) from github-copilot/ vs openai-codex/. No GLM collision here because no zai/openrouter keys are configured; the mechanism is identical.
Desktop picker shows two rows with identical labels and no secondary textVerified (screenshots)fake agent and real omp. The DOM rows' title tooltips are also identical ("GPT-5.1" / "GPT-5.1").
Desktop mapping drops the field (useThreadCreationOptions.ts)VerifiedL604-L614; also the preview-provider mapping in ModelReasoningPicker.tsx#L383-L396. Additionally the row component MenuRowButton has no description slot at all (L1390-L1460), so mapping alone would not have rendered it.
"Desktop render slot already exists: PickerOption.description"MisleadingThe type field exists (OptionPicker.tsx#L35) and OptionPicker renders it, but the model picker does not use OptionPicker rows; it uses its own MenuRowButton, which ignores the field. A render change is required, not just a mapping change.
Mobile already maps and renders itVerifiedexecution-options.ts#L111-L119, mobile ModelReasoningPicker.tsx#L234. Note: this means mobile already shows Claude Code / Codex descriptions as subtitles today.
CLI unaffected (bb provider models prints raw ids)Verifiedbb provider models acp-omp lists github-copilot/gpt-5.1 and openai-codex/gpt-5.1 on separate lines with the id column.
Not reproduced on a shipped release (reporter's honesty note)Now reproducedReproduced on base fcada5a3b (73 commits after the reporter's 00561091); the code path is unchanged between the two.
Not a duplicate of #1093 / #1836Verified#1093 is about OpenCode custom models being absent; #1836 is about plugin reuse of the picker. Neither concerns dropped descriptions.

3. Environment

4. Minimal reproduction

4a. Unit level (fastest, no running app)

  1. Save model-catalog.repro-2062.test.ts as plugins/provider-acp/src/bridge/model-catalog.repro-2062.test.ts.
  2. Run it from the plugin directory:
    cd plugins/provider-acp && pnpm exec vitest run src/bridge/model-catalog.repro-2062.test.ts
  3. Expected: both tests pass. Actual on fcada5a3b:
    FAIL  src/bridge/model-catalog.repro-2062.test.ts > #2062 ACP config-option model descriptions
          > keeps the description the agent sent for each select option
    AssertionError: expected [ '', '' ] to deeply equal [ 'zai/glm-4.7', 'openrouter/glm-4.7' ]
    
    - Expected
    + Received
      [
    -   "zai/glm-4.7",
    -   "openrouter/glm-4.7",
    +   "",
    +   "",
      ]
     ❯ src/bridge/model-catalog.repro-2062.test.ts:44:46
    
     Test Files  1 failed (1)
          Tests  1 failed | 1 passed (2)
    The second test (leaves two rows that differ only by id) passes and documents the consequence: both catalog entries have displayName === "GLM 4.7" and differ only in id.
// plugins/provider-acp/src/bridge/model-catalog.repro-2062.test.ts
import { describe, expect, it } from "vitest";
import { acpSessionNewResultSchema } from "../wire.js";
import {
  buildModelCatalogFromConfigOptions,
  findAcpModelConfigOption,
} from "./model-catalog.js";

// Exactly what an omp-style agent returns from session/new: two providers
// expose the same display name; value and description differ.
const sessionNew = acpSessionNewResultSchema.parse({
  sessionId: "s1",
  configOptions: [
    {
      id: "model",
      name: "Model",
      category: "model",
      type: "select",
      currentValue: "zai/glm-4.7",
      options: [
        { value: "zai/glm-4.7", name: "GLM 4.7", description: "zai/glm-4.7" },
        {
          value: "openrouter/glm-4.7",
          name: "GLM 4.7",
          description: "openrouter/glm-4.7",
        },
      ],
    },
  ],
});

describe("#2062 ACP config-option model descriptions", () => {
  const models = buildModelCatalogFromConfigOptions(
    findAcpModelConfigOption(sessionNew.configOptions),
  );

  it("keeps the description the agent sent for each select option", () => {
    expect(models.map((m) => m.description)).toEqual([
      "zai/glm-4.7",
      "openrouter/glm-4.7",
    ]);
  });

  it("leaves two rows that differ only by id when descriptions are dropped", () => {
    const [a, b] = models;
    expect(a.displayName).toBe(b.displayName);
    expect(a.id).not.toBe(b.id);
  });
});

4b. Live desktop picker with a fake ACP agent (no omp needed)

  1. Save fake-omp-acp.mjs somewhere, e.g. /tmp/fake-omp-acp.mjs. It is a stdio JSON-RPC agent whose session/new result contains a model select with options zai/glm-4.7 ("GLM 4.7"), openrouter/glm-4.7 ("GLM 4.7") and anthropic/claude-opus-5, each with description = its value.
  2. Register it in the instance's config.json (for a dev instance: <data dir>/config.json; for a real install ~/.bb/config.json) before starting bb:
    {
      "customAcpAgents": [
        { "id": "fake-omp", "displayName": "Fake OMP", "command": "node",
          "args": ["/tmp/fake-omp-acp.mjs"] }
      ]
    }
  3. Start bb (scripts/bb-dev-app current), create a project, open the composer and click the provider/model button, then click the "Fake OMP" provider tab.
  4. What the server hands the app — every description is empty:
    $ curl -s "$BB_SERVER_URL/api/v1/system/execution-options?providerId=acp-fake-omp" | jq '.models[] | {id, displayName, description}'
    { "id": "zai/glm-4.7",              "displayName": "GLM 4.7",       "description": "" }
    { "id": "openrouter/glm-4.7",       "displayName": "GLM 4.7",       "description": "" }
    { "id": "anthropic/claude-opus-5",  "displayName": "Claude Opus 5", "description": "" }
    (full response: execution-options-acp-fake-omp.json)
  5. Expected: the two GLM rows are distinguishable. Actual: two identical rows; even the DOM title tooltip is "GLM 4.7" on both (accessibility tree: button "GLM 4.7", button "GLM 4.7").
Composer before opening the picker
Before: the new-thread composer on the dev instance; the provider/model button is the "5.6-Sol Medium" control.
Picker showing two identical GLM 4.7 rows
The bug (fake agent): two "GLM 4.7" rows with nothing to tell zai/ from openrouter/.

4c. Live with the real omp (oh-my-pi) agent

  1. With omp on PATH bb auto-registers acp-omp. bb provider models acp-omp lists 72 models; the id column disambiguates them (log):
    github-copilot/gpt-5.1                    GPT-5.1
    github-copilot/gpt-5.1-codex              GPT-5.1 Codex
    …
    openai-codex/gpt-5.1                      GPT-5.1
    openai-codex/gpt-5.1-codex                GPT-5.1 Codex
  2. In the picker choose the "oh-my-pi" tab and type GPT-5.1 in the search box. Actual: "GPT-5.1", "GPT-5.1 Codex", "GPT-5.1 Codex Max" and "GPT-5.1 Codex mini" each appear twice with identical text and tooltips. The execution-options response has 72 models, 0 non-empty descriptions and 15 duplicated display names (json).
Real omp picker with duplicated GPT-5.1 rows
The bug with the real omp agent: every GPT-5.1 variant is listed twice (github-copilot vs openai-codex) with no visible difference.
Full page with the omp picker open
Same state, full page, for context.

Repro files: 2062/repro/ (agent script, config, doobie scripts, API responses, logs).

5. Root cause

There are two independent drop sites; fixing either alone does not make the rows distinguishable.

5a. Bridge: the config-options catalog path discards description

The ACP bridge prefers the configOptions "model" select over the legacy models field when both are present (bridge.ts#L898-L925), and omp advertises only configOptions. The option schema passes unknown keys through, so description reaches the builder, which then writes a literal empty string:

// plugins/provider-acp/src/bridge/model-catalog.ts#L276-L287 (base)
    return {
      id: option.value,
      model: option.value,
      displayName: option.name ?? option.value,
      description: "",                       // <-- drop site; option.description is ignored
      supportedReasoningEfforts: reasoning.supportedReasoningEfforts,
      defaultReasoningEffort: reasoning.defaultReasoningEffort,
      isDefault,
    };

The sibling buildModelCatalogFromSessionModels a few lines down already does description: model.description ?? "" (L296-L325). The literal dates from the ACP-native discovery work in #321 and moved unchanged into the plugin in #1640. AvailableModel.description is a required z.string() (provider-types.ts#L16-L27), so the server passes the empty string through unchanged to /api/v1/system/execution-options.

5b. Desktop: the model picker has no disambiguation for equal labels

The app maps AvailableModel to { value, label, routeProviderId? } (useThreadCreationOptions.ts#L604-L614, and the provider-preview path at ModelReasoningPicker.tsx#L383-L396). The row component renders label plus the optional qualifier (used today only for Pi's routeProviderId) and sets the tooltip to the same text (L1436-L1447). Nothing in the chain looks at description or at value for display, so two options with equal displayName are visually identical. The search filter does match on value (L143-L152), so typing openai-codex narrows the list — but the user has no way to learn that those ids exist from the UI.

Deeper issue. The picker assumes displayName is unique per provider. That is not guaranteed by the AvailableModel contract and is already false for omp (15 collisions on this machine, all from the agent aggregating several upstream providers). Any future provider that aggregates backends will hit the same wall, independent of the ACP description field.

6. Proposed fix (first principles)

  1. Bridge (required, two lines): type description: acpOptionalString on acpConfigOptionSelectOptionSchema and map description: option.description ?? "" in buildModelCatalogFromConfigOptions, mirroring the session-models path. No HOST_DAEMON_PROTOCOL_VERSION bump: the field is already required in the wire contract; only its value changes. PR #2063 does exactly this part correctly.
  2. Desktop (required, but narrower than #2063): do not unconditionally render description as a second line — Claude Code and Codex already ship non-empty marketing descriptions ("Opus 5 with 1M context for complex long coding sessions", "Latest frontier agentic coding model."), so that turns a one-line dense menu into a two-line menu for every user. Instead, in the modelOptions / moreModelOptions / preview mappings, detect label collisions and only for colliding rows set the existing qualifier slot (the one Pi's routeProviderId already uses) to a short disambiguator: the agent's description when it is short and differs from the label, else the raw value. This reuses the existing inline "label qualifier" rendering and the existing title="label · qualifier" tooltip, keeps row height, and fixes every provider, not only ACP.
    What could go wrong: long descriptions (Gemini-style sentences) in the qualifier would truncate; cap or prefer value when the description exceeds ~40 characters. If the team wants full descriptions as subtitles (matching mobile), that is a product decision and should land with left-aligned text and a compact-density check, not as a side effect of this bug.
  3. Test: the unit test above for the bridge, plus a picker test asserting that two options with equal labels render different accessible names / tooltips and that clicking the second one emits its own value.

7. PR review — #2063 Preserve ACP model descriptions in the catalog and pickers

What it changes (8 files, +140/−12; diff): types description on the select-option wire schema; maps it in buildModelCatalogFromConfigOptions; threads description into desktop ModelPickerOptions in three mapping sites; wraps MenuRowButton's label in a new flex-1 flex-col container and renders description as a second line; adds tests in the plugin, app and mobile.

Does it address the root cause? Yes for 5a (bridge). For 5b it chooses "always show description" rather than "disambiguate"; that is a defensible product choice in isolation, but the implementation regresses unrelated surfaces.

How I tested. Merged pr-2063 onto base fcada5a3b (clean merge; the PR is based on 00561091, 73 commits behind). Ran pnpm exec turbo run typecheck for bb-plugin-provider-acp, @bb/app, @bb/mobile (pass, log); vitest for the plugin's model-catalog/wire tests plus my repro (36/36 pass, log); the app's picker and hooks tests (377/377 pass, log). Rebuilt, restarted my dev instance on the merged branch and screenshotted the picker for Fake OMP, Claude Code and Codex.

[HIGH] Layout regression for every provider: all picker rows become horizontally centered. apps/app/src/components/pickers/ModelReasoningPicker.tsx (PR hunk at the old L1432-L1447, new <span className="flex min-w-0 flex-1 flex-col gap-0.5">). Before, the label span was a content-sized flex child pinned left by justify-between. The PR makes the wrapper flex-1 so it fills the row, and its children are block-level (truncate) and inherit the <button> user-agent default text-align: center. Result: every model row and every reasoning row ("Low", "Medium", …) is centered. Measured in the running app on the PR build: buttonTextAlign: "center", labelTextAlign: "center", wrapperWidth: 266. jsdom tests cannot see this. Fix: add text-left (or items-start + self-start) on the wrapper.

Claude picker on base
Base: Claude Code picker, left-aligned one-line rows, "More models" visible without scrolling.
Claude picker on PR 2063
PR #2063: same picker. Every row is centered, each model has a truncated second line, the popover grew from 254px to 321px wide and "More models" is pushed below the fold.

[HIGH] Unannounced visual change for Claude Code and Codex (and Pi / any provider with descriptions). apps/app/src/hooks/useThreadCreationOptions.ts L599-L600 / L620-L621 (PR) forward description for all providers. Claude Code models ship descriptions from model-catalog-data.ts and Codex from models.ts#L120, so with the PR every default-provider row doubles in height ("5.6-Sol / Latest frontier agentic coding model.", "Opus 5 1M / Opus 5 with 1M context for complex long coding sessions"). The PR description says only that "non-empty descriptions flow into picker options"; it does not say this rewrites the default picker for every user, and no screenshot or storybook update accompanies it. Mobile already behaves this way, so parity is an argument for it, but it is a product decision that should be made explicitly. The server also tags custom models with "Custom model from config.json" (execution-options.ts#L394), which would now render under every custom model row.

Codex picker on PR 2063
PR #2063, Codex tab: centered two-line rows with truncated marketing copy; only 5 of 8 models fit before scrolling.
Fake OMP picker on PR 2063
PR #2063, Fake OMP tab: the duplicates are now distinguishable (the intended effect), but centered.

[MEDIUM] The approach treats the symptom for one agent rather than the general problem. Rows are distinguishable only when the agent happens to put a distinguishing string in description. An agent that sends description: null (pi-acp does, per the comment at wire.ts#L222-L231) or a prose description identical for both variants would still produce identical rows. The existing qualifier slot, driven by a label-collision check, handles all cases and is the smaller change (see §6).

[LOW] Stale base. Branch is on 00561091, 73 commits behind; it merges cleanly onto fcada5a3b and typechecks/tests pass there, so this is cosmetic — rebase before merge.

[LOW] Tooltip not updated. The title attribute stays label · qualifier; with the second line present the hover tooltip no longer matches the row content. Minor.

Things I checked and found fine: no as/unknown smuggling; acpOptionalString correctly normalises null; no HOST_DAEMON_PROTOCOL_VERSION bump needed (required field, value-only change) — the PR states this correctly; no server/daemon boundary violation (the bridge is the right layer for provider translation); ModelPickerOption only flows into ModelReasoningPicker, so OptionPicker's own description rendering is not accidentally activated; the plugin, app and mobile tests added by the PR are real regression tests (the plugin one fails on base).

Verdict: REQUEST CHANGES. Keep the wire.ts / model-catalog.ts hunks and their tests as-is. For the desktop half either (a) restrict the secondary text to rows whose label collides and use the existing qualifier slot, or (b) if the maintainers explicitly want mobile parity (descriptions everywhere), fix the centering (text-left), re-verify Claude Code / Codex density with screenshots, and call the behaviour change out in the PR description.

9. Appendix

Raw omp probe (session/new over stdio, first 3 options and collisions)

$ node probe-omp-acp.mjs omp acp
config option ids: mode, model, thinking
model select options: 72
first 3 options raw:
[
  { "value": "github-copilot/claude-fable-5",   "name": "Claude Fable 5",   "description": "github-copilot/claude-fable-5" },
  { "value": "github-copilot/claude-haiku-4.5", "name": "Claude Haiku 4.5", "description": "github-copilot/claude-haiku-4.5" },
  { "value": "github-copilot/claude-opus-4.5",  "name": "Claude Opus 4.5",  "description": "github-copilot/claude-opus-4.5" }
]
display names shared by >1 option: 15
  "GPT-4.1" -> github-copilot/gpt-4.1 | github-copilot/gpt-4.1-2025-04-14
  "GPT-4o" -> github-copilot/gpt-4o | github-copilot/gpt-4-o-preview | github-copilot/gpt-4o-2024-05-13 | github-copilot/gpt-4o-2024-08-06 | github-copilot/gpt-4o-2024-11-20
  "GPT-5" -> github-copilot/gpt-5 | openai-codex/gpt-5
  "GPT-5.1" -> github-copilot/gpt-5.1 | openai-codex/gpt-5.1
  "GPT-5.1 Codex" -> github-copilot/gpt-5.1-codex | openai-codex/gpt-5.1-codex

Execution-options analysis for the real omp provider (base)

models: 72 selectedOnly: 0 nonEmptyDescriptions: 0
duplicate display names: 15
  "GPT-4.1" -> github-copilot/gpt-4.1 | github-copilot/gpt-4.1-2025-04-14
  "GPT-4o" -> github-copilot/gpt-4o | github-copilot/gpt-4-o-preview | github-copilot/gpt-4o-2024-05-13 | github-copilot/gpt-4o-2024-08-06 | github-copilot/gpt-4o-2024-11-20
  "GPT-5" -> github-copilot/gpt-5 | openai-codex/gpt-5
  "GPT-5.1" -> github-copilot/gpt-5.1 | openai-codex/gpt-5.1
  "GPT-5.1 Codex" -> github-copilot/gpt-5.1-codex | openai-codex/gpt-5.1-codex
  "GPT-5.1 Codex Max" -> github-copilot/gpt-5.1-codex-max | openai-codex/gpt-5.1-codex-max
  "GPT-5.1 Codex mini" -> github-copilot/gpt-5.1-codex-mini | openai-codex/gpt-5.1-codex-mini
  "GPT-5.2" -> github-copilot/gpt-5.2 | openai-codex/gpt-5.2
  "GPT-5.2 Codex" -> github-copilot/gpt-5.2-codex | openai-codex/gpt-5.2-codex
  "GPT-5.4" -> github-copilot/gpt-5.4 | openai-codex/gpt-5.4
  "GPT-5.4 nano" -> github-copilot/gpt-5.4-nano | openai-codex/gpt-5.4-nano
  "GPT-5.5" -> github-copilot/gpt-5.5 | openai-codex/gpt-5.5
  "GPT 3.5 Turbo" -> github-copilot/gpt-3.5-turbo | github-copilot/gpt-3.5-turbo-0613
  "GPT 4" -> github-copilot/gpt-4 | github-copilot/gpt-4-0613
  "GPT-4o mini" -> github-copilot/gpt-4o-mini | github-copilot/gpt-4o-mini-2024-07-18

Picker DOM rows, PR build (textContent of each row button)

Fake OMP : "GLM 4.7zai/glm-4.7", "GLM 4.7openrouter/glm-4.7", "Claude Opus 5anthropic/claude-opus-5"
Claude   : "Fable 5Fable 5 for demanding reasoning; requires Claude Code v2.1.170+",
           "Opus 51MOpus 5 with 1M context for complex long coding sessions", …
Codex    : "5.6-SolLatest frontier agentic coding model.",
           "5.6-TerraBalanced agentic coding model for everyday work.", …
computed : buttonTextAlign "center", labelTextAlign "center", wrapper "flex min-w-0 flex-1 flex-col gap-0.5" width 266px

Commands run (abridged)

git checkout fcada5a3b && pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
cd plugins/provider-acp && pnpm exec vitest run src/bridge/model-catalog.repro-2062.test.ts      # fails on base
cp 2062/repro/config.json "$DATA_DIR/config.json" && scripts/bb-dev-app current
pnpm bb:dev provider list ; pnpm bb:dev provider models acp-fake-omp ; pnpm bb:dev provider models acp-omp
curl -s "$BB_SERVER_URL/api/v1/system/execution-options?providerId=acp-fake-omp"
node 2062/repro/probe-omp-acp.mjs omp acp
doobie --headless run 2062/repro/doobie-*.js                                                       # screenshots
git fetch origin pull/2063/head:pr-2063 && git switch -c pr-2063-on-base fcada5a3b && git merge pr-2063
pnpm exec turbo run typecheck --filter=bb-plugin-provider-acp --filter=@bb/app --filter=@bb/mobile
cd apps/app && pnpm exec vitest run src/components/pickers/ModelReasoningPicker.test.tsx src/hooks
pnpm dev:stop && pnpm exec turbo run build && scripts/bb-dev-app current                         # PR build screenshots
pnpm dev:stop ; rm -rf "$DATA_DIR" /tmp/bb-2062-scratch ; ss -ltn | grep -E "17877|25877|33877"    # cleanup, ports free

Other artifacts: execution-options on the PR build (descriptions populated), PR diff, doobie scripts used for every screenshot.