← reports

#3142 · Unavailable machine providers remain selectable

Bug Medium Effort: Medium providers ui open on GitHub 2026-09-05 · base dba32a469

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The new-thread picker can retain a provider that the selected machine reports as not_installed. A focused hook test changed the execution target while a provider preference was remembered; the picker kept both providers and kept the unavailable one selected instead of falling back to the ready provider. The same failure occurred in two clean checkouts at the trusted base commit. The cause is a split availability model: the server roster includes every always-visibility provider, while the client only asks for machine readiness when there is no existing provider preference and then maps the complete roster into options.

2. Claims vs findings

ClaimStatusEvidence
A provider confirmed unavailable on the selected machine remains in the new-thread provider picker.VerifiedThe regression supplied machine-routed state with one not_installed provider and one ready provider. Actual options were [global-provider, project-provider]; expected options contained only the ready provider.
A remembered provider remains selected after the execution machine changes.VerifiedAfter routing changed to the second host, actual selection remained project-provider; expected selection was global-provider.
The server roster does not filter always-visibility providers by target-host installation state.VerifiedThe trusted source concatenates all configured always providers with the separately probed installed-only group. Only the installed-only group receives provider.health filtering.
The machine CLI-status endpoint can independently report that a listed provider is absent.Verified in sourceThe status service asks every installation-capable roster entry for provider.installation.status. That result is not joined back into the execution-options roster or picker options.
Selecting the absent provider shows the exact reported model-loading error.UnverifiedNo real remote provider process was launched. The deterministic test covered option derivation and fallback, not the rendered error copy.

3. Environment

4. Minimal reproduction

  1. At the trusted base commit, save the test below as apps/app/src/hooks/useThreadCreationOptions.issue-3142.test.tsx.
  2. Run:
    pnpm exec turbo run test --filter=@bb/app --force -- src/hooks/useThreadCreationOptions.issue-3142.test.tsx
  3. Expected:
    providerOptions: ["global-provider"]
    selectedProviderId: "global-provider"
    Test Files  1 passed (1)
  4. Actual:
    AssertionError: expected [ 'global-provider', 'project-provider' ] to deeply equal [ 'global-provider' ]
    AssertionError: expected 'project-provider' to be 'global-provider'
    Test Files  1 failed (1)
    Tests       1 failed (1)

Repro files: test, first run, second clean run.

Complete repro test

// @vitest-environment jsdom

import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type {
  SystemExecutionOptionsResponse,
  SystemProviderState,
} from "@bb/server-contract";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { makeProviderInfo } from "@bb/test-helpers/domain-fixtures";
import { sdk } from "@/lib/sdk";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { useThreadCreationOptions } from "./useThreadCreationOptions";

const REMEMBERED_PROVIDER_ID = "project-provider";
const READY_PROVIDER_ID = "global-provider";

vi.mock("@/lib/sdk", () => ({
  sdk: {
    system: {
      executionOptions: vi.fn(),
      providerStates: vi.fn(),
    },
  },
}));

function providerState(
  providerId: string,
  status: SystemProviderState["status"],
): SystemProviderState {
  return {
    providerId,
    displayName: providerId,
    status,
    statusMessage: null,
    planLabel: null,
    accountEmail: null,
    installedVersion: null,
    minimumSupportedVersion: null,
    canInstall: false,
    canUpdate: false,
    loginCommand: null,
  };
}

function executionOptions(): SystemExecutionOptionsResponse {
  const provider = makeProviderInfo({
    id: READY_PROVIDER_ID,
    displayName: "Ready Provider",
    logoUrl: null,
    maintenance: { health: true, usage: false, installation: true },
    composerActions: [],
  });
  return {
    providers: [
      provider,
      {
        ...provider,
        id: REMEMBERED_PROVIDER_ID,
        displayName: "Remembered Provider",
      },
    ],
    models: [],
    selectedOnlyModels: [],
    permissionCeiling: "full",
    modelLoadError: null,
  };
}

