#5311 · Explicit ACP catalog failures become successful default-only results
Verdict: REPRODUCED · Root-cause confidence: high · Label: confirmed-repro
1. TL;DR
A custom ACP provider can lose its advertised models when its configured catalog command fails. The bridge reports a successful one-model catalog instead of an error if its process-local cache is empty. The server treats a successful catalog as authoritative and persists it, so formerly selectable models disappear. The daemon can shut down its maintenance runtime after one idle minute, explaining why a catalog from an earlier refresh is not necessarily in the next bridge process. Returning an existing JSON-RPC error when an explicit listing has no usable catalog lets the server retain its last good list.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Nonzero exit produces a successful default-only response | Verified | Fresh-process script output; failing regression on two trusted checkouts. |
| Command timeout has the same result | Verified | A real process exceeds the production 30-second timeout in both checkouts; expected error is absent. |
| The process-local cache cannot protect subsequent fresh bridges | Verified mechanism | Separate bridge processes yield three models, then one default; daemon maintenance idle shutdown is configured for 60 seconds. |
| Server persists the degraded response and logs success | Verified by trusted source | Settlement overwrites entry.good, calls persistGood, and records outcome success for every successful result. No live server incident was recreated. |
| Specific desktop timing and scheduler retry count | Unverified | No access to the reporter's private incident; no macOS run. |
3. Environment
- Trusted origin/main commit: fe1a02d7b994866cfd477e3ad7c0bc85fa525002; get-bb/bb is public.
- Linux 4.19.0-gvisor x86_64; Node v22.19.0; pnpm 9.15.0.
- Two clean temporary shared-object clones, separately installed and built with the frozen lockfile. Shared Git objects do not share worktrees or bridge module state.
- No app server, ports, production data, real provider, credentials, or external model requests. Each regression uses its own temporary workspace, cleaned by the existing fixture.
- No linked open PR at investigation time. Only trusted main code and agent-authored regressions were executed.
4. Minimal reproduction
- Clone the public repository and select the recorded base, then install and build:
git clone https://github.com/get-bb/bb.git bb-5311 cd bb-5311 git checkout --detach fe1a02d7b994866cfd477e3ad7c0bc85fa525002 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Download the attached fresh-process script to an absolute scratch path; execute it with the checkout path:
node /tmp/model-list-repro.mjs "$PWD"
- The first isolated bridge returns alpha, beta, gamma. The next isolated bridge runs a listing that exits with status 2. Expected: JSON-RPC error and no result. Actual: successful result containing only acp-default.
successful listing {"jsonrpc":"2.0","id":1,"result":{"models":[{"id":"alpha","model":"alpha","displayName":"Alpha","description":"","supportedReasoningEfforts":[{"reasoningEffort":"medium","description":"Alpha"}],"defaultReasoningEffort":"medium","supportedServiceTiers":[],"isDefault":true},{"id":"beta","model":"beta","displayName":"Beta","description":"","supportedReasoningEfforts":[{"reasoningEffort":"medium","description":"Beta"}],"defaultReasoningEffort":"medium","supportedServiceTiers":[],"isDefault":false},{"id":"gamma","model":"gamma","displayName":"Gamma","description":"","supportedReasoningEfforts":[{"reasoningEffort":"medium","description":"Gamma"}],"defaultReasoningEffort":"medium","supportedServiceTiers":[],"isDefault":false}],"selectedOnlyModels":[]}} unsuccessful listing in a fresh bridge {"jsonrpc":"2.0","id":1,"result":{"models":[{"id":"acp-default","model":"acp-default","displayName":"Agent default","description":"Model selection is managed by the connected ACP agent.","supportedReasoningEfforts":[{"reasoningEffort":"medium","description":"Reasoning effort is managed by the connected ACP agent."}],"defaultReasoningEffort":"medium","isDefault":true}],"selectedOnlyModels":[]}} - Apply the attached regression-only patch to unchanged production, and run:
git apply /tmp/regression.patch pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- --testNamePattern='configured command|last good CLI catalog'
Expected after repair: four cases pass. Actual before repair: nonzero exit, unusable output, and timeout all lack the expected error; same-command last-good cache preservation already passes.@bb/provider-bridge-acp:test: Test Files 1 failed | 18 skipped (19) @bb/provider-bridge-acp:test: Tests 3 failed | 1 passed | 388 skipped (392) @bb/provider-bridge-acp:test: Start at 01:45:35 @bb/provider-bridge-acp:test: Duration 37.33s (transform 76.00s, setup 0ms, import 89.54s, tests 30.48s, environment 1ms) @bb/provider-bridge-acp:test: @bb/provider-bridge-acp:test: @bb/provider-bridge-acp:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯ @bb/provider-bridge-acp:test: @bb/provider-bridge-acp:test: FAIL |@bb/provider-bridge-acp:isolated| src/bridge/bridge.test.ts > acp bridge > fails model/list when the configured command exits unsuccessfully without a cached catalog @bb/provider-bridge-acp:test: FAIL |@bb/provider-bridge-acp:isolated| src/bridge/bridge.test.ts > acp bridge > fails model/list when the configured command prints no models without a cached catalog @bb/provider-bridge-acp:test: FAIL |@bb/provider-bridge-acp:isolated| src/bridge/bridge.test.ts > acp bridge > fails model/list when the configured command times out without a cached catalog @bb/provider-bridge-acp:test: AssertionError: expected undefined to be 'ACP model list command failed to prov…' // Object.is equality @bb/provider-bridge-acp:test: @bb/provider-bridge-acp:test: - Expected: @bb/provider-bridge-acp:test: "ACP model list command failed to provide a model catalog." @bb/provider-bridge-acp:test: @bb/provider-bridge-acp:test: + Received: @bb/provider-bridge-acp:test: undefined @bb/provider-bridge-acp:test: @bb/provider-bridge-acp:test: ❯ src/bridge/bridge.test.ts:1229:39 @bb/provider-bridge-acp:test: 1227| 40_000, @bb/provider-bridge-acp:test: 1228| ); @bb/provider-bridge-acp:test: 1229| expect(response.error?.message).toBe( @bb/provider-bridge-acp:test: | ^ @bb/provider-bridge-acp:test: 1230| "ACP model list command failed to provide a model catalog.", @bb/provider-bridge-acp:test: 1231| ); @bb/provider-bridge-acp:test: @bb/provider-bridge-acp:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/3]⎯ @bb/provider-bridge-acp:test: @bb/provider-bridge-acp:test: ELIFECYCLE Test failed. See above for more details. @bb/provider-bridge-acp#test: ERROR command (<isolated-work>/base/packages/provider-bridge-acp) /usr/local/bin/pnpm run test --testNamePattern=configured command|last good CLI catalog exited (1) Tasks: 1 successful, 2 total Cached: 0 cached, 2 total Time: 39.813s Failed: @bb/provider-bridge-acp#test ERROR run failed: command exited (1)
Fresh-process script
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
const checkout = resolve(process.argv[2] ?? ".");
const bridgeUrl = pathToFileURL(
resolve(checkout, "packages/provider-bridge-acp/src/bridge/bridge.ts"),
).href;
const scenarios = [
["successful listing", "process.stdout.write('alpha - Alpha\\nbeta - Beta\\ngamma - Gamma\\n')"],
["unsuccessful listing in a fresh bridge", "process.exit(2)"],
];
for (const [name, script] of scenarios) {
const request = {
jsonrpc: "2.0",
id: 1,
method: "model/list",
params: {
providerOptions: {
acpLaunchSpec: {
displayName: "Catalog Reproduction",
command: process.execPath,
args: ["-e", script],
env: {},
modelCli: { listArgs: ["-e", script], primaryModels: [] },
},
},
},
};
const worker = `import { handleLine } from ${JSON.stringify(bridgeUrl)}; handleLine(${JSON.stringify(JSON.stringify(request))});`;
const result = spawnSync(
process.execPath,
["--conditions=source", "--import", "tsx", "--input-type=module", "-e", worker],
{ cwd: checkout, encoding: "utf8", timeout: 45_000 },
);
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(result.stderr);
console.log(name);
console.log(result.stdout.trim());
}
Regression test
This patch extends the owning bridge boundary tests without production test seams. It deliberately replaces the prior test that blessed the empty-output fallback.
diff --git a/packages/provider-bridge-acp/src/bridge/bridge.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.test.ts
index 06f4cd4ce..73aa9e83c 100644
--- a/packages/provider-bridge-acp/src/bridge/bridge.test.ts
+++ b/packages/provider-bridge-acp/src/bridge/bridge.test.ts
@@ -1210,11 +1210,49 @@ describe("acp bridge", () => {
});
});
- it("falls back to the synthetic model when the list command prints no models", async () => {
- const emptyId = sendModelList({ modelLines: "no model lines here" });
- expect((await waitForResponse(emptyId)).result).toMatchObject({
- models: [{ id: "acp-default", isDefault: true }],
+ it.each([
+ ["exits unsuccessfully", "process.exit(2)"],
+ ["prints no models", "process.stdout.write('no model lines here')"],
+ ["times out", "setInterval(() => {}, 1000)"],
+ ])(
+ "fails model/list when the configured command %s without a cached catalog",
+ async (_failure, script) => {
+ const failingId = sendModelList({
+ agent: { command: process.execPath, args: ["-e", script] },
+ modelListArgs: ["--"],
+ });
+ const response = await waitFor(
+ () => findResponse(failingId),
+ `response ${failingId}`,
+ 40_000,
+ );
+ expect(response.error?.message).toBe(
+ "ACP model list command failed to provide a model catalog.",
+ );
+ expect(response.result).toBeUndefined();
+ },
+ 45_000,
+ );
+
+ it("keeps the last good CLI catalog when the same command later fails", async () => {
+ const scriptPath = join(workspaceDir, "model-list.cjs");
+ writeFileSync(
+ scriptPath,
+ "process.stdout.write('cached-model - Cached Model\\n')",
+ );
+ const launch = {
+ agent: { command: process.execPath, args: [scriptPath] },
+ modelListArgs: ["--"],
+ };
+ const first = await waitForResponse(sendModelList(launch));
+ expect(first.result).toMatchObject({
+ models: [{ id: "cached-model", displayName: "Cached Model" }],
});
+
+ writeFileSync(scriptPath, "process.exit(2)");
+ const second = await waitForResponse(sendModelList(launch));
+ expect(second.error).toBeUndefined();
+ expect(second.result).toEqual(first.result);
});
it("keeps CLI reasoning on the resolved model variant instead of ACP config", async () => {
5. Root cause
Explicit model CLI errors and unusable output return a matching process-local cached catalog or null. Authentication and missing executables already have their own error handling. A new bridge has no model cache:
packages/provider-bridge-acp/src/bridge/bridge.ts:777
async function loadAgentModelCatalog(
listCommand: AcpAgentCommandParam,
): Promise<AgentModelCatalog | null> {
const stdout = await execPortableFile(listCommand.command, listCommand.args, {
cwd: listCommand.cwd ?? process.cwd(),
env: {
...withoutBridgeRuntimeEnv(process.env),
...(listCommand.envVars ?? {}),
},
maxBuffer: 1024 * 1024,
timeout: MODEL_LIST_TIMEOUT_MS,
}).then(
({ stdout }) => stdout,
(error: unknown) => {
if (isMissingExecutableError(error)) throw error;
const output = z
.object({ stdout: z.string(), stderr: z.string() })
.safeParse(error);
if (
isAuthRequiredModelListError(
error,
output.success ? output.data.stdout : "",
output.success ? output.data.stderr : "",
)
) {
throw new AcpModelListAuthRequiredError();
}
return null;
},
);
const key = JSON.stringify(listCommand);
if (stdout === null) {
process.stderr.write(
`acp bridge: model list command "${listCommand.command}" failed\n`,
);
return cachedModelCatalog?.key === key ? cachedModelCatalog.catalog : null;
}
const catalog = buildAgentModelCatalog(parseAgentModelLines(stdout));
if (!catalog) {
process.stderr.write(
`acp bridge: model list command "${listCommand.command}" printed no models\n`,
);
return cachedModelCatalog?.key === key ? cachedModelCatalog.catalog : null;
}
cachedModelCatalog = { key, catalog };
The model-list handler sends the catalog when available. Otherwise an explicit CLI configuration skips session discovery and falls through to the synthetic default success result:
packages/provider-bridge-acp/src/bridge/bridge.ts:2634
const catalog = params.listCommand
? await loadAgentModelCatalog(params.listCommand)
: null;
if (catalog) {
sendModels(
params.parameterizedModelPicker && dialectId === "cursor"
? buildCursorParameterizedModelCatalog(catalog.models)
: catalog.models,
);
return;
}
const sessionDiscoveredModels =
params.listCommand === undefined && params.agent
? await loadSessionDiscoveredModels(
params.agent,
params.reasoningProbePriorityModelIds,
params.parameterizedModelPicker,
)
: null;
if (sessionDiscoveredModels) {
sendModels(sessionDiscoveredModels);
return;
}
sendResult(id, {
models: [
applyConfiguredReasoningToModel(ACP_DEFAULT_MODEL, {
reasoningCli: params.reasoningCli,
nativeReasoning: params.nativeReasoning,
}),
],
selectedOnlyModels: [],
});
}
function decodeLaunchSpec(
The server is behaving consistently with the response it receives: success replaces and persists the good catalog. On failure it records the error without replacing entry.good. Its outcome is named failed (or a categorized error), not the literal failure:
apps/server/src/services/providers/provider-model-catalog-store.ts:418
if (settlement.ok) {
entry.good = {
fingerprint: refresh.fingerprint,
models: settlement.models,
selectedOnlyModels: settlement.selectedOnlyModels,
modelsJson: JSON.stringify(settlement.models),
selectedOnlyModelsJson: JSON.stringify(settlement.selectedOnlyModels),
fetchedAt: refresh.markedStale ? 0 : now,
};
entry.failure = null;
entry.unavailableDetail = null;
persistGood(deps, entry.key, entry.good);
fields = {
outcome: "success",
modelCount:
settlement.models.length + settlement.selectedOnlyModels.length,
};
} else {
const { error } = settlement;
const errorFields =
error instanceof ApiError
? expectedFallbackErrorLogFields(error)apps/server/src/services/providers/provider-model-catalog-store.ts:457
: null;
entry.failure = {
fingerprint: refresh.fingerprint,
code: code ?? "failed",
detail: toProviderModelCatalogFailureDetail(error),
failedAt: now,
};
entry.unavailableDetail = null;
level = code === null ? "error" : "warn";
fields = { outcome: code ?? "failed", ...errorFields };
}
const changed = before !== pickerView(entry, refresh.fingerprint);
if (changed) {
schedulePush(deps, hostId);
}
log(level, { ...fields, changed });
}
function startRefresh(
The cache lifetime mismatch is a normal lifecycle possibility, not just a hypothetical bridge crash. The daemon's maintenance runtime becomes eligible for shutdown after its requests finish:
apps/host-daemon/src/runtime-manager.ts:56
const PROVIDER_MAINTENANCE_IDLE_TIMEOUT_MS = 60_000;
apps/host-daemon/src/runtime-manager.ts:782
private scheduleProviderMaintenanceIdleShutdown(): void {
this.clearProviderMaintenanceIdleTimer();
if (
this.providerMaintenanceActiveRequests > 0 ||
(this.providerMaintenanceRuntime === null &&
this.pendingProviderMaintenanceRuntime === null)
) {
return;
}
const timeoutMs =
this.options.providerMaintenanceIdleTimeoutMs ??
PROVIDER_MAINTENANCE_IDLE_TIMEOUT_MS;
this.providerMaintenanceIdleTimer = setTimeout(() => {
this.providerMaintenanceIdleTimer = null;
if (this.providerMaintenanceActiveRequests > 0) return;
void this.shutdownProviderMaintenanceRuntime().catch((error) => {
this.options.logger?.warn(
{ err: error },
"Failed to shut down idle provider maintenance runtime",
);
});
}, timeoutMs);
this.providerMaintenanceIdleTimer.unref();
6. Proposed fix
After trying an explicit listCommand, reject model/list if no usable cached or fresh catalog exists. Use the existing JSON-RPC error path; do not change public schemas, host protocol, persisted data, dependencies, or server policy. Preserve same-process last-good CLI cache reuse and the synthetic default for agents without a CLI listing. The implementation is a five-line guard in the ACP bridge; the regression changes keep the whole patch at 51 changed text lines across two files.
7. Verification
The same agent repeated the regression in a second clean checkout at the identical trusted commit, with a separate frozen install, complete Turbo build, and new fixture workspace. This is a repeated clean run, not independent-agent verification. The fresh-process script also returned three models followed by a default-only success. No report correction was needed.
@bb/provider-bridge-acp:test: Test Files 1 failed | 18 skipped (19) @bb/provider-bridge-acp:test: Tests 3 failed | 1 passed | 388 skipped (392) @bb/provider-bridge-acp:test: Start at 01:47:29 @bb/provider-bridge-acp:test: Duration 36.69s (transform 73.29s, setup 0ms, import 84.88s, tests 30.38s, environment 1ms) @bb/provider-bridge-acp:test: @bb/provider-bridge-acp:test: ELIFECYCLE Test failed. See above for more details. @bb/provider-bridge-acp#test: ERROR command (<isolated-work>/verify/packages/provider-bridge-acp) /usr/local/bin/pnpm run test --testNamePattern=configured command|last good CLI catalog exited (1) Tasks: 1 successful, 2 total Cached: 0 cached, 2 total Time: 38.624s Failed: @bb/provider-bridge-acp#test ERROR run failed: command exited (1)
After adding the guard in the first checkout, the full ACP bridge suite passed: 388 passed and four intentionally skipped tests across 19 test files. Package lint and typecheck passed in the same Turbo run. The real timeout regression was not mocked.
pnpm exec turbo run test lint typecheck --filter=@bb/provider-bridge-acp Test Files 19 passed (19) Tests 388 passed | 4 skipped (392)
The existing server catalog suite also passes all 18 tests, including preservation of the last good persisted row when a refresh fails. It uses the repository's real in-memory database test harness, not a mocked database.
pnpm exec turbo run test --filter=@bb/server -- test/providers/provider-model-catalog-store.test.ts Test Files 1 passed (1) Tests 18 passed (18)
Server catalog suite output. git diff --check passes; git diff --numstat origin/main reports 42 additions and four deletions in the test, plus five production additions (51 total text lines, two files, no binary changes).
8. Related issues
Trusted GitHub metadata identifies #4459 (closed, native ACP discovery and maintenance lifecycle) and #365 (closed, composer bootstrap resilience). These do not negate the reproduced explicit CLI failure. No PR branch was fetched or executed.
9. Appendix
Artifacts: first regression run, second regression run, raw JSON-RPC output, fixed bridge suite. Nonvisual bug; no screenshots fabricated. Issue content was treated solely as untrusted claims; no issue instructions, scripts, linked binaries, or external issue links were followed.
Investigation commands: trusted GitHub issue/property/label/timeline reads; fetch origin main; shared clean clones; frozen pnpm installs; full Turbo builds; focused and full tests; package lint/typecheck; oxfmt; git diff --check and --numstat. Every GitHub write uses the supplied SlopCop identity wrapper.