diff --git a/bun.lock b/bun.lock index e0aeb1db..f127365a 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/src/agents/atlas.ts b/src/agents/atlas.ts index a08d355e..6d29a1bf 100644 --- a/src/agents/atlas.ts +++ b/src/agents/atlas.ts @@ -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 }, diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index a5d3ec7a..66a37a18 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -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" diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 4bcc62ba..a2f14608 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -151,10 +151,6 @@ export async function createBuiltinAgents( client?: any, browserProvider?: BrowserAutomationProvider ): Promise> { - 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 diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 54200745..849a97d2 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -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 diff --git a/src/shared/model-resolver.test.ts b/src/shared/model-resolver.test.ts index eb4744e9..d4b0dde0 100644 --- a/src/shared/model-resolver.test.ts +++ b/src/shared/model-resolver.test.ts @@ -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") }) }) }) diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts index 11a59788..2fb8a07e 100644 --- a/src/shared/model-resolver.ts +++ b/src/shared/model-resolver.ts @@ -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 - 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" } } diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 081856e6..0c455fe9 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -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") }) }) diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index cc683896..f2007d20 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -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