beforeEach(() => {
  window.localStorage.setItem(
    "bb.promptbox.environment",
    "host:first-host:local",
  );
  window.localStorage.setItem(
    "bb.promptbox.provider",
    REMEMBERED_PROVIDER_ID,
  );
  vi.mocked(sdk.system.executionOptions).mockResolvedValue(executionOptions());
  vi.mocked(sdk.system.providerStates).mockImplementation(async (args) => ({
    providers:
      args?.hostId === "second-host"
        ? [
            providerState(REMEMBERED_PROVIDER_ID, "not_installed"),
            providerState(READY_PROVIDER_ID, "ready"),
          ]
        : [providerState(REMEMBERED_PROVIDER_ID, "ready")],
  }));
});

afterEach(() => {
  cleanup();
  window.localStorage.clear();
  vi.clearAllMocks();
});

it("removes a remembered unavailable provider and selects a ready provider after a machine change", async () => {
  const { result } = renderHook(
    () =>
      useThreadCreationOptions({
        scope: "new-thread",
        preferReadyProviderWhenUnset: true,
      }),
    { wrapper: createQueryClientTestHarness().wrapper },
  );

  await waitFor(() => {
    expect(result.current.selectedProviderId).toBe(REMEMBERED_PROVIDER_ID);
  });

  act(() => {
    result.current.setEnvironmentSelectionValue("host:second-host:local");
  });

  await waitFor(() => {
    expect(sdk.system.executionOptions).toHaveBeenCalledWith(
      expect.objectContaining({ hostId: "second-host" }),
    );
    expect(result.current.providerOptions.length).toBeGreaterThan(0);
  });
  expect.soft(
    result.current.providerOptions.map((option) => option.value),
  ).toEqual([READY_PROVIDER_ID]);
  expect(result.current.selectedProviderId).toBe(READY_PROVIDER_ID);
});

Second clean verification

A fresh detached worktree was created at the exact base SHA, received the same test file, completed a frozen install, and ran the same Turbo command. It failed with the same two assertions: the unavailable provider remained in the options and remained selected. No report claim was changed after the second run.

5. Root cause

The server’s machine-routed provider roster has two paths. Configured providers with always visibility are returned directly. In contrast, installed-only providers are probed per host and omitted on not_installed. The final host roster simply concatenates those two lists.

return listConfiguredSystemProviderInfos(deps, capability).concat(
  await listInstalledPluginProviderInfos(deps, hostId, capability),
);

The client has a separate readiness path, but enables it only when the new-thread selection is empty and latches the first result. A stored provider therefore prevents readiness from being queried, and a later machine change does not reset the latch. The hook then accepts any roster member as effective and maps every roster member into a picker option.

The machine installation query is already present in the root composer, but only the selected provider’s unsupported-version flag is consumed. Also, ProviderInfo.available is assigned from plugin registration availability rather than target-machine installation status (registration projection).

A deeper constraint is deliberate historical behavior: the repository already has a test requiring the initial ready provider to remain latched across machine switches, introduced as part of keeping provider tabs stable. Correcting this report therefore requires an explicit decision about when stability yields to confirmed machine unavailability.

6. Proposed fix (first principles)

Keep plugin availability, machine installation/readiness, authentication, and transient failures as distinct states. For the new-thread scope, resolve provider states for the current execution routing even when a provider preference exists; exclude only providers whose current machine state is definitively not_installed; keep ready, authentication failures, and unknown visible. Recompute the fallback when routing changes, selecting the first ready provider if the remembered selection is definitively unavailable. Add the repro as a regression test and revise the existing latching test to encode the agreed machine-switch policy. Separately decide whether bb provider list --machine should report runtime readiness or continue reporting plugin registration availability; overloading ProviderInfo.available would conflate two meanings.

7. Related issues

8. Appendix

Commands run

git fetch origin main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=@bb/app --force -- src/hooks/useThreadCreationOptions.issue-3142.test.tsx
git worktree add --detach <clean-checkout> dba32a469fd820ff6106715db0aaf6ed297d79a5
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run test --filter=@bb/app --force -- src/hooks/useThreadCreationOptions.issue-3142.test.tsx

Trust note

The issue title, body, comments, links, code blocks, and quoted text were treated as untrusted claims. No issue-supplied command, patch, branch, binary, or external URL was executed. Investigation and both test runs used only trusted origin/main code plus the reproduction test written from repository evidence.

Limits

No linked open pull request was found. The exact remote Linux setup, provider executables, and reported rendered error text were not exercised; the central option and selection defect was reproduced at the exact hook boundary in two clean checkouts.