diff --git a/apps/server/src/provider/providerPoolSnapshot.test.ts b/apps/server/src/provider/providerPoolSnapshot.test.ts index 9ca11a259457a..ed1d904b3399f 100644 --- a/apps/server/src/provider/providerPoolSnapshot.test.ts +++ b/apps/server/src/provider/providerPoolSnapshot.test.ts @@ -99,23 +99,40 @@ describe("withProviderPools", () => { assert.deepEqual(shared.at(-1)?.continuation, { groupKey: "codex:home:shared" }); }); - it("reports the healthiest member's status and auth", () => { - const published = withProviderPools({ - providers: [ - provider("claudeAgent", claude, { - status: "error", - auth: { status: "unauthenticated" }, - checkedAt: "2026-10-19T00:00:01.000Z", - }), - provider("claudeAgent_work", claude, { checkedAt: "2026-10-19T00:00:02.000Z" }), - ], - providerPools: pools({ claudeAgent: ["claudeAgent", "claudeAgent_work"] }), - }); - const pool = published.at(-1); - assert.equal(pool?.status, "ready"); - assert.equal(pool?.auth.status, "authenticated"); - assert.equal(pool?.checkedAt, "2026-10-19T00:00:02.000Z"); - }); + it.each([false, true])( + "reports the healthiest member's catalog and health (unavailable: %s)", + (unavailable) => { + const published = withProviderPools({ + providers: [ + provider("claudeAgent", claude, { + status: "error", + auth: { status: "unauthenticated" }, + checkedAt: "2026-10-19T00:00:01.000Z", + ...(unavailable + ? { + availability: "unavailable", + enabled: false, + installed: false, + models: [], + slashCommands: [], + } + : {}), + }), + provider("claudeAgent_work", claude, { checkedAt: "2026-10-19T00:00:02.000Z" }), + ], + providerPools: pools({ claudeAgent: ["claudeAgent", "claudeAgent_work"] }), + }); + const pool = published.at(-1); + assert.equal(pool?.status, "ready"); + assert.equal(pool?.auth.status, "authenticated"); + assert.equal(pool?.checkedAt, "2026-10-19T00:00:02.000Z"); + assert.deepEqual( + pool?.models.map((entry) => entry.slug), + ["opus", "sonnet"], + ); + assert.deepEqual(pool?.slashCommands, [{ name: "compact" }, { name: "review" }]); + }, + ); it("keeps the pool and member identities when only one account remains enabled", () => { const providers = [personal, provider("claudeAgent_work", claude, { enabled: false })]; diff --git a/apps/server/src/provider/providerPoolSnapshot.ts b/apps/server/src/provider/providerPoolSnapshot.ts index fe8a2d0258fb3..01044f1961589 100644 --- a/apps/server/src/provider/providerPoolSnapshot.ts +++ b/apps/server/src/provider/providerPoolSnapshot.ts @@ -1,4 +1,5 @@ import { + isProviderAvailable, PROVIDER_DISPLAY_NAMES, resolveActiveProviderPools, type ActiveProviderPool, @@ -64,12 +65,16 @@ function makePoolEntry( members: ReadonlyArray, ): ServerProvider { const healthiest = healthiestMember(members); - const slugs = [...new Set(members.flatMap((member) => member.models.map((model) => model.slug)))]; + // Unavailable placeholders have no catalog and cannot receive failover. + const catalogMembers = members.filter(isProviderAvailable); + const slugs = [ + ...new Set(catalogMembers.flatMap((member) => member.models.map((model) => model.slug))), + ]; // A failover re-sends the run's options to another member, so a model is - // shared only when every member accepts the same options for it. + // shared only when every available member accepts the same options for it. const sharedSlugs = new Set( slugs.filter((slug) => { - const signatures = members.map((member) => { + const signatures = catalogMembers.map((member) => { const model = member.models.find((candidate) => candidate.slug === slug); return model === undefined ? undefined : optionSignature(model); }); @@ -78,7 +83,7 @@ function makePoolEntry( ); }), ); - const models = members + const models = catalogMembers .flatMap((member) => member.models) .filter((model, index, all) => { if (!sharedSlugs.has(model.slug)) return false; @@ -114,8 +119,8 @@ function makePoolEntry( auth: healthiest.auth, checkedAt: healthiest.checkedAt, models, - slashCommands: intersectByName(members.map((member) => member.slashCommands)), - skills: intersectByName(members.map((member) => member.skills)), + slashCommands: intersectByName(catalogMembers.map((member) => member.slashCommands)), + skills: intersectByName(catalogMembers.map((member) => member.skills)), pool: { role: "pool", memberInstanceIds: pool.memberInstanceIds,