diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 66a37a18..352c1a61 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -1,7 +1,8 @@ -import { describe, test, expect, beforeEach } from "bun:test" +import { describe, test, expect, beforeEach, spyOn, afterEach } from "bun:test" import { createBuiltinAgents } from "./utils" import type { AgentConfig } from "@opencode-ai/sdk" import { clearSkillCache } from "../features/opencode-skill-loader/skill-content" +import * as connectedProvidersCache from "../shared/connected-providers-cache" const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5" @@ -46,17 +47,32 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.sisyphus.reasoningEffort).toBeUndefined() }) - test("Oracle uses first fallback entry when no availableModels provided (no cache scenario)", async () => { - // #given - no available models simulates CI without model cache + test("Oracle uses connected provider when no availableModels but connected cache exists", async () => { + // #given - connected providers cache exists with openai + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) // #when const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) - // #then - uses first fallback entry (openai/gpt-5.2) instead of system default + // #then - uses openai from connected cache expect(agents.oracle.model).toBe("openai/gpt-5.2") expect(agents.oracle.reasoningEffort).toBe("medium") expect(agents.oracle.textVerbosity).toBe("high") expect(agents.oracle.thinking).toBeUndefined() + cacheSpy.mockRestore() + }) + + test("Oracle created without model field when no cache exists (first run scenario)", async () => { + // #given - no cache at all (first run) + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) + + // #when + const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) + + // #then - oracle should be created with system default model (fallback to systemDefaultModel) + expect(agents.oracle).toBeDefined() + expect(agents.oracle.model).toBe(TEST_DEFAULT_MODEL) + cacheSpy.mockRestore() }) test("Oracle with GPT model override has reasoningEffort, no thinking", async () => { @@ -107,26 +123,42 @@ describe("createBuiltinAgents with model overrides", () => { }) describe("createBuiltinAgents without systemDefaultModel", () => { - test("creates agents successfully without systemDefaultModel", async () => { - // #given - no systemDefaultModel provided + test("creates agents with connected provider when cache exists", async () => { + // #given - connected providers cache exists + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) // #when const agents = await createBuiltinAgents([], {}, undefined, undefined) - // #then - agents should still be created using fallback chain + // #then - agents should use connected provider from fallback chain expect(agents.oracle).toBeDefined() expect(agents.oracle.model).toBe("openai/gpt-5.2") + cacheSpy.mockRestore() }) - test("sisyphus uses fallback chain when systemDefaultModel undefined", async () => { - // #given - no systemDefaultModel + test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => { + // #given - no cache and no system default + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) // #when const agents = await createBuiltinAgents([], {}, undefined, undefined) - // #then - sisyphus should use its fallback chain + // #then - oracle should NOT be created (resolveModelWithFallback returns undefined) + expect(agents.oracle).toBeUndefined() + cacheSpy.mockRestore() + }) + + test("sisyphus uses connected provider when cache exists", async () => { + // #given - connected providers cache exists with anthropic + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"]) + + // #when + const agents = await createBuiltinAgents([], {}, undefined, undefined) + + // #then - sisyphus should use anthropic from connected cache expect(agents.sisyphus).toBeDefined() expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5") + cacheSpy.mockRestore() }) }) diff --git a/src/shared/model-resolver.test.ts b/src/shared/model-resolver.test.ts index d4b0dde0..eba415e4 100644 --- a/src/shared/model-resolver.test.ts +++ b/src/shared/model-resolver.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" +import { describe, expect, test, spyOn, beforeEach, afterEach, mock } from "bun:test" import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver" import * as logger from "./logger" +import * as connectedProvidersCache from "./connected-providers-cache" describe("resolveModel", () => { describe("priority chain", () => { @@ -336,8 +337,48 @@ describe("resolveModelWithFallback", () => { expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default") }) - test("uses first fallback entry when availableModels is empty (no cache scenario)", () => { - // #given - empty availableModels simulates CI environment without model cache + test("returns undefined when availableModels empty and no connected providers cache exists", () => { + // #given - both model cache and connected-providers cache are missing (first run) + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) + const input: ExtendedModelResolutionInput = { + fallbackChain: [ + { providers: ["anthropic"], model: "claude-opus-4-5" }, + ], + availableModels: new Set(), + systemDefaultModel: undefined, // no system default configured + } + + // #when + const result = resolveModelWithFallback(input) + + // #then - should return undefined to let OpenCode use Provider.defaultModel() + expect(result).toBeUndefined() + cacheSpy.mockRestore() + }) + + test("uses connected provider when availableModels empty but connected providers cache exists", () => { + // #given - model cache missing but connected-providers cache exists + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"]) + const input: ExtendedModelResolutionInput = { + fallbackChain: [ + { providers: ["anthropic", "openai"], model: "claude-opus-4-5" }, + ], + availableModels: new Set(), + systemDefaultModel: "google/gemini-3-pro", + } + + // #when + const result = resolveModelWithFallback(input) + + // #then - should use openai (second provider) since anthropic not in connected cache + expect(result!.model).toBe("openai/claude-opus-4-5") + expect(result!.source).toBe("provider-fallback") + cacheSpy.mockRestore() + }) + + test("falls through to system default when no cache and systemDefaultModel is provided", () => { + // #given - no cache but system default is configured + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) const input: ExtendedModelResolutionInput = { fallbackChain: [ { providers: ["anthropic"], model: "claude-opus-4-5" }, @@ -349,9 +390,10 @@ describe("resolveModelWithFallback", () => { // #when const result = resolveModelWithFallback(input) - // #then - should use first fallback entry, not system default - expect(result!.model).toBe("anthropic/claude-opus-4-5") - expect(result!.source).toBe("provider-fallback") + // #then - should fall through to system default + expect(result!.model).toBe("google/gemini-3-pro") + expect(result!.source).toBe("system-default") + cacheSpy.mockRestore() }) test("returns system default when fallbackChain is not provided", () => { diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts index 2fb8a07e..627d1ab1 100644 --- a/src/shared/model-resolver.ts +++ b/src/shared/model-resolver.ts @@ -58,25 +58,26 @@ export function resolveModelWithFallback( const connectedProviders = readConnectedProvidersCache() const connectedSet = connectedProviders ? new Set(connectedProviders) : null - for (const entry of fallbackChain) { - for (const provider of entry.providers) { - if (connectedSet === null || 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, - hasConnectedCache: connectedSet !== null - }) - return { model, source: "provider-fallback", variant: entry.variant } + // When no cache exists at all, skip fallback chain and fall through to system default + // This allows OpenCode to use Provider.defaultModel() as the final fallback + if (connectedSet === null) { + log("No cache available, skipping fallback chain to use 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 matching provider in connected cache, falling through to system default") } - const firstEntry = fallbackChain[0] - const firstProvider = firstEntry.providers[0] - const model = `${firstProvider}/${firstEntry.model}` - log("Model resolved via fallback chain (no cache at all, using first entry)", { provider: firstProvider, model: firstEntry.model, variant: firstEntry.variant }) - return { model, source: "provider-fallback", variant: firstEntry.variant } } for (const entry of fallbackChain) {