diff --git a/src/shared/model-resolver.test.ts b/src/shared/model-resolver.test.ts index a081705cc..464040b86 100644 --- a/src/shared/model-resolver.test.ts +++ b/src/shared/model-resolver.test.ts @@ -356,10 +356,8 @@ describe("resolveModelWithFallback", () => { cacheSpy.mockRestore() }) - test("skips fallback chain when availableModels empty even if connected providers cache exists", () => { + test("uses connected provider from fallback when availableModels empty but cache exists", () => { // #given - model cache missing but connected-providers cache exists - // This scenario caused bugs: provider is connected but may not have the model available - // Fix: When we can't verify model availability, skip fallback chain entirely const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"]) const input: ExtendedModelResolutionInput = { fallbackChain: [ @@ -372,32 +370,49 @@ describe("resolveModelWithFallback", () => { // #when const result = resolveModelWithFallback(input) - // #then - should fall through to system default (NOT use connected provider blindly) - expect(result!.model).toBe("google/gemini-3-pro") - expect(result!.source).toBe("system-default") + // #then - should use connected provider (openai) from fallback chain + expect(result!.model).toBe("openai/claude-opus-4-5") + expect(result!.source).toBe("provider-fallback") cacheSpy.mockRestore() }) - test("prevents selecting model from provider that may not have it (bug reproduction)", () => { - // #given - user removed anthropic oauth, has quotio, but explore agent fallback has opencode - // opencode may be "connected" but doesn't have claude-haiku-4-5 - const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["quotio", "opencode"]) + test("uses github-copilot when google not connected (visual-engineering scenario)", () => { + // #given - user has github-copilot but not google connected + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["github-copilot"]) + const input: ExtendedModelResolutionInput = { + fallbackChain: [ + { providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }, + ], + availableModels: new Set(), + systemDefaultModel: "anthropic/claude-sonnet-4-5", + } + + // #when + const result = resolveModelWithFallback(input) + + // #then - should use github-copilot (second provider) since google not connected + expect(result!.model).toBe("github-copilot/gemini-3-pro") + expect(result!.source).toBe("provider-fallback") + cacheSpy.mockRestore() + }) + + test("falls through to system default when no provider in fallback is connected", () => { + // #given - user only has quotio connected, but fallback chain has anthropic/opencode + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["quotio"]) const input: ExtendedModelResolutionInput = { fallbackChain: [ { providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }, ], - availableModels: new Set(), // no model cache available + availableModels: new Set(), systemDefaultModel: "quotio/claude-opus-4-5-20251101", } // #when const result = resolveModelWithFallback(input) - // #then - should NOT return opencode/claude-haiku-4-5 (model may not exist) - // should fall through to system default which user has configured + // #then - no provider in fallback is connected, fall through to system default expect(result!.model).toBe("quotio/claude-opus-4-5-20251101") expect(result!.source).toBe("system-default") - expect(result!.model).not.toBe("opencode/claude-haiku-4-5") cacheSpy.mockRestore() }) diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts index d58533db4..ba49dff69 100644 --- a/src/shared/model-resolver.ts +++ b/src/shared/model-resolver.ts @@ -55,10 +55,27 @@ export function resolveModelWithFallback( // Step 2: Provider fallback chain (with availability check) if (fallbackChain && fallbackChain.length > 0) { if (availableModels.size === 0) { - // When model cache is empty, we cannot verify if a provider actually has the model. - // Skip fallback chain entirely and fall through to system default. - // This prevents selecting provider/model combinations that may not exist. - log("No model cache available, skipping fallback chain to use system default") + const connectedProviders = readConnectedProvidersCache() + const connectedSet = connectedProviders ? new Set(connectedProviders) : null + + if (connectedSet === null) { + log("Model fallback chain skipped (no connected providers cache) - falling through to system default") + } else { + for (const entry of fallbackChain) { + for (const provider of entry.providers) { + if (connectedSet.has(provider)) { + const model = `${provider}/${entry.model}` + log("Model resolved via fallback chain (no model cache, using connected provider)", { + provider, + model: entry.model, + variant: entry.variant, + }) + return { model, source: "provider-fallback", variant: entry.variant } + } + } + } + log("No connected provider found in fallback chain, falling through to system default") + } } for (const entry of fallbackChain) { diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index 49c81cbe1..653ecf888 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -537,7 +537,7 @@ To continue this session: session_id="${args.session_id}"` } } else { const resolution = resolveModelWithFallback({ - userModel: userCategories?.[args.category]?.model ?? resolved.model ?? sisyphusJuniorModel, + userModel: userCategories?.[args.category]?.model ?? sisyphusJuniorModel, fallbackChain: requirement.fallbackChain, availableModels, systemDefaultModel,