diff --git a/src/agents/utils.ts b/src/agents/utils.ts index a2f14608..dc96f6ff 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -149,7 +149,8 @@ export async function createBuiltinAgents( gitMasterConfig?: GitMasterConfig, discoveredSkills: LoadedSkill[] = [], client?: any, - browserProvider?: BrowserAutomationProvider + browserProvider?: BrowserAutomationProvider, + uiSelectedModel?: string ): Promise> { const connectedProviders = readConnectedProvidersCache() const availableModels = client @@ -198,6 +199,7 @@ export async function createBuiltinAgents( const requirement = AGENT_MODEL_REQUIREMENTS[agentName] const resolution = resolveModelWithFallback({ + uiSelectedModel, userModel: override?.model, fallbackChain: requirement?.fallbackChain, availableModels, @@ -241,6 +243,7 @@ export async function createBuiltinAgents( const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"] const sisyphusResolution = resolveModelWithFallback({ + uiSelectedModel, userModel: sisyphusOverride?.model, fallbackChain: sisyphusRequirement?.fallbackChain, availableModels, @@ -282,6 +285,7 @@ export async function createBuiltinAgents( const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"] const atlasResolution = resolveModelWithFallback({ + uiSelectedModel, userModel: orchestratorOverride?.model, fallbackChain: atlasRequirement?.fallbackChain, availableModels, diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index a01b3f89..9039c771 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -133,16 +133,20 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { ]; const browserProvider = pluginConfig.browser_automation_engine?.provider ?? "playwright"; + // config.model represents the currently active model in OpenCode (including UI selection) + // Pass it as uiSelectedModel so it takes highest priority in model resolution + const currentModel = config.model as string | undefined; const builtinAgents = await createBuiltinAgents( migratedDisabledAgents, pluginConfig.agents, ctx.directory, - config.model as string | undefined, + undefined, // systemDefaultModel - let fallback chain handle this pluginConfig.categories, pluginConfig.git_master, allDiscoveredSkills, ctx.client, - browserProvider + browserProvider, + currentModel // uiSelectedModel - takes highest priority ); // Claude Code agents: Do NOT apply permission migration @@ -225,7 +229,6 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { pluginConfig.agents?.["prometheus"] as | (Record & { category?: string; model?: string; variant?: string }) | undefined; - const defaultModel = config.model as string | undefined; const categoryConfig = prometheusOverride?.category ? resolveCategoryConfig( @@ -241,10 +244,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { : new Set(); const modelResolution = resolveModelWithFallback({ + uiSelectedModel: currentModel, userModel: prometheusOverride?.model ?? categoryConfig?.model, fallbackChain: prometheusRequirement?.fallbackChain, availableModels, - systemDefaultModel: defaultModel ?? "", + systemDefaultModel: undefined, // let fallback chain handle this }); const resolvedModel = modelResolution?.model; const resolvedVariant = modelResolution?.variant; diff --git a/src/shared/model-resolver.test.ts b/src/shared/model-resolver.test.ts index 464040b8..9e1e665f 100644 --- a/src/shared/model-resolver.test.ts +++ b/src/shared/model-resolver.test.ts @@ -113,7 +113,80 @@ describe("resolveModelWithFallback", () => { logSpy.mockRestore() }) - describe("Step 1: Override", () => { + describe("Step 1: UI Selection (highest priority)", () => { + test("returns uiSelectedModel with override source when provided", () => { + // #given + const input: ExtendedModelResolutionInput = { + uiSelectedModel: "opencode/glm-4.7-free", + userModel: "anthropic/claude-opus-4-5", + fallbackChain: [ + { providers: ["anthropic", "github-copilot"], model: "claude-opus-4-5" }, + ], + availableModels: new Set(["anthropic/claude-opus-4-5", "github-copilot/claude-opus-4-5-preview"]), + systemDefaultModel: "google/gemini-3-pro", + } + + // #when + const result = resolveModelWithFallback(input) + + // #then + expect(result!.model).toBe("opencode/glm-4.7-free") + expect(result!.source).toBe("override") + expect(logSpy).toHaveBeenCalledWith("Model resolved via UI selection", { model: "opencode/glm-4.7-free" }) + }) + + test("UI selection takes priority over config override", () => { + // #given + const input: ExtendedModelResolutionInput = { + uiSelectedModel: "opencode/glm-4.7-free", + userModel: "anthropic/claude-opus-4-5", + availableModels: new Set(["anthropic/claude-opus-4-5"]), + systemDefaultModel: "google/gemini-3-pro", + } + + // #when + const result = resolveModelWithFallback(input) + + // #then + expect(result!.model).toBe("opencode/glm-4.7-free") + expect(result!.source).toBe("override") + }) + + test("whitespace-only uiSelectedModel is treated as not provided", () => { + // #given + const input: ExtendedModelResolutionInput = { + uiSelectedModel: " ", + userModel: "anthropic/claude-opus-4-5", + availableModels: new Set(["anthropic/claude-opus-4-5"]), + systemDefaultModel: "google/gemini-3-pro", + } + + // #when + const result = resolveModelWithFallback(input) + + // #then + expect(result!.model).toBe("anthropic/claude-opus-4-5") + expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-5" }) + }) + + test("empty string uiSelectedModel falls through to config override", () => { + // #given + const input: ExtendedModelResolutionInput = { + uiSelectedModel: "", + userModel: "anthropic/claude-opus-4-5", + availableModels: new Set(["anthropic/claude-opus-4-5"]), + systemDefaultModel: "google/gemini-3-pro", + } + + // #when + const result = resolveModelWithFallback(input) + + // #then + expect(result!.model).toBe("anthropic/claude-opus-4-5") + }) + }) + + describe("Step 2: Config Override", () => { test("returns userModel with override source when userModel is provided", () => { // #given const input: ExtendedModelResolutionInput = { @@ -131,7 +204,7 @@ describe("resolveModelWithFallback", () => { // #then expect(result!.model).toBe("anthropic/claude-opus-4-5") expect(result!.source).toBe("override") - expect(logSpy).toHaveBeenCalledWith("Model resolved via override", { model: "anthropic/claude-opus-4-5" }) + expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-5" }) }) test("override takes priority even if model not in availableModels", () => { @@ -190,7 +263,7 @@ describe("resolveModelWithFallback", () => { }) }) - describe("Step 2: Provider fallback chain", () => { + describe("Step 3: Provider fallback chain", () => { test("tries providers in order within entry and returns first match", () => { // #given const input: ExtendedModelResolutionInput = { @@ -317,7 +390,7 @@ describe("resolveModelWithFallback", () => { }) }) - describe("Step 3: System default fallback (no availability match)", () => { + describe("Step 4: System default fallback (no availability match)", () => { test("returns system default when no availability match found in fallback chain", () => { // #given const input: ExtendedModelResolutionInput = { diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts index ba49dff6..9026a9c4 100644 --- a/src/shared/model-resolver.ts +++ b/src/shared/model-resolver.ts @@ -21,6 +21,7 @@ export type ModelResolutionResult = { } export type ExtendedModelResolutionInput = { + uiSelectedModel?: string userModel?: string fallbackChain?: FallbackEntry[] availableModels: Set @@ -43,16 +44,23 @@ export function resolveModel(input: ModelResolutionInput): string | undefined { export function resolveModelWithFallback( input: ExtendedModelResolutionInput, ): ModelResolutionResult | undefined { - const { userModel, fallbackChain, availableModels, systemDefaultModel } = input + const { uiSelectedModel, userModel, fallbackChain, availableModels, systemDefaultModel } = input - // Step 1: Override + // Step 1: UI Selection (highest priority - respects user's model choice in OpenCode UI) + const normalizedUiModel = normalizeModel(uiSelectedModel) + if (normalizedUiModel) { + log("Model resolved via UI selection", { model: normalizedUiModel }) + return { model: normalizedUiModel, source: "override" } + } + + // Step 2: Config Override (from oh-my-opencode.json) const normalizedUserModel = normalizeModel(userModel) if (normalizedUserModel) { - log("Model resolved via override", { model: normalizedUserModel }) + log("Model resolved via config override", { model: normalizedUserModel }) return { model: normalizedUserModel, source: "override" } } - // Step 2: Provider fallback chain (with availability check) + // Step 3: Provider fallback chain (with availability check) if (fallbackChain && fallbackChain.length > 0) { if (availableModels.size === 0) { const connectedProviders = readConnectedProvidersCache() @@ -91,7 +99,7 @@ export function resolveModelWithFallback( log("No available model found in fallback chain, falling through to system default") } - // Step 3: System default (if provided) + // Step 4: System default (if provided) if (systemDefaultModel === undefined) { log("No model resolved - systemDefaultModel not configured") return undefined