feat: make systemDefaultModel optional for OpenCode fallback (#1136)

- Remove mandatory model requirement from plugin initialization
- Allow OpenCode to use its built-in model fallback when user doesn't specify
- Update model-resolver to handle undefined systemDefaultModel
- Remove throw errors in config-handler, utils, atlas, delegate-task
- Add tests for optional model scenarios

Closes #1129

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
justsisyphus 2026-01-26 17:01:08 +09:00 committed by GitHub
parent c9b86b7815
commit 3ee519c7b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 246 additions and 190 deletions

View File

@ -27,13 +27,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.0.1",
"oh-my-opencode-darwin-x64": "3.0.1",
"oh-my-opencode-linux-arm64": "3.0.1",
"oh-my-opencode-linux-arm64-musl": "3.0.1",
"oh-my-opencode-linux-x64": "3.0.1",
"oh-my-opencode-linux-x64-musl": "3.0.1",
"oh-my-opencode-windows-x64": "3.0.1",
"oh-my-opencode-darwin-arm64": "3.1.0",
"oh-my-opencode-darwin-x64": "3.1.0",
"oh-my-opencode-linux-arm64": "3.1.0",
"oh-my-opencode-linux-arm64-musl": "3.1.0",
"oh-my-opencode-linux-x64": "3.1.0",
"oh-my-opencode-linux-x64-musl": "3.1.0",
"oh-my-opencode-windows-x64": "3.1.0",
},
},
},
@ -225,19 +225,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.0.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-LRcLVi6DsmGh3ICFeN4yVJ0KinvCM5jotd2z7tZQ74n0sziHO7grjK1CmJaPV9eCv0clatoK5xfFCeEJ3FvXYg=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8j7XI+n1bz7xIg35Zpjqp1AqoIoFWuVZdYyI9vTAZ0b6ta/mIlNOWPLAbFyEHfKelA9g3Xa+4sYnKPSxU5dQoA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.0.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ZaC0ZBe5M2f2aMncNsAMu9IZ3MjSPfNVcfUTCgJkp03db8lLPsajgjeG3556Er72hxignDPsEbrLkJBNlsDbAA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Kd/3KpnF07cw+qBAyLwA0y8tp3S0X8b8HWH55WGlVp6m4gvQ432kKgDum/jat1vqP/3J8hm4P/sly5ibY5gMqw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.0.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-pcOvV6Y2GSwKr0exDndeB2BtFt297XhJFQgrq1cbeEJawoRONDRp7LNSpjwILSQpQ7YkkYnO2bIczBmxI5llNA=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-qy/QohHGM6eSQjHVEgibsDauUvlAgYPw5xrQqa9cVLo1hL4KMIhb+i4wGAxCK2p84rG2bfC2m8+IfZUxhhwcTg=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.0.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7kXKaVbgFnOMSaw+j4JbZNs7O7mkvCekcfWPwh/9I/0WD21/n4PbAGl01ePhRoQh+u9MC6t8FH046hEjL2sk1g=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-HIO7zj3M5QAYOfgvFM7Djeuen9kdZD4RA51wzXcXiPj1FPAuBNAW9N7lTEGYBSgObgwX+vXnC3HwLSF7nqkw8w=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.0.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-1BOV1EnKa5BErhZmWiddnbriHwm1KFrPr+0BUCDdFX/d/hrMAJTo1733zaEnvKuXzvrdHSp/VznXheeUI1VjkA=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-zcKaibnEhvbReiTsqbg+dog/Z3pnBx4v6R3AR5nVhGBO27hRSAXgA/fviYyE5bWD591WB7Pqwduf0t854ilKjw=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.0.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ASyTVatvU1nNJ0mk9o+A/GjybT5vOdgU172ystzCsnQ+12Mnv68GgaeMu/UFJgJNaZmKdhyUAP9XhnOKvEDBGQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-xmtHEyAhY93Djg5qEauvMqSF0x3tf8pzOGdKB6CuZmhCG69fZXk/dEwPrO0vKbOeGMV/T4K6HAg1+8Ue1N1ZaQ=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.0.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-QIuA564mVpwzCprhhAoyd8TSw0Rt2VM6M9y7H0fOoC/UjXuU+d7wIuUNuqUUMVaUnMedkctTZop0X0i2Q+Bvhg=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-pDgHd0mGWWVsiO0fT8C7bi6CziOXU38g+k2dWlGm1YXCMzyrrWZZCF7oIp+EzJB02saSCF/oJ2f1/uj/VPeLMA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@ -523,9 +523,6 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
}
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
if (!ctx.model) {
throw new Error("createAtlasAgent requires a model in context")
}
const restrictions = createAgentToolRestrictions([
"task",
"call_omo_agent",
@ -534,7 +531,7 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
description:
"Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done",
mode: "primary" as const,
model: ctx.model,
...(ctx.model ? { model: ctx.model } : {}),
temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx),
thinking: { type: "enabled", budgetTokens: 32000 },

View File

@ -106,6 +106,30 @@ describe("createBuiltinAgents with model overrides", () => {
})
})
describe("createBuiltinAgents without systemDefaultModel", () => {
test("creates agents successfully without systemDefaultModel", async () => {
// #given - no systemDefaultModel provided
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - agents should still be created using fallback chain
expect(agents.oracle).toBeDefined()
expect(agents.oracle.model).toBe("openai/gpt-5.2")
})
test("sisyphus uses fallback chain when systemDefaultModel undefined", async () => {
// #given - no systemDefaultModel
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - sisyphus should use its fallback chain
expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
})
})
describe("buildAgent with category and skills", () => {
const { buildAgent } = require("./utils")
const TEST_MODEL = "anthropic/claude-opus-4-5"

View File

@ -151,10 +151,6 @@ export async function createBuiltinAgents(
client?: any,
browserProvider?: BrowserAutomationProvider
): Promise<Record<string, AgentConfig>> {
if (!systemDefaultModel) {
throw new Error("createBuiltinAgents requires systemDefaultModel")
}
const connectedProviders = readConnectedProvidersCache()
const availableModels = client
? await fetchAvailableModels(client, { connectedProviders: connectedProviders ?? undefined })
@ -201,13 +197,14 @@ export async function createBuiltinAgents(
const override = findCaseInsensitive(agentOverrides, agentName)
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Use resolver to determine model
const { model, variant: resolvedVariant } = resolveModelWithFallback({
const resolution = resolveModelWithFallback({
userModel: override?.model,
fallbackChain: requirement?.fallbackChain,
availableModels,
systemDefaultModel,
})
if (!resolution) continue
const { model, variant: resolvedVariant } = resolution
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider)
@ -243,72 +240,76 @@ export async function createBuiltinAgents(
const sisyphusOverride = agentOverrides["sisyphus"]
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
// Use resolver to determine model
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = resolveModelWithFallback({
const sisyphusResolution = resolveModelWithFallback({
userModel: sisyphusOverride?.model,
fallbackChain: sisyphusRequirement?.fallbackChain,
availableModels,
systemDefaultModel,
})
let sisyphusConfig = createSisyphusAgent(
sisyphusModel,
availableAgents,
undefined,
availableSkills,
availableCategories
)
// Apply variant from override or resolved fallback chain
if (sisyphusOverride?.variant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
} else if (sisyphusResolvedVariant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
if (sisyphusResolution) {
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution
if (directory && sisyphusConfig.prompt) {
const envContext = createEnvContext()
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
}
let sisyphusConfig = createSisyphusAgent(
sisyphusModel,
availableAgents,
undefined,
availableSkills,
availableCategories
)
if (sisyphusOverride?.variant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
} else if (sisyphusResolvedVariant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
if (sisyphusOverride) {
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
}
if (directory && sisyphusConfig.prompt) {
const envContext = createEnvContext()
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
}
result["sisyphus"] = sisyphusConfig
if (sisyphusOverride) {
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
}
result["sisyphus"] = sisyphusConfig
}
}
if (!disabledAgents.includes("atlas")) {
const orchestratorOverride = agentOverrides["atlas"]
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
// Use resolver to determine model
const { model: atlasModel, variant: atlasResolvedVariant } = resolveModelWithFallback({
const atlasResolution = resolveModelWithFallback({
userModel: orchestratorOverride?.model,
fallbackChain: atlasRequirement?.fallbackChain,
availableModels,
systemDefaultModel,
})
let orchestratorConfig = createAtlasAgent({
model: atlasModel,
availableAgents,
availableSkills,
userCategories: categories,
})
// Apply variant from override or resolved fallback chain
if (orchestratorOverride?.variant) {
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
} else if (atlasResolvedVariant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
if (atlasResolution) {
const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution
if (orchestratorOverride) {
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
}
let orchestratorConfig = createAtlasAgent({
model: atlasModel,
availableAgents,
availableSkills,
userCategories: categories,
})
if (orchestratorOverride?.variant) {
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
} else if (atlasResolvedVariant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
result["atlas"] = orchestratorConfig
if (orchestratorOverride) {
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
}
result["atlas"] = orchestratorConfig
}
}
return result

View File

@ -105,41 +105,6 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
log(`Plugin load errors`, { errors: pluginComponents.errors });
}
if (!(config.model as string | undefined)?.trim()) {
let fallbackModel: string | undefined
for (const agentConfig of Object.values(pluginConfig.agents ?? {})) {
const model = (agentConfig as { model?: string })?.model
if (model && typeof model === 'string' && model.trim()) {
fallbackModel = model.trim()
break
}
}
if (!fallbackModel) {
for (const categoryConfig of Object.values(pluginConfig.categories ?? {})) {
const model = (categoryConfig as { model?: string })?.model
if (model && typeof model === 'string' && model.trim()) {
fallbackModel = model.trim()
break
}
}
}
if (fallbackModel) {
config.model = fallbackModel
log(`No default model specified, using fallback from config: ${fallbackModel}`)
} else {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
throw new Error(
'oh-my-opencode requires a default model.\n\n' +
`Add this to ${paths.configJsonc}:\n\n` +
' "model": "anthropic/claude-sonnet-4-5"\n\n' +
'(Replace with your preferred provider/model)'
)
}
}
// Migrate disabled_agents from old names to new names
const migratedDisabledAgents = (pluginConfig.disabled_agents ?? []).map(agent => {
return AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent

View File

@ -128,8 +128,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("override")
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" })
})
@ -148,8 +148,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("custom/my-model")
expect(result.source).toBe("override")
expect(result!.model).toBe("custom/my-model")
expect(result!.source).toBe("override")
})
test("whitespace-only userModel is treated as not provided", () => {
@ -167,7 +167,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.source).not.toBe("override")
expect(result!.source).not.toBe("override")
})
test("empty string userModel is treated as not provided", () => {
@ -185,7 +185,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.source).not.toBe("override")
expect(result!.source).not.toBe("override")
})
})
@ -204,8 +204,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("github-copilot/claude-opus-4-5-preview")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("github-copilot/claude-opus-4-5-preview")
expect(result!.source).toBe("provider-fallback")
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
provider: "github-copilot",
model: "claude-opus-4-5",
@ -228,8 +228,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("openai/gpt-5.2")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("openai/gpt-5.2")
expect(result!.source).toBe("provider-fallback")
})
test("tries next provider when first provider has no match", () => {
@ -246,8 +246,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("opencode/gpt-5-nano")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("opencode/gpt-5-nano")
expect(result!.source).toBe("provider-fallback")
})
test("uses fuzzy matching within provider", () => {
@ -264,8 +264,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
test("skips fallback chain when not provided", () => {
@ -279,7 +279,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.source).toBe("system-default")
expect(result!.source).toBe("system-default")
})
test("skips fallback chain when empty", () => {
@ -294,7 +294,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.source).toBe("system-default")
expect(result!.source).toBe("system-default")
})
test("case-insensitive fuzzy matching", () => {
@ -311,8 +311,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
})
@ -331,8 +331,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("system-default")
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.source).toBe("system-default")
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
})
@ -350,8 +350,8 @@ describe("resolveModelWithFallback", () => {
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")
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
test("returns system default when fallbackChain is not provided", () => {
@ -365,8 +365,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("system-default")
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.source).toBe("system-default")
})
})
@ -386,8 +386,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
test("tries all providers in first entry before moving to second entry", () => {
@ -405,8 +405,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("google/gemini-3-pro")
expect(result!.source).toBe("provider-fallback")
})
test("returns first matching entry even if later entries have better matches", () => {
@ -427,8 +427,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("openai/gpt-5.2")
expect(result.source).toBe("provider-fallback")
expect(result!.model).toBe("openai/gpt-5.2")
expect(result!.source).toBe("provider-fallback")
})
test("falls through to system default when none match availability", () => {
@ -447,8 +447,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("system/default")
expect(result.source).toBe("system-default")
expect(result!.model).toBe("system/default")
expect(result!.source).toBe("system-default")
})
})
@ -462,11 +462,81 @@ describe("resolveModelWithFallback", () => {
}
// #when
const result: ModelResolutionResult = resolveModelWithFallback(input)
const result = resolveModelWithFallback(input)
// #then
expect(typeof result.model).toBe("string")
expect(["override", "provider-fallback", "system-default"]).toContain(result.source)
expect(result).toBeDefined()
expect(typeof result!.model).toBe("string")
expect(["override", "provider-fallback", "system-default"]).toContain(result!.source)
})
})
describe("Optional systemDefaultModel", () => {
test("returns undefined when systemDefaultModel is undefined and no fallback found", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "nonexistent-model" },
],
availableModels: new Set(["openai/gpt-5.2"]),
systemDefaultModel: undefined,
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result).toBeUndefined()
})
test("returns undefined when no fallbackChain and systemDefaultModel is undefined", () => {
// #given
const input: ExtendedModelResolutionInput = {
availableModels: new Set(["openai/gpt-5.2"]),
systemDefaultModel: undefined,
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result).toBeUndefined()
})
test("still returns override when userModel provided even if systemDefaultModel undefined", () => {
// #given
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-5",
availableModels: new Set(),
systemDefaultModel: undefined,
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result).toBeDefined()
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("override")
})
test("still returns fallback match when systemDefaultModel undefined", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-5" },
],
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: undefined,
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result).toBeDefined()
expect(result!.model).toBe("anthropic/claude-opus-4-5")
expect(result!.source).toBe("provider-fallback")
})
})
})

