From 81301a6071e937a91bb0c8063b96c2204ba47969 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 12 Mar 2026 17:27:21 +0900 Subject: [PATCH] feat: skip model resolution for delegated tasks when provider cache not yet created Before provider cache exists (first run), resolveModelForDelegateTask now returns undefined instead of guessing a model. This lets OpenCode use its system default model when no model is specified in the prompt body. User-specified model overrides still take priority regardless of cache state. --- .../delegate-task/model-selection.test.ts | 130 ++++++++++++++++++ src/tools/delegate-task/model-selection.ts | 7 + 2 files changed, 137 insertions(+) create mode 100644 src/tools/delegate-task/model-selection.test.ts diff --git a/src/tools/delegate-task/model-selection.test.ts b/src/tools/delegate-task/model-selection.test.ts new file mode 100644 index 000000000..857efa7ad --- /dev/null +++ b/src/tools/delegate-task/model-selection.test.ts @@ -0,0 +1,130 @@ +declare const require: (name: string) => any +const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test") +import { resolveModelForDelegateTask } from "./model-selection" +import * as connectedProvidersCache from "../../shared/connected-providers-cache" + +describe("resolveModelForDelegateTask", () => { + let hasConnectedProvidersSpy: ReturnType | undefined + let hasProviderModelsSpy: ReturnType | undefined + + beforeEach(() => { + mock.restore() + }) + + afterEach(() => { + hasConnectedProvidersSpy?.mockRestore() + hasProviderModelsSpy?.mockRestore() + }) + + describe("#given no provider cache exists (pre-cache scenario)", () => { + beforeEach(() => { + hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(false) + hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(false) + }) + + describe("#when availableModels is empty and no user model override", () => { + test("#then returns undefined to let OpenCode use system default", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: "anthropic/claude-sonnet-4-6", + fallbackChain: [ + { providers: ["anthropic"], model: "claude-sonnet-4-6" }, + ], + availableModels: new Set(), + systemDefaultModel: "anthropic/claude-sonnet-4-6", + }) + + expect(result).toBeUndefined() + }) + }) + + describe("#when user explicitly set a model override", () => { + test("#then returns the user model regardless of cache state", () => { + const result = resolveModelForDelegateTask({ + userModel: "openai/gpt-5.4", + categoryDefaultModel: "anthropic/claude-sonnet-4-6", + fallbackChain: [ + { providers: ["anthropic"], model: "claude-sonnet-4-6" }, + ], + availableModels: new Set(), + systemDefaultModel: "anthropic/claude-sonnet-4-6", + }) + + expect(result).toEqual({ model: "openai/gpt-5.4" }) + }) + }) + + describe("#when user set fallback_models but no cache exists", () => { + test("#then returns undefined (skip fallback resolution without cache)", () => { + const result = resolveModelForDelegateTask({ + userFallbackModels: ["openai/gpt-5.4", "google/gemini-3.1-pro"], + categoryDefaultModel: "anthropic/claude-sonnet-4-6", + fallbackChain: [ + { providers: ["anthropic"], model: "claude-sonnet-4-6" }, + ], + availableModels: new Set(), + }) + + expect(result).toBeUndefined() + }) + }) + }) + + describe("#given provider cache exists", () => { + beforeEach(() => { + hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true) + hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(true) + }) + + describe("#when availableModels is empty (cache exists but empty)", () => { + test("#then falls through to category default model (existing behavior)", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: "anthropic/claude-sonnet-4-6", + fallbackChain: [ + { providers: ["anthropic"], model: "claude-sonnet-4-6" }, + ], + availableModels: new Set(), + systemDefaultModel: "anthropic/claude-sonnet-4-6", + }) + + expect(result).toBeDefined() + expect(result!.model).toBe("anthropic/claude-sonnet-4-6") + }) + }) + + describe("#when availableModels has entries and category default matches", () => { + test("#then resolves via fuzzy match (existing behavior)", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: "anthropic/claude-sonnet-4-6", + fallbackChain: [ + { providers: ["anthropic"], model: "claude-sonnet-4-6" }, + ], + availableModels: new Set(["anthropic/claude-sonnet-4-6"]), + }) + + expect(result).toBeDefined() + expect(result!.model).toBe("anthropic/claude-sonnet-4-6") + }) + }) + }) + + describe("#given only connected providers cache exists (no provider-models cache)", () => { + beforeEach(() => { + hasConnectedProvidersSpy = spyOn(connectedProvidersCache, "hasConnectedProvidersCache").mockReturnValue(true) + hasProviderModelsSpy = spyOn(connectedProvidersCache, "hasProviderModelsCache").mockReturnValue(false) + }) + + describe("#when availableModels is empty", () => { + test("#then falls through to existing resolution (cache partially ready)", () => { + const result = resolveModelForDelegateTask({ + categoryDefaultModel: "anthropic/claude-sonnet-4-6", + fallbackChain: [ + { providers: ["anthropic"], model: "claude-sonnet-4-6" }, + ], + availableModels: new Set(), + }) + + expect(result).toBeDefined() + }) + }) + }) +}) diff --git a/src/tools/delegate-task/model-selection.ts b/src/tools/delegate-task/model-selection.ts index 0d085d868..92a33db56 100644 --- a/src/tools/delegate-task/model-selection.ts +++ b/src/tools/delegate-task/model-selection.ts @@ -2,6 +2,7 @@ import type { FallbackEntry } from "../../shared/model-requirements" import { normalizeModel } from "../../shared/model-normalization" import { fuzzyMatchModel } from "../../shared/model-availability" import { transformModelForProvider } from "../../shared/provider-model-id-transform" +import { hasConnectedProvidersCache, hasProviderModelsCache } from "../../shared/connected-providers-cache" function isExplicitHighModel(model: string): boolean { return /(?:^|\/)[^/]+-high$/.test(model) @@ -25,6 +26,12 @@ export function resolveModelForDelegateTask(input: { return { model: userModel } } + // Before provider cache is created (first run), skip model resolution entirely. + // OpenCode will use its system default model when no model is specified in the prompt. + if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) { + return undefined + } + const categoryDefault = normalizeModel(input.categoryDefaultModel) const explicitHighBaseModel = categoryDefault ? getExplicitHighBaseModel(categoryDefault) : null const explicitHighModel = explicitHighBaseModel ? categoryDefault : undefined