Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions apps/server/src/services/system/execution-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,25 @@ async function listSystemProviderInfosForHost(
hostId: string,
capability?: ProviderCapabilityFilter,
): Promise<ProviderInfo[]> {
return listConfiguredSystemProviderInfos(deps, capability).concat(
await listInstalledPluginProviderInfos(deps, hostId, capability),
const configured = listConfiguredSystemProviderInfos(deps, capability);
const installed = await listInstalledPluginProviderInfos(
deps,
hostId,
capability,
);
// Both halves follow the registry's user order, but concatenating them
// would pin every always-listed provider above every installed-only one,
// so a drag that moves a provider across that boundary never sticks.
// Keep the registry's order instead: list whichever half survived the
// capability filter and the installed-only health probe.
const listedIds = new Set([
...configured.map((provider) => provider.id),
...installed.map((provider) => provider.id),
]);
return deps.providerRegistry
.list()
.filter((registration) => listedIds.has(registration.info.id))
.map((registration) => registration.info);
}

function resolveSystemProviderInfosPlan(
Expand Down
14 changes: 12 additions & 2 deletions apps/server/test/helpers/test-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { serve } from "@hono/node-server";
import type { AddressInfo } from "node:net";
import { createConnection, type DbConnection } from "@bb/db";
import { createConnection, getAppSettings, type DbConnection } from "@bb/db";
import { defaultFeatureFlags, type HostType } from "@bb/domain";
import { initDb } from "../../src/db.js";
import { createApp } from "../../src/server.js";
Expand Down Expand Up @@ -166,7 +166,17 @@ export async function createTestAppHarness(
const watchInterests = new WatchInterestCoordinator({ db, hub });
const sharedPorts = new HostSharedPortCoordinator({ db, hub });
const workspaceReadCaches = new WorkspaceReadCaches({ hub });
const providerRegistry = createProviderRegistryService({});
// Same preference wiring as the real server: picker order and the default
// provider are user settings read per registry call.
const providerRegistry = createProviderRegistryService({
readUserProviderPreferences: () => {
const settings = getAppSettings(db);
return {
providerOrder: settings.providerOrder,
defaultProviderId: settings.defaultProviderId,
};
},
});
const pluginHostArtifacts = new PluginHostArtifactRegistry();
const providerNativeRoots = createProviderNativeRootsCache(
nativeRootsClock === undefined ? {} : { now: nativeRootsClock },
Expand Down
37 changes: 37 additions & 0 deletions apps/server/test/system/provider-routing.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { updateHost } from "@bb/db";
import { defaultAppSettings } from "@bb/domain";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import type { HostDaemonOnlineRpcRequestMessage } from "@bb/host-daemon-contract";
Expand Down Expand Up @@ -265,4 +266,40 @@ describe("GET /api/v1/system/providers", () => {
}
});
});

it("keeps the user's providerOrder across the installed-only visibility split", async () => {
await withTestHarness({}, async (harness) => {
const primary = seedHostSession(harness.deps, {
id: "host-provider-order-primary",
});
seedPrimaryHost(harness.deps, primary.host.id);
// Only acp-opencode reports installed; every other installed-only
// agent (acp-omp, acp-grok) stays hidden from the listing.
registerHostRpcResponder(harness, {
hostId: primary.host.id,
sessionId: primary.session.id,
handle: (request) =>
providerHostResponse(request, "acp-opencode", "model"),
});

const put = await harness.app.request("/api/v1/settings/general", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...defaultAppSettings,
providerOrder: ["acp-opencode", "claude-code", "codex"],
defaultProviderId: null,
}),
});
expect(put.status).toBe(200);

const ids = await providerIds(
await harness.app.request("/api/v1/system/providers"),
);
// The installed-only provider the user pinned first must lead the
// listing; the always-listed providers follow in pinned order, not
// ahead of it.
expect(ids.slice(0, 3)).toEqual(["acp-opencode", "claude-code", "codex"]);
});
});
});