#4370 · Catalog fallback changes execution selection
REPRODUCED · Root-cause confidence: high
1. TL;DR
When a verified model list loses the selected ID, the composer selection resolver chooses the list default. It then treats this automatic fallback as an explicit user choice, even when no field was touched. A focused test of the actual selection and source functions reproduces both changes in two clean checkouts. The server code explains why a submitted replacement can persist; live provider behavior and the reporter's historical incidents were not replayed.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Loss of an exact catalog ID selects the default. | Verified | Both test runs change chosen-model[1m] to catalog-default. |
| Automatic recovery is explicit without a user edit. | Verified | Empty touchedFields produces modelSource explicit. |
| The replacement persists into subsequent turns or a model override. | Supported by static trace | Execution planning reads the last model; override recovery stores explicit replacements. No live turn or database replay was performed. |
| Claude discovery fluctuates and still accepts omitted IDs. | Unverified | No provider process, account, or reporter logs were used. |
3. Environment
Linux x86_64, Node v26.8.1, Vitest 4.1.1. Both checkouts used the same trusted base above and frozen installation. Both full Turbo builds passed: 60 tasks successful. No server, browser, ports, runtime data directory, or provider account was used. This is a selection-state defect, verified without a visual layout claim or screenshot.
4. Minimal reproduction
- Check out the recorded main commit and install/build.
- Save the regression test at the path below.
- Run it. The first assertion confirms the original selection survives while present. The second asserts preservation after a verified refresh removes the exact ID but keeps an unsuffixed ID and a different default.
git clone https://github.com/get-bb/bb.git bb-repro cd bb-repro git checkout --detach 0baa605b32a00619c1d7e3f32be6553ebcf8244a pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build # Save the linked test as apps/app/src/hooks/thread-creation-options/issue-4370.test.ts pnpm exec turbo run test --filter=@bb/app -- issue-4370.test.ts # If the unrelated generated-icon precheck blocks Vitest, run the focused investigation directly: cd apps/app pnpm exec vitest run --config vitest.config.ts src/hooks/thread-creation-options/issue-4370.test.ts pnpm exec vitest run --config vitest.config.ts src/hooks/useThreadCreationOptions.test.tsx
Expected: current ID and no explicit source. Actual in both runs:
- "modelSource": undefined, - "selectedModel": "chosen-model[1m]", + "modelSource": "explicit", + "selectedModel": "catalog-default", Test Files 1 failed (1) Tests 1 failed (1)
The failure is intentional regression evidence, not a passing test. The ordinary Turbo test entrypoint stopped at an unrelated generated-PWA-icon freshness check; direct Vitest was used only for investigation after the successful build.
import { expect, it } from "vitest";
import type { AvailableModel } from "@bb/domain";
import { resolveModelCatalogSelection } from "./model-catalog-selection";
import { buildExecutionInputSources } from "./selection-state";
const model = (name: string, isDefault = false): AvailableModel => ({
id: name,
model: name,
displayName: name,
description: "",
supportedReasoningEfforts: [],
defaultReasoningEffort: "medium",
isDefault,
});
it("preserves the current model and its implicit source after a catalog refresh", () => {
const current = model("chosen-model[1m]");
const fallback = model("catalog-default", true);
const select = (models: AvailableModel[]) => resolveModelCatalogSelection({
models,
selectedOnlyModels: [],
selectedModel: current.model,
preferredReasoningLevel: "medium",
provider: undefined,
catalogIsVerified: true,
formatModelLabel: (label) => label,
});
expect(select([current, fallback]).selectedModel).toBe(current.model);
const refreshed = select([model("chosen-model"), fallback]);
const sources = buildExecutionInputSources({
effectiveValues: {
selectedProviderId: "test-provider",
selectedModel: refreshed.selectedModel,
serviceTier: undefined,
reasoningLevel: refreshed.reasoningLevel,
permissionMode: "full",
},
forceExplicitModel: refreshed.isUnavailableModelRecovery,
scope: "component-local",
storedValues: {
selectedProviderId: "",
selectedModel: "",
serviceTier: "",
reasoningLevel: "",
permissionMode: "",
},
touchedFields: new Set(),
});
expect({
selectedModel: refreshed.selectedModel,
modelSource: sources.model,
}).toEqual({
selectedModel: current.model,
modelSource: undefined,
});
});
5. Root cause
Catalog selection checks exact IDs and unique provider-prefix suffix matches. It does not equate a context suffix with its absence. Selected-only entries help only when the exact selected ID is included. Verified nonempty lists missing that ID select isDefault or the first model.
if (!catalogIsVerified && selectedModelSelection) {
return selectedModelSelection;
}
if (availableModels.length === 0) {
return selectedModelSelection;
}
if (
availableModels.some((model) => model.model === selectedModelSelection)
) {
return selectedModelSelection;
}
return (
availableModels.find((model) => model.isDefault)?.model ??
availableModels[0].model
);
})();
The hook feeds the recovery flag into forceExplicitModel. Source resolution treats this flag as a touch, producing explicit.
touched: touchedFields.has("selectedProviderId"),
});
const modelSource = resolveCreateExecutionInputSource({
hasStoredValue:
usesStoredValues &&
hasValue(storedValues.selectedModel) &&
storedValues.selectedModel === effectiveValues.selectedModel,
hasValue: hasValue(effectiveValues.selectedModel),
touched:
forceExplicitModel ||
forcesExplicitExecutionFields ||
touchedFields.has("selectedModel"),
});
const serviceTierSource = resolveCreateExecutionInputSource({
Thread composer execution selection carries the chosen model and sources, and The send mutation forwards them. Server execution precedence prefers supplied model, then thread override, then last execution. Override recovery stores an explicit replacement when an override already exists.
Claude catalog construction derives IDs from resolved probe values and adds aliases from that probe. This code supports the mechanism but does not prove any particular probe fluctuation.
6. Proposed fix and simple-fix decision
Choose an explicit policy for catalog omissions: retain the current ID as a selected-only entry, or block submission and request a deliberate replacement. Preserve new-thread default behavior and truly invalid model recovery. Do not assume context-suffixed IDs are interchangeable without a provider contract.
No automatic PR: the existing missing-model tests deliberately require silent explicit fallback for component-local and new-thread selections. Changing that policy requires a product decision, failing the caller's simple-fix condition. No production edit, branch, or push was attempted. The relevant existing hook suite passes all 27 tests. No open PR linked through the issue timeline or found by issue-number search at review time.
7. Verification
The same agent repeated the test in a second clean detached Git worktree at 0baa605b32a00619c1d7e3f32be6553ebcf8244a. It received a fresh frozen install and full Turbo build, then only the locally authored reproduction file. The identical direct Vitest command failed with the identical two-field diff: first run 330 ms, second run 287 ms. No report correction was needed. This was repeat verification by the same agent, not an independent review.
8. Related issues
Related-issue claims were not needed for this reproduction and were not independently assessed.
9. Appendix
- First reproduction log
- Second reproduction log
- Existing hook suite: 27 passed
- Unrelated Turbo precheck blocker
- Reproduction commands
Trust boundary: issue suggestions were treated as untrusted claims. No linked code, scripts, patches, external URLs, or real runtime data were executed or accessed. Synthetic model IDs isolate the catalog-selection behavior.