View File

@ -6,7 +6,7 @@ import { readConnectedProvidersCache } from "./connected-providers-cache"
export type ModelResolutionInput = {
userModel?: string
inheritedModel?: string
systemDefault: string
systemDefault?: string
}
export type ModelSource =
@ -24,7 +24,7 @@ export type ExtendedModelResolutionInput = {
userModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
systemDefaultModel: string
systemDefaultModel?: string
}
function normalizeModel(model?: string): string | undefined {
@ -32,7 +32,7 @@ function normalizeModel(model?: string): string | undefined {
return trimmed || undefined
}
export function resolveModel(input: ModelResolutionInput): string {
export function resolveModel(input: ModelResolutionInput): string | undefined {
return (
normalizeModel(input.userModel) ??
normalizeModel(input.inheritedModel) ??
@ -42,7 +42,7 @@ export function resolveModel(input: ModelResolutionInput): string {
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
): ModelResolutionResult {
): ModelResolutionResult | undefined {
const { userModel, fallbackChain, availableModels, systemDefaultModel } = input
// Step 1: Override
@ -92,7 +92,12 @@ export function resolveModelWithFallback(
log("No available model found in fallback chain, falling through to system default")
}
// Step 4: System default
// Step 3: System default (if provided)
if (systemDefaultModel === undefined) {
log("No model resolved - systemDefaultModel not configured")
return undefined
}
log("Model resolved via system default", { model: systemDefaultModel })
return { model: systemDefaultModel, source: "system-default" }
}

View File

@ -78,11 +78,11 @@ describe("sisyphus-task", () => {
})
describe("category delegation config validation", () => {
test("returns error when systemDefaultModel is not configured", async () => {
test("proceeds without error when systemDefaultModel is undefined", async () => {
// #given a mock client with no model in config
const { createDelegateTask } = require("./tools")
const mockManager = { launch: async () => ({}) }
const mockManager = { launch: async () => ({ id: "task-123" }) }
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({}) }, // No model configured
@ -111,14 +111,14 @@ describe("sisyphus-task", () => {
description: "Test task",
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
load_skills: ["git-master"],
run_in_background: true,
load_skills: [],
},
toolContext
)
// #then returns descriptive error message
expect(result).toContain("oh-my-opencode requires a default model")
// #then proceeds without error - uses fallback chain
expect(result).not.toContain("oh-my-opencode requires a default model")
})
})

View File

@ -115,9 +115,9 @@ export function resolveCategoryConfig(
options: {
userCategories?: CategoriesConfig
inheritedModel?: string
systemDefaultModel: string
systemDefaultModel?: string
}
): { config: CategoryConfig; promptAppend: string; model: string } | null {
): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null {
const { userCategories, inheritedModel, systemDefaultModel } = options
const defaultConfig = DEFAULT_CATEGORIES[categoryName]
const userConfig = userCategories?.[categoryName]
@ -497,17 +497,6 @@ To continue this session: session_id="${args.session_id}"`
let modelInfo: ModelFallbackInfo | undefined
if (args.category) {
// Guard: require system default model for category delegation
if (!systemDefaultModel) {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
return (
'oh-my-opencode requires a default model.\n\n' +
`Add this to ${paths.configJsonc}:\n\n` +
' "model": "anthropic/claude-sonnet-4-5"\n\n' +
'(Replace with your preferred provider/model)'
)
}
const connectedProviders = readConnectedProvidersCache()
const availableModels = await fetchAvailableModels(client, {
connectedProviders: connectedProviders ?? undefined
@ -523,55 +512,60 @@ To continue this session: session_id="${args.session_id}"`
}
const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category]
let actualModel: string
let actualModel: string | undefined
if (!requirement) {
actualModel = resolved.model
modelInfo = { model: actualModel, type: "system-default", source: "system-default" }
if (actualModel) {
modelInfo = { model: actualModel, type: "system-default", source: "system-default" }
}
} else {
const { model: resolvedModel, source, variant: resolvedVariant } = resolveModelWithFallback({
const resolution = resolveModelWithFallback({
userModel: userCategories?.[args.category]?.model ?? sisyphusJuniorModel,
fallbackChain: requirement.fallbackChain,
availableModels,
systemDefaultModel,
})
actualModel = resolvedModel
if (resolution) {
const { model: resolvedModel, source, variant: resolvedVariant } = resolution
actualModel = resolvedModel
if (!parseModelString(actualModel)) {
return `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
if (!parseModelString(actualModel)) {
return `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
}
let type: "user-defined" | "inherited" | "category-default" | "system-default"
switch (source) {
case "override":
type = "user-defined"
break
case "provider-fallback":
type = "category-default"
break
case "system-default":
type = "system-default"
break
}
modelInfo = { model: actualModel, type, source }
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category]?.variant ?? resolvedVariant
categoryModel = parsedModel
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
: undefined
}
let type: "user-defined" | "inherited" | "category-default" | "system-default"
switch (source) {
case "override":
type = "user-defined"
break
case "provider-fallback":
type = "category-default"
break
case "system-default":
type = "system-default"
break
}
modelInfo = { model: actualModel, type, source }
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category]?.variant ?? resolvedVariant
categoryModel = parsedModel
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
: undefined
}
agentToUse = SISYPHUS_JUNIOR_AGENT
if (!categoryModel) {
if (!categoryModel && actualModel) {
const parsedModel = parseModelString(actualModel)
categoryModel = parsedModel ?? undefined
}
categoryPromptAppend = resolved.promptAppend || undefined
const isUnstableAgent = resolved.config.is_unstable_agent === true || actualModel.toLowerCase().includes("gemini")
const isUnstableAgent = resolved.config.is_unstable_agent === true || (actualModel?.toLowerCase().includes("gemini") ?? false)
// Handle both boolean false and string "false" due to potential serialization
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean