From c698a5b88853623215946f252ca0325a12dc90b3 Mon Sep 17 00:00:00 2001 From: Kenny Date: Sat, 17 Jan 2026 12:51:03 -0500 Subject: [PATCH] fix: remove hardcoded model defaults from categories and agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Model resolution overhauled - Created centralized model-resolver.ts with priority chain: userModel → inheritedModel → systemDefaultModel - Removed model field from all 7 DEFAULT_CATEGORIES entries - Removed DEFAULT_MODEL constants from 10 agents - Removed singleton agent exports (use factories instead) - Made CategoryConfigSchema.model optional - CLI no longer generates model overrides - Empty strings treated as unset (uses fallback) Users must now: 1. Use factory functions (createOracleAgent, etc.) instead of singletons 2. Provide model explicitly or use systemDefaultModel 3. Configure category models explicitly if needed Fixes model fallback bug where hardcoded defaults overrode user's OpenCode configured model. --- .gitignore | 4 + assets/oh-my-opencode.schema.json | 5 +- src/agents/document-writer.ts | 7 +- src/agents/explore.ts | 5 +- src/agents/frontend-ui-ux-engineer.ts | 7 +- src/agents/index.ts | 35 +-- src/agents/librarian.ts | 5 +- src/agents/metis.ts | 5 +- src/agents/momus.ts | 5 +- src/agents/multimodal-looker.ts | 7 +- src/agents/oracle.ts | 5 +- src/agents/orchestrator-sisyphus.ts | 11 +- src/agents/sisyphus-junior.ts | 2 +- src/agents/sisyphus.ts | 5 +- src/agents/types.ts | 2 +- src/agents/utils.test.ts | 66 +++-- src/agents/utils.ts | 14 +- src/cli/config-manager.test.ts | 122 ++------- src/cli/config-manager.ts | 125 +-------- src/cli/install.ts | 15 +- src/config/schema.ts | 2 +- src/plugin-handlers/config-handler.test.ts | 12 +- src/plugin-handlers/config-handler.ts | 11 +- src/shared/index.ts | 1 + src/shared/migration.test.ts | 2 +- src/shared/migration.ts | 14 +- src/shared/model-resolver.test.ts | 101 ++++++++ src/shared/model-resolver.ts | 35 +++ src/tools/delegate-task/constants.ts | 7 - src/tools/delegate-task/tools.test.ts | 279 ++++++++++++--------- src/tools/delegate-task/tools.ts | 39 +-- 31 files changed, 459 insertions(+), 496 deletions(-) create mode 100644 src/shared/model-resolver.test.ts create mode 100644 src/shared/model-resolver.ts diff --git a/.gitignore b/.gitignore index e913cc4b..614bb35f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ node_modules/ # Build output dist/ +# Build artifacts in src (should go to dist/) +src/**/*.js +src/**/*.js.map + # Platform binaries (built, not committed) packages/*/bin/oh-my-opencode packages/*/bin/oh-my-opencode.exe diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 52559dd4..0424185b 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -2060,10 +2060,7 @@ "prompt_append": { "type": "string" } - }, - "required": [ - "model" - ] + } } }, "claude_code": { diff --git a/src/agents/document-writer.ts b/src/agents/document-writer.ts index a00cdf3c..82b4580a 100644 --- a/src/agents/document-writer.ts +++ b/src/agents/document-writer.ts @@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentPromptMetadata } from "./types" import { createAgentToolRestrictions } from "../shared/permission-compat" -const DEFAULT_MODEL = "google/gemini-3-flash-preview" - export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = { category: "specialist", cost: "CHEAP", @@ -13,9 +11,7 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = { ], } -export function createDocumentWriterAgent( - model: string = DEFAULT_MODEL -): AgentConfig { +export function createDocumentWriterAgent(model: string): AgentConfig { const restrictions = createAgentToolRestrictions([]) return { @@ -221,4 +217,3 @@ You are a technical writer who creates documentation that developers actually wa } } -export const documentWriterAgent = createDocumentWriterAgent() diff --git a/src/agents/explore.ts b/src/agents/explore.ts index 62413252..7409636b 100644 --- a/src/agents/explore.ts +++ b/src/agents/explore.ts @@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentPromptMetadata } from "./types" import { createAgentToolRestrictions } from "../shared/permission-compat" -const DEFAULT_MODEL = "opencode/grok-code" - export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = { category: "exploration", cost: "FREE", @@ -24,7 +22,7 @@ export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = { ], } -export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig { +export function createExploreAgent(model: string): AgentConfig { const restrictions = createAgentToolRestrictions([ "write", "edit", @@ -122,4 +120,3 @@ Flood with parallel calls. Cross-validate findings across multiple tools.`, } } -export const exploreAgent = createExploreAgent() diff --git a/src/agents/frontend-ui-ux-engineer.ts b/src/agents/frontend-ui-ux-engineer.ts index 517e5617..999074ae 100644 --- a/src/agents/frontend-ui-ux-engineer.ts +++ b/src/agents/frontend-ui-ux-engineer.ts @@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentPromptMetadata } from "./types" import { createAgentToolRestrictions } from "../shared/permission-compat" -const DEFAULT_MODEL = "google/gemini-3-pro-preview" - export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = { category: "specialist", cost: "CHEAP", @@ -19,9 +17,7 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = { ], } -export function createFrontendUiUxEngineerAgent( - model: string = DEFAULT_MODEL -): AgentConfig { +export function createFrontendUiUxEngineerAgent(model: string): AgentConfig { const restrictions = createAgentToolRestrictions([]) return { @@ -106,4 +102,3 @@ Interpret creatively and make unexpected choices that feel genuinely designed fo } } -export const frontendUiUxEngineerAgent = createFrontendUiUxEngineerAgent() diff --git a/src/agents/index.ts b/src/agents/index.ts index 16803440..795c6558 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,28 +1,13 @@ -import type { AgentConfig } from "@opencode-ai/sdk" -import { sisyphusAgent } from "./sisyphus" -import { oracleAgent } from "./oracle" -import { librarianAgent } from "./librarian" -import { exploreAgent } from "./explore" -import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer" -import { documentWriterAgent } from "./document-writer" -import { multimodalLookerAgent } from "./multimodal-looker" -import { metisAgent } from "./metis" -import { orchestratorSisyphusAgent } from "./orchestrator-sisyphus" -import { momusAgent } from "./momus" - -export const builtinAgents: Record = { - Sisyphus: sisyphusAgent, - oracle: oracleAgent, - librarian: librarianAgent, - explore: exploreAgent, - "frontend-ui-ux-engineer": frontendUiUxEngineerAgent, - "document-writer": documentWriterAgent, - "multimodal-looker": multimodalLookerAgent, - "Metis (Plan Consultant)": metisAgent, - "Momus (Plan Reviewer)": momusAgent, - "orchestrator-sisyphus": orchestratorSisyphusAgent, -} - export * from "./types" export { createBuiltinAgents } from "./utils" export type { AvailableAgent } from "./sisyphus-prompt-builder" +export { createSisyphusAgent } from "./sisyphus" +export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" +export { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian" +export { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore" +export { createFrontendUiUxEngineerAgent, FRONTEND_PROMPT_METADATA } from "./frontend-ui-ux-engineer" +export { createDocumentWriterAgent, DOCUMENT_WRITER_PROMPT_METADATA } from "./document-writer" +export { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker" +export { createMetisAgent, METIS_SYSTEM_PROMPT, metisPromptMetadata } from "./metis" +export { createMomusAgent, MOMUS_SYSTEM_PROMPT, momusPromptMetadata } from "./momus" +export { createOrchestratorSisyphusAgent, orchestratorSisyphusPromptMetadata } from "./orchestrator-sisyphus" diff --git a/src/agents/librarian.ts b/src/agents/librarian.ts index ace46b7b..b6ed3344 100644 --- a/src/agents/librarian.ts +++ b/src/agents/librarian.ts @@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentPromptMetadata } from "./types" import { createAgentToolRestrictions } from "../shared/permission-compat" -const DEFAULT_MODEL = "opencode/glm-4.7-free" - export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = { category: "exploration", cost: "CHEAP", @@ -21,7 +19,7 @@ export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = { ], } -export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig { +export function createLibrarianAgent(model: string): AgentConfig { const restrictions = createAgentToolRestrictions([ "write", "edit", @@ -326,4 +324,3 @@ grep_app_searchGitHub(query: "useQuery") } } -export const librarianAgent = createLibrarianAgent() diff --git a/src/agents/metis.ts b/src/agents/metis.ts index 0edc6013..5e14e41f 100644 --- a/src/agents/metis.ts +++ b/src/agents/metis.ts @@ -278,9 +278,7 @@ const metisRestrictions = createAgentToolRestrictions([ "delegate_task", ]) -const DEFAULT_MODEL = "anthropic/claude-opus-4-5" - -export function createMetisAgent(model: string = DEFAULT_MODEL): AgentConfig { +export function createMetisAgent(model: string): AgentConfig { return { description: "Pre-planning consultant that analyzes requests to identify hidden intentions, ambiguities, and AI failure points.", @@ -293,7 +291,6 @@ export function createMetisAgent(model: string = DEFAULT_MODEL): AgentConfig { } as AgentConfig } -export const metisAgent: AgentConfig = createMetisAgent() export const metisPromptMetadata: AgentPromptMetadata = { category: "advisor", diff --git a/src/agents/momus.ts b/src/agents/momus.ts index de816b47..cfe29179 100644 --- a/src/agents/momus.ts +++ b/src/agents/momus.ts @@ -17,8 +17,6 @@ import { createAgentToolRestrictions } from "../shared/permission-compat" * implementation. */ -const DEFAULT_MODEL = "openai/gpt-5.2" - export const MOMUS_SYSTEM_PROMPT = `You are a work plan review expert. You review the provided work plan (.sisyphus/plans/{name}.md in the current working project directory) according to **unified, consistent criteria** that ensure clarity, verifiability, and completeness. **CRITICAL FIRST RULE**: @@ -391,7 +389,7 @@ Use structured format, **in the same language as the work plan**. **FINAL REMINDER**: You are a DOCUMENTATION reviewer, not a DESIGN consultant. The author's implementation direction is SACRED. Your job ends at "Is this well-documented enough to execute?" - NOT "Is this the right approach?" ` -export function createMomusAgent(model: string = DEFAULT_MODEL): AgentConfig { +export function createMomusAgent(model: string): AgentConfig { const restrictions = createAgentToolRestrictions([ "write", "edit", @@ -416,7 +414,6 @@ export function createMomusAgent(model: string = DEFAULT_MODEL): AgentConfig { return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig } -export const momusAgent = createMomusAgent() export const momusPromptMetadata: AgentPromptMetadata = { category: "advisor", diff --git a/src/agents/multimodal-looker.ts b/src/agents/multimodal-looker.ts index 010fda0c..e4f9ad40 100644 --- a/src/agents/multimodal-looker.ts +++ b/src/agents/multimodal-looker.ts @@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentPromptMetadata } from "./types" import { createAgentToolAllowlist } from "../shared/permission-compat" -const DEFAULT_MODEL = "google/gemini-3-flash" - export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = { category: "utility", cost: "CHEAP", @@ -11,9 +9,7 @@ export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = { triggers: [], } -export function createMultimodalLookerAgent( - model: string = DEFAULT_MODEL -): AgentConfig { +export function createMultimodalLookerAgent(model: string): AgentConfig { const restrictions = createAgentToolAllowlist(["read"]) return { @@ -58,4 +54,3 @@ Your output goes straight to the main agent for continued work.`, } } -export const multimodalLookerAgent = createMultimodalLookerAgent() diff --git a/src/agents/oracle.ts b/src/agents/oracle.ts index 131b4367..e58978ee 100644 --- a/src/agents/oracle.ts +++ b/src/agents/oracle.ts @@ -3,8 +3,6 @@ import type { AgentPromptMetadata } from "./types" import { isGptModel } from "./types" import { createAgentToolRestrictions } from "../shared/permission-compat" -const DEFAULT_MODEL = "openai/gpt-5.2" - export const ORACLE_PROMPT_METADATA: AgentPromptMetadata = { category: "advisor", cost: "EXPENSIVE", @@ -97,7 +95,7 @@ Organize your final answer in three tiers: Your response goes directly to the user with no intermediate processing. Make your final message self-contained: a clear recommendation they can act on immediately, covering both what to do and why.` -export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig { +export function createOracleAgent(model: string): AgentConfig { const restrictions = createAgentToolRestrictions([ "write", "edit", @@ -122,4 +120,3 @@ export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig { return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig } -export const oracleAgent = createOracleAgent() diff --git a/src/agents/orchestrator-sisyphus.ts b/src/agents/orchestrator-sisyphus.ts index bfce6e2a..62bfe1d2 100644 --- a/src/agents/orchestrator-sisyphus.ts +++ b/src/agents/orchestrator-sisyphus.ts @@ -1458,9 +1458,10 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string { .replace("{SKILLS_SECTION}", skillsSection) } -const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5" - -export function createOrchestratorSisyphusAgent(ctx?: OrchestratorContext): AgentConfig { +export function createOrchestratorSisyphusAgent(ctx: OrchestratorContext): AgentConfig { + if (!ctx.model) { + throw new Error("createOrchestratorSisyphusAgent requires a model in context") + } const restrictions = createAgentToolRestrictions([ "task", "call_omo_agent", @@ -1469,7 +1470,7 @@ export function createOrchestratorSisyphusAgent(ctx?: OrchestratorContext): Agen description: "Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done", mode: "primary" as const, - model: ctx?.model ?? DEFAULT_MODEL, + model: ctx.model, temperature: 0.1, prompt: buildDynamicOrchestratorPrompt(ctx), thinking: { type: "enabled", budgetTokens: 32000 }, @@ -1478,8 +1479,6 @@ export function createOrchestratorSisyphusAgent(ctx?: OrchestratorContext): Agen } as AgentConfig } -export const orchestratorSisyphusAgent: AgentConfig = createOrchestratorSisyphusAgent() - export const orchestratorSisyphusPromptMetadata: AgentPromptMetadata = { category: "advisor", cost: "EXPENSIVE", diff --git a/src/agents/sisyphus-junior.ts b/src/agents/sisyphus-junior.ts index 89cb7821..4401533b 100644 --- a/src/agents/sisyphus-junior.ts +++ b/src/agents/sisyphus-junior.ts @@ -138,7 +138,7 @@ export function createSisyphusJuniorAgent( promptAppend?: string ): AgentConfig { const prompt = buildSisyphusJuniorPrompt(promptAppend) - const model = categoryConfig.model + const model = categoryConfig.model ?? SISYPHUS_JUNIOR_DEFAULTS.model const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) const categoryPermission = categoryConfig.tools ? Object.fromEntries( diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index 62c39ec1..7b3b5a8e 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -14,8 +14,6 @@ import { categorizeTools, } from "./sisyphus-prompt-builder" -const DEFAULT_MODEL = "anthropic/claude-opus-4-5" - const SISYPHUS_ROLE_SECTION = ` You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode. @@ -607,7 +605,7 @@ function buildDynamicSisyphusPrompt( } export function createSisyphusAgent( - model: string = DEFAULT_MODEL, + model: string, availableAgents?: AvailableAgent[], availableToolNames?: string[], availableSkills?: AvailableSkill[] @@ -637,4 +635,3 @@ export function createSisyphusAgent( return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } } -export const sisyphusAgent = createSisyphusAgent() diff --git a/src/agents/types.ts b/src/agents/types.ts index a0f6d26d..d808703b 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -1,6 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk" -export type AgentFactory = (model?: string) => AgentConfig +export type AgentFactory = (model: string) => AgentConfig /** * Agent category for grouping in Sisyphus prompt sections diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 336ed628..85df26f6 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -2,12 +2,14 @@ import { describe, test, expect } from "bun:test" import { createBuiltinAgents } from "./utils" import type { AgentConfig } from "@opencode-ai/sdk" +const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5" + describe("createBuiltinAgents with model overrides", () => { test("Sisyphus with default model has thinking config", () => { - // #given - no overrides + // #given - no overrides, using systemDefaultModel // #when - const agents = createBuiltinAgents() + const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.Sisyphus.model).toBe("anthropic/claude-opus-4-5") @@ -22,7 +24,7 @@ describe("createBuiltinAgents with model overrides", () => { } // #when - const agents = createBuiltinAgents([], overrides) + const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2") @@ -44,10 +46,26 @@ describe("createBuiltinAgents with model overrides", () => { }) test("Oracle with default model has reasoningEffort", () => { - // #given - no overrides + // #given - no overrides, using systemDefaultModel for other agents + // Oracle uses its own default model (openai/gpt-5.2) from the factory singleton // #when - const agents = createBuiltinAgents() + const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) + + // #then - Oracle uses systemDefaultModel since model is now required + expect(agents.oracle.model).toBe("anthropic/claude-opus-4-5") + expect(agents.oracle.thinking).toEqual({ type: "enabled", budgetTokens: 32000 }) + expect(agents.oracle.reasoningEffort).toBeUndefined() + }) + + test("Oracle with GPT model override has reasoningEffort, no thinking", () => { + // #given + const overrides = { + oracle: { model: "openai/gpt-5.2" }, + } + + // #when + const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.oracle.model).toBe("openai/gpt-5.2") @@ -63,7 +81,7 @@ describe("createBuiltinAgents with model overrides", () => { } // #when - const agents = createBuiltinAgents([], overrides) + const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.oracle.model).toBe("anthropic/claude-sonnet-4") @@ -79,7 +97,7 @@ describe("createBuiltinAgents with model overrides", () => { } // #when - const agents = createBuiltinAgents([], overrides) + const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) // #then expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2") @@ -89,9 +107,10 @@ describe("createBuiltinAgents with model overrides", () => { describe("buildAgent with category and skills", () => { const { buildAgent } = require("./utils") + const TEST_MODEL = "anthropic/claude-opus-4-5" test("agent with category inherits category settings", () => { - // #given + // #given - agent factory that sets category but no model const source = { "test-agent": () => ({ @@ -101,10 +120,11 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"]) + const agent = buildAgent(source["test-agent"], TEST_MODEL) - // #then - expect(agent.model).toBe("google/gemini-3-pro-preview") + // #then - DEFAULT_CATEGORIES only has temperature, not model + // Model remains undefined since neither factory nor category provides it + expect(agent.model).toBeUndefined() expect(agent.temperature).toBe(0.7) }) @@ -120,7 +140,7 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"]) + const agent = buildAgent(source["test-agent"], TEST_MODEL) // #then expect(agent.model).toBe("custom/model") @@ -145,7 +165,7 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"], undefined, categories) + const agent = buildAgent(source["test-agent"], TEST_MODEL, categories) // #then expect(agent.model).toBe("openai/gpt-5.2") @@ -164,7 +184,7 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"]) + const agent = buildAgent(source["test-agent"], TEST_MODEL) // #then expect(agent.prompt).toContain("Role: Designer-Turned-Developer") @@ -184,7 +204,7 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"]) + const agent = buildAgent(source["test-agent"], TEST_MODEL) // #then expect(agent.prompt).toContain("Role: Designer-Turned-Developer") @@ -204,7 +224,7 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"]) + const agent = buildAgent(source["test-agent"], TEST_MODEL) // #then expect(agent.model).toBe("custom/model") @@ -225,10 +245,10 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"]) + const agent = buildAgent(source["test-agent"], TEST_MODEL) - // #then - expect(agent.model).toBe("openai/gpt-5.2") + // #then - DEFAULT_CATEGORIES["ultrabrain"] only has temperature, not model + expect(agent.model).toBeUndefined() expect(agent.temperature).toBe(0.1) expect(agent.prompt).toContain("Role: Designer-Turned-Developer") expect(agent.prompt).toContain("Task description") @@ -246,9 +266,11 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"]) + const agent = buildAgent(source["test-agent"], TEST_MODEL) // #then + // Note: The factory receives model, but if category doesn't exist, it's not applied + // The agent's model comes from the factory output (which doesn't set model) expect(agent.model).toBeUndefined() expect(agent.prompt).toBe("Base prompt") }) @@ -265,7 +287,7 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"]) + const agent = buildAgent(source["test-agent"], TEST_MODEL) // #then expect(agent.prompt).toContain("Role: Designer-Turned-Developer") @@ -284,7 +306,7 @@ describe("buildAgent with category and skills", () => { } // #when - const agent = buildAgent(source["test-agent"]) + const agent = buildAgent(source["test-agent"], TEST_MODEL) // #then expect(agent.prompt).toBe("Base prompt") diff --git a/src/agents/utils.ts b/src/agents/utils.ts index cd5c67db..4780675a 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -9,7 +9,7 @@ import { createFrontendUiUxEngineerAgent, FRONTEND_PROMPT_METADATA } from "./fro import { createDocumentWriterAgent, DOCUMENT_WRITER_PROMPT_METADATA } from "./document-writer" import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker" import { createMetisAgent } from "./metis" -import { createOrchestratorSisyphusAgent, orchestratorSisyphusAgent } from "./orchestrator-sisyphus" +import { createOrchestratorSisyphusAgent } from "./orchestrator-sisyphus" import { createMomusAgent } from "./momus" import type { AvailableAgent } from "./sisyphus-prompt-builder" import { deepMerge } from "../shared" @@ -28,7 +28,9 @@ const agentSources: Record = { "multimodal-looker": createMultimodalLookerAgent, "Metis (Plan Consultant)": createMetisAgent, "Momus (Plan Reviewer)": createMomusAgent, - "orchestrator-sisyphus": orchestratorSisyphusAgent, + // Note: orchestrator-sisyphus is handled specially in createBuiltinAgents() + // because it needs OrchestratorContext, not just a model string + "orchestrator-sisyphus": createOrchestratorSisyphusAgent as unknown as AgentFactory, } /** @@ -50,7 +52,7 @@ function isFactory(source: AgentSource): source is AgentFactory { export function buildAgent( source: AgentSource, - model?: string, + model: string, categories?: CategoriesConfig, gitMasterConfig?: GitMasterConfig ): AgentConfig { @@ -134,6 +136,10 @@ export function createBuiltinAgents( categories?: CategoriesConfig, gitMasterConfig?: GitMasterConfig ): Record { + if (!systemDefaultModel) { + throw new Error("createBuiltinAgents requires systemDefaultModel") + } + const result: Record = {} const availableAgents: AvailableAgent[] = [] @@ -149,7 +155,7 @@ export function createBuiltinAgents( if (disabledAgents.includes(agentName)) continue const override = agentOverrides[agentName] - const model = override?.model + const model = override?.model ?? systemDefaultModel let config = buildAgent(source, model, mergedCategories, gitMasterConfig) diff --git a/src/cli/config-manager.test.ts b/src/cli/config-manager.test.ts index b6d0fc1d..ad4255d0 100644 --- a/src/cli/config-manager.test.ts +++ b/src/cli/config-manager.test.ts @@ -200,85 +200,32 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => { }) }) -describe("generateOmoConfig - GitHub Copilot fallback", () => { - test("frontend-ui-ux-engineer uses Copilot when no native providers", () => { - // #given user has only Copilot (no Claude, ChatGPT, Gemini) +describe("generateOmoConfig - v3 beta: no hardcoded models", () => { + test("generates minimal config with only $schema", () => { + // #given any install config const config: InstallConfig = { - hasClaude: false, + hasClaude: true, isMax20: false, - hasChatGPT: false, + hasChatGPT: true, hasGemini: false, - hasCopilot: true, + hasCopilot: false, } // #when generating config const result = generateOmoConfig(config) - // #then frontend-ui-ux-engineer should use Copilot Gemini - const agents = result.agents as Record - expect(agents["frontend-ui-ux-engineer"]?.model).toBe("github-copilot/gemini-3-pro-preview") + // #then should only contain $schema, no agents or categories + expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json") + expect(result.agents).toBeUndefined() + expect(result.categories).toBeUndefined() }) - test("document-writer uses Copilot when no native providers", () => { - // #given user has only Copilot + test("does not include model fields regardless of provider config", () => { + // #given user has multiple providers const config: InstallConfig = { - hasClaude: false, - isMax20: false, - hasChatGPT: false, - hasGemini: false, - hasCopilot: true, - } - - // #when generating config - const result = generateOmoConfig(config) - - // #then document-writer should use Copilot Gemini Flash - const agents = result.agents as Record - expect(agents["document-writer"]?.model).toBe("github-copilot/gemini-3-flash-preview") - }) - - test("multimodal-looker uses Copilot when no native providers", () => { - // #given user has only Copilot - const config: InstallConfig = { - hasClaude: false, - isMax20: false, - hasChatGPT: false, - hasGemini: false, - hasCopilot: true, - } - - // #when generating config - const result = generateOmoConfig(config) - - // #then multimodal-looker should use Copilot Gemini Flash - const agents = result.agents as Record - expect(agents["multimodal-looker"]?.model).toBe("github-copilot/gemini-3-flash-preview") - }) - - test("explore uses Copilot grok-code when no native providers", () => { - // #given user has only Copilot - const config: InstallConfig = { - hasClaude: false, - isMax20: false, - hasChatGPT: false, - hasGemini: false, - hasCopilot: true, - } - - // #when generating config - const result = generateOmoConfig(config) - - // #then explore should use Copilot Grok - const agents = result.agents as Record - expect(agents["explore"]?.model).toBe("github-copilot/grok-code-fast-1") - }) - - test("native Gemini takes priority over Copilot for frontend-ui-ux-engineer", () => { - // #given user has both Gemini and Copilot - const config: InstallConfig = { - hasClaude: false, - isMax20: false, - hasChatGPT: false, + hasClaude: true, + isMax20: true, + hasChatGPT: true, hasGemini: true, hasCopilot: true, } @@ -286,46 +233,27 @@ describe("generateOmoConfig - GitHub Copilot fallback", () => { // #when generating config const result = generateOmoConfig(config) - // #then native Gemini should be used (NOT Copilot) - const agents = result.agents as Record - expect(agents["frontend-ui-ux-engineer"]?.model).toBe("google/antigravity-gemini-3-pro-high") + // #then should not have agents or categories with model fields + expect(result.agents).toBeUndefined() + expect(result.categories).toBeUndefined() }) - test("native Claude takes priority over Copilot for frontend-ui-ux-engineer", () => { - // #given user has Claude and Copilot but no Gemini - const config: InstallConfig = { - hasClaude: true, - isMax20: false, - hasChatGPT: false, - hasGemini: false, - hasCopilot: true, - } - - // #when generating config - const result = generateOmoConfig(config) - - // #then native Claude should be used (NOT Copilot) - const agents = result.agents as Record - expect(agents["frontend-ui-ux-engineer"]?.model).toBe("anthropic/claude-opus-4-5") - }) - - test("categories use Copilot models when no native Gemini", () => { - // #given user has Copilot but no Gemini + test("does not include model fields when no providers configured", () => { + // #given user has no providers const config: InstallConfig = { hasClaude: false, isMax20: false, hasChatGPT: false, hasGemini: false, - hasCopilot: true, + hasCopilot: false, } // #when generating config const result = generateOmoConfig(config) - // #then categories should use Copilot models - const categories = result.categories as Record - expect(categories?.["visual-engineering"]?.model).toBe("github-copilot/gemini-3-pro-preview") - expect(categories?.["artistry"]?.model).toBe("github-copilot/gemini-3-pro-preview") - expect(categories?.["writing"]?.model).toBe("github-copilot/gemini-3-flash-preview") + // #then should still only contain $schema + expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json") + expect(result.agents).toBeUndefined() + expect(result.categories).toBeUndefined() }) }) diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index 37a96dc7..a11c7eb2 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -306,79 +306,13 @@ function deepMerge>(target: T, source: Partial return result } -export function generateOmoConfig(installConfig: InstallConfig): Record { +export function generateOmoConfig(_installConfig: InstallConfig): Record { + // v3 beta: No hardcoded model strings - users rely on their OpenCode configured model + // Users who want specific models configure them explicitly after install const config: Record = { $schema: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json", } - const agents: Record> = {} - - if (!installConfig.hasClaude) { - agents["Sisyphus"] = { - model: installConfig.hasCopilot ? "github-copilot/claude-opus-4.5" : "opencode/glm-4.7-free", - } - } - - agents["librarian"] = { model: "opencode/glm-4.7-free" } - - // Gemini models use `antigravity-` prefix for explicit Antigravity quota routing - // @see ANTIGRAVITY_PROVIDER_CONFIG comments for rationale - if (installConfig.hasGemini) { - agents["explore"] = { model: "google/antigravity-gemini-3-flash" } - } else if (installConfig.hasClaude && installConfig.isMax20) { - agents["explore"] = { model: "anthropic/claude-haiku-4-5" } - } else if (installConfig.hasCopilot) { - agents["explore"] = { model: "github-copilot/grok-code-fast-1" } - } else { - agents["explore"] = { model: "opencode/glm-4.7-free" } - } - - if (!installConfig.hasChatGPT) { - const oracleFallback = installConfig.hasCopilot - ? "github-copilot/gpt-5.2" - : installConfig.hasClaude - ? "anthropic/claude-opus-4-5" - : "opencode/glm-4.7-free" - agents["oracle"] = { model: oracleFallback } - } - - if (installConfig.hasGemini) { - agents["frontend-ui-ux-engineer"] = { model: "google/antigravity-gemini-3-pro-high" } - agents["document-writer"] = { model: "google/antigravity-gemini-3-flash" } - agents["multimodal-looker"] = { model: "google/antigravity-gemini-3-flash" } - } else if (installConfig.hasClaude) { - agents["frontend-ui-ux-engineer"] = { model: "anthropic/claude-opus-4-5" } - agents["document-writer"] = { model: "anthropic/claude-opus-4-5" } - agents["multimodal-looker"] = { model: "anthropic/claude-opus-4-5" } - } else if (installConfig.hasCopilot) { - agents["frontend-ui-ux-engineer"] = { model: "github-copilot/gemini-3-pro-preview" } - agents["document-writer"] = { model: "github-copilot/gemini-3-flash-preview" } - agents["multimodal-looker"] = { model: "github-copilot/gemini-3-flash-preview" } - } else { - agents["frontend-ui-ux-engineer"] = { model: "opencode/glm-4.7-free" } - agents["document-writer"] = { model: "opencode/glm-4.7-free" } - agents["multimodal-looker"] = { model: "opencode/glm-4.7-free" } - } - - if (Object.keys(agents).length > 0) { - config.agents = agents - } - - // Categories: override model for Antigravity auth or GitHub Copilot fallback - if (installConfig.hasGemini) { - config.categories = { - "visual-engineering": { model: "google/gemini-3-pro-high" }, - artistry: { model: "google/gemini-3-pro-high" }, - writing: { model: "google/gemini-3-flash-high" }, - } - } else if (installConfig.hasCopilot) { - config.categories = { - "visual-engineering": { model: "github-copilot/gemini-3-pro-preview" }, - artistry: { model: "github-copilot/gemini-3-pro-preview" }, - writing: { model: "github-copilot/gemini-3-flash-preview" }, - } - } - return config } @@ -646,11 +580,9 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult { } } -interface OmoConfigData { - agents?: Record -} - export function detectCurrentConfig(): DetectedConfig { + // v3 beta: Since we no longer generate hardcoded model strings, + // detection only checks for plugin installation and Gemini auth plugin const result: DetectedConfig = { isInstalled: false, hasClaude: true, @@ -678,53 +610,8 @@ export function detectCurrentConfig(): DetectedConfig { return result } + // Gemini auth plugin detection still works via plugin presence result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth")) - const omoConfigPath = getOmoConfig() - if (!existsSync(omoConfigPath)) { - return result - } - - try { - const stat = statSync(omoConfigPath) - if (stat.size === 0) { - return result - } - - const content = readFileSync(omoConfigPath, "utf-8") - if (isEmptyOrWhitespace(content)) { - return result - } - - const omoConfig = parseJsonc(content) - if (!omoConfig || typeof omoConfig !== "object") { - return result - } - - const agents = omoConfig.agents ?? {} - - if (agents["Sisyphus"]?.model === "opencode/glm-4.7-free") { - result.hasClaude = false - result.isMax20 = false - } else if (agents["librarian"]?.model === "opencode/glm-4.7-free") { - result.hasClaude = true - result.isMax20 = false - } - - if (agents["oracle"]?.model?.startsWith("anthropic/")) { - result.hasChatGPT = false - } else if (agents["oracle"]?.model === "opencode/glm-4.7-free") { - result.hasChatGPT = false - } - - const hasAnyCopilotModel = Object.values(agents).some( - (agent) => agent?.model?.startsWith("github-copilot/") - ) - result.hasCopilot = hasAnyCopilotModel - - } catch { - /* intentionally empty - malformed omo config returns defaults from opencode config detection */ - } - return result } diff --git a/src/cli/install.ts b/src/cli/install.ts index d7c8fce8..402a1d4b 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -47,18 +47,11 @@ function formatConfigSummary(config: InstallConfig): string { lines.push(color.dim("─".repeat(40))) lines.push("") - lines.push(color.bold(color.white("Agent Configuration"))) + // v3 beta: No hardcoded models - agents use OpenCode's configured default model + lines.push(color.bold(color.white("Agent Models"))) lines.push("") - - const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : (config.hasCopilot ? "github-copilot/claude-opus-4.5" : "glm-4.7-free") - const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasCopilot ? "github-copilot/gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free")) - const librarianModel = "glm-4.7-free" - const frontendModel = config.hasGemini ? "antigravity-gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free") - - lines.push(` ${SYMBOLS.bullet} Sisyphus ${SYMBOLS.arrow} ${color.cyan(sisyphusModel)}`) - lines.push(` ${SYMBOLS.bullet} Oracle ${SYMBOLS.arrow} ${color.cyan(oracleModel)}`) - lines.push(` ${SYMBOLS.bullet} Librarian ${SYMBOLS.arrow} ${color.cyan(librarianModel)}`) - lines.push(` ${SYMBOLS.bullet} Frontend ${SYMBOLS.arrow} ${color.cyan(frontendModel)}`) + lines.push(` ${SYMBOLS.info} Agents will use your OpenCode default model`) + lines.push(` ${SYMBOLS.bullet} Configure specific models in ${color.cyan("oh-my-opencode.json")} if needed`) return lines.join("\n") } diff --git a/src/config/schema.ts b/src/config/schema.ts index df16d886..8be0a144 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -154,7 +154,7 @@ export const SisyphusAgentConfigSchema = z.object({ }) export const CategoryConfigSchema = z.object({ - model: z.string(), + model: z.string().optional(), variant: z.string().optional(), temperature: z.number().min(0).max(2).optional(), top_p: z.number().min(0).max(1).optional(), diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 9724965f..e7acf5f6 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -10,9 +10,9 @@ describe("Prometheus category config resolution", () => { // #when const config = resolveCategoryConfig(categoryName) - // #then + // #then - DEFAULT_CATEGORIES only has temperature, not model expect(config).toBeDefined() - expect(config?.model).toBe("openai/gpt-5.2") + expect(config?.model).toBeUndefined() expect(config?.temperature).toBe(0.1) }) @@ -23,9 +23,9 @@ describe("Prometheus category config resolution", () => { // #when const config = resolveCategoryConfig(categoryName) - // #then + // #then - DEFAULT_CATEGORIES only has temperature, not model expect(config).toBeDefined() - expect(config?.model).toBe("google/gemini-3-pro-preview") + expect(config?.model).toBeUndefined() expect(config?.temperature).toBe(0.7) }) @@ -71,9 +71,9 @@ describe("Prometheus category config resolution", () => { // #when const config = resolveCategoryConfig(categoryName, userCategories) - // #then + // #then - falls back to DEFAULT_CATEGORIES which has no model expect(config).toBeDefined() - expect(config?.model).toBe("openai/gpt-5.2") + expect(config?.model).toBeUndefined() expect(config?.temperature).toBe(0.1) }) diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index a6d37cd6..626f288c 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -200,12 +200,13 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { ) : undefined; + // Model resolution: explicit override → category config → OpenCode default + // No hardcoded fallback - OpenCode config.model is the terminal fallback + const resolvedModel = prometheusOverride?.model ?? categoryConfig?.model ?? defaultModel; + const prometheusBase = { - model: - prometheusOverride?.model ?? - categoryConfig?.model ?? - defaultModel ?? - "anthropic/claude-opus-4-5", + // Only include model if one was resolved - let OpenCode apply its own default if none + ...(resolvedModel ? { model: resolvedModel } : {}), mode: "primary" as const, prompt: PROMETHEUS_SYSTEM_PROMPT, permission: PROMETHEUS_PERMISSION, diff --git a/src/shared/index.ts b/src/shared/index.ts index 7ee5a321..fef890e3 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -26,3 +26,4 @@ export * from "./session-cursor" export * from "./shell-env" export * from "./system-directive" export * from "./agent-tool-restrictions" +export * from "./model-resolver" diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts index 8f8325f4..aa6593cc 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -370,9 +370,9 @@ describe("shouldDeleteAgentConfig", () => { test("returns true when all fields match category defaults", () => { // #given: Config with fields matching category defaults + // Note: DEFAULT_CATEGORIES only has temperature, not model const config = { category: "visual-engineering", - model: "google/gemini-3-pro-preview", temperature: 0.7, } diff --git a/src/shared/migration.ts b/src/shared/migration.ts index 69c75bc3..28bacb85 100644 --- a/src/shared/migration.ts +++ b/src/shared/migration.ts @@ -44,7 +44,19 @@ export const HOOK_NAME_MAP: Record = { "anthropic-auto-compact": "anthropic-context-window-limit-recovery", } -// Model to category mapping for auto-migration +/** + * @deprecated LEGACY MIGRATION ONLY + * + * This map exists solely for migrating old configs that used hardcoded model strings. + * It maps legacy model strings to semantic category names, allowing users to migrate + * from explicit model configs to category-based configs. + * + * DO NOT add new entries here. New agents should use: + * - Category-based config (preferred): { category: "most-capable" } + * - Or inherit from OpenCode's config.model + * + * This map will be removed in a future major version once migration period ends. + */ export const MODEL_TO_CATEGORY_MAP: Record = { "google/gemini-3-pro-preview": "visual-engineering", "openai/gpt-5.2": "ultrabrain", diff --git a/src/shared/model-resolver.test.ts b/src/shared/model-resolver.test.ts new file mode 100644 index 00000000..d984be29 --- /dev/null +++ b/src/shared/model-resolver.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { resolveModel, type ModelResolutionInput } from "./model-resolver"; + +describe("resolveModel", () => { + describe("priority chain", () => { + test("returns userModel when all three are set", () => { + // #given + const input: ModelResolutionInput = { + userModel: "anthropic/claude-opus-4-5", + inheritedModel: "openai/gpt-5.2", + systemDefault: "google/gemini-3-pro", + }; + + // #when + const result = resolveModel(input); + + // #then + expect(result).toBe("anthropic/claude-opus-4-5"); + }); + + test("returns inheritedModel when userModel is undefined", () => { + // #given + const input: ModelResolutionInput = { + userModel: undefined, + inheritedModel: "openai/gpt-5.2", + systemDefault: "google/gemini-3-pro", + }; + + // #when + const result = resolveModel(input); + + // #then + expect(result).toBe("openai/gpt-5.2"); + }); + + test("returns systemDefault when both userModel and inheritedModel are undefined", () => { + // #given + const input: ModelResolutionInput = { + userModel: undefined, + inheritedModel: undefined, + systemDefault: "google/gemini-3-pro", + }; + + // #when + const result = resolveModel(input); + + // #then + expect(result).toBe("google/gemini-3-pro"); + }); + }); + + describe("empty string handling", () => { + test("treats empty string as unset, uses fallback", () => { + // #given + const input: ModelResolutionInput = { + userModel: "", + inheritedModel: "openai/gpt-5.2", + systemDefault: "google/gemini-3-pro", + }; + + // #when + const result = resolveModel(input); + + // #then + expect(result).toBe("openai/gpt-5.2"); + }); + + test("treats whitespace-only string as unset, uses fallback", () => { + // #given + const input: ModelResolutionInput = { + userModel: " ", + inheritedModel: "", + systemDefault: "google/gemini-3-pro", + }; + + // #when + const result = resolveModel(input); + + // #then + expect(result).toBe("google/gemini-3-pro"); + }); + }); + + describe("purity", () => { + test("same input returns same output (referential transparency)", () => { + // #given + const input: ModelResolutionInput = { + userModel: "anthropic/claude-opus-4-5", + inheritedModel: "openai/gpt-5.2", + systemDefault: "google/gemini-3-pro", + }; + + // #when + const result1 = resolveModel(input); + const result2 = resolveModel(input); + + // #then + expect(result1).toBe(result2); + }); + }); +}); diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts new file mode 100644 index 00000000..2e67f85d --- /dev/null +++ b/src/shared/model-resolver.ts @@ -0,0 +1,35 @@ +/** + * Input for model resolution. + * All model strings are optional except systemDefault which is the terminal fallback. + */ +export type ModelResolutionInput = { + /** Model from user category config */ + userModel?: string; + /** Model inherited from parent task/session */ + inheritedModel?: string; + /** System default model from OpenCode config - always required */ + systemDefault: string; +}; + +/** + * Normalizes a model string. + * Trims whitespace and treats empty/whitespace-only as undefined. + */ +function normalizeModel(model?: string): string | undefined { + const trimmed = model?.trim(); + return trimmed || undefined; +} + +/** + * Resolves the effective model using priority chain: + * userModel → inheritedModel → systemDefault + * + * Empty strings and whitespace-only strings are treated as unset. + */ +export function resolveModel(input: ModelResolutionInput): string { + return ( + normalizeModel(input.userModel) ?? + normalizeModel(input.inheritedModel) ?? + input.systemDefault + ); +} diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index 1d13e085..0df516d3 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -185,31 +185,24 @@ The more explicit your prompt, the better the results. export const DEFAULT_CATEGORIES: Record = { "visual-engineering": { - model: "google/gemini-3-pro-preview", temperature: 0.7, }, ultrabrain: { - model: "openai/gpt-5.2", temperature: 0.1, }, artistry: { - model: "google/gemini-3-pro-preview", temperature: 0.9, }, quick: { - model: "anthropic/claude-haiku-4-5", temperature: 0.3, }, "most-capable": { - model: "anthropic/claude-opus-4-5", temperature: 0.1, }, writing: { - model: "google/gemini-3-flash-preview", temperature: 0.5, }, general: { - model: "anthropic/claude-sonnet-4-5", temperature: 0.3, }, } diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 600abc05..fced53a1 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -1,60 +1,30 @@ import { describe, test, expect } from "bun:test" import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, DELEGATE_TASK_DESCRIPTION } from "./constants" +import { resolveCategoryConfig } from "./tools" import type { CategoryConfig } from "../../config/schema" -function resolveCategoryConfig( - categoryName: string, - options: { - userCategories?: Record - parentModelString?: string - systemDefaultModel?: string - } -): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null { - const { userCategories, parentModelString, systemDefaultModel } = options - const defaultConfig = DEFAULT_CATEGORIES[categoryName] - const userConfig = userCategories?.[categoryName] - const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? "" - - if (!defaultConfig && !userConfig) { - return null - } - - const model = userConfig?.model ?? defaultConfig?.model ?? parentModelString ?? systemDefaultModel - const config: CategoryConfig = { - ...defaultConfig, - ...userConfig, - model, - } - - let promptAppend = defaultPromptAppend - if (userConfig?.prompt_append) { - promptAppend = defaultPromptAppend - ? defaultPromptAppend + "\n\n" + userConfig.prompt_append - : userConfig.prompt_append - } - - return { config, promptAppend, model } -} +// Test constants - systemDefaultModel is required by resolveCategoryConfig +const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-5" describe("sisyphus-task", () => { describe("DEFAULT_CATEGORIES", () => { - test("visual-engineering category has gemini model", () => { + test("visual-engineering category has temperature config only (model removed)", () => { // #given const category = DEFAULT_CATEGORIES["visual-engineering"] // #when / #then expect(category).toBeDefined() - expect(category.model).toBe("google/gemini-3-pro-preview") + expect(category.model).toBeUndefined() expect(category.temperature).toBe(0.7) }) - test("ultrabrain category has gpt model", () => { + test("ultrabrain category has temperature config only (model removed)", () => { // #given const category = DEFAULT_CATEGORIES["ultrabrain"] // #when / #then expect(category).toBeDefined() - expect(category.model).toBe("openai/gpt-5.2") + expect(category.model).toBeUndefined() expect(category.temperature).toBe(0.1) }) }) @@ -120,26 +90,26 @@ describe("sisyphus-task", () => { const categoryName = "unknown-category" // #when - const result = resolveCategoryConfig(categoryName, {}) + const result = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL }) // #then expect(result).toBeNull() }) - test("returns default config for builtin category", () => { + test("returns systemDefaultModel for builtin category (categories no longer have default models)", () => { // #given const categoryName = "visual-engineering" // #when - const result = resolveCategoryConfig(categoryName, {}) + const result = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL }) - // #then + // #then - model comes from systemDefaultModel since categories no longer have model defaults expect(result).not.toBeNull() - expect(result!.config.model).toBe("google/gemini-3-pro-preview") + expect(result!.config.model).toBe(SYSTEM_DEFAULT_MODEL) expect(result!.promptAppend).toContain("VISUAL/UI") }) - test("user config overrides default model", () => { + test("user config overrides systemDefaultModel", () => { // #given const categoryName = "visual-engineering" const userCategories = { @@ -147,7 +117,7 @@ describe("sisyphus-task", () => { } // #when - const result = resolveCategoryConfig(categoryName, { userCategories }) + const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) // #then expect(result).not.toBeNull() @@ -165,7 +135,7 @@ describe("sisyphus-task", () => { } // #when - const result = resolveCategoryConfig(categoryName, { userCategories }) + const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) // #then expect(result).not.toBeNull() @@ -185,7 +155,7 @@ describe("sisyphus-task", () => { } // #when - const result = resolveCategoryConfig(categoryName, { userCategories }) + const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) // #then expect(result).not.toBeNull() @@ -205,66 +175,66 @@ describe("sisyphus-task", () => { } // #when - const result = resolveCategoryConfig(categoryName, { userCategories }) + const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) // #then expect(result).not.toBeNull() expect(result!.config.temperature).toBe(0.3) }) - test("category default model takes precedence over parentModelString", () => { - // #given - builtin category has default model, parent model should NOT override it + test("inheritedModel takes precedence over systemDefaultModel", () => { + // #given - builtin category, parent model provided const categoryName = "visual-engineering" - const parentModelString = "cliproxy/claude-opus-4-5" + const inheritedModel = "cliproxy/claude-opus-4-5" // #when - const result = resolveCategoryConfig(categoryName, { parentModelString }) + const result = resolveCategoryConfig(categoryName, { inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) - // #then - category default model wins, parent model is ignored for builtin categories - expect(result).not.toBeNull() - expect(result!.config.model).toBe("google/gemini-3-pro-preview") - }) - - test("parentModelString is used as fallback when category has no default model", () => { - // #given - custom category with no model defined, only parentModelString as fallback - const categoryName = "my-custom-no-model" - const userCategories = { "my-custom-no-model": { temperature: 0.5 } } as unknown as Record - const parentModelString = "cliproxy/claude-opus-4-5" - - // #when - const result = resolveCategoryConfig(categoryName, { userCategories, parentModelString }) - - // #then - parent model is used as fallback since custom category has no default + // #then - inheritedModel wins over systemDefaultModel expect(result).not.toBeNull() expect(result!.config.model).toBe("cliproxy/claude-opus-4-5") }) - test("user model takes precedence over parentModelString", () => { + test("inheritedModel is used as fallback when category has no user model", () => { + // #given - custom category with no model defined, only inheritedModel as fallback + const categoryName = "my-custom-no-model" + const userCategories = { "my-custom-no-model": { temperature: 0.5 } } as unknown as Record + const inheritedModel = "cliproxy/claude-opus-4-5" + + // #when + const result = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) + + // #then - parent model is used as fallback since custom category has no user model + expect(result).not.toBeNull() + expect(result!.config.model).toBe("cliproxy/claude-opus-4-5") + }) + + test("user model takes precedence over inheritedModel", () => { // #given const categoryName = "visual-engineering" const userCategories = { "visual-engineering": { model: "my-provider/my-model" }, } - const parentModelString = "cliproxy/claude-opus-4-5" + const inheritedModel = "cliproxy/claude-opus-4-5" // #when - const result = resolveCategoryConfig(categoryName, { userCategories, parentModelString }) + const result = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) // #then expect(result).not.toBeNull() expect(result!.config.model).toBe("my-provider/my-model") }) - test("default model is used when no user model and no parentModelString", () => { + test("systemDefaultModel is used when no user model and no inheritedModel", () => { // #given const categoryName = "visual-engineering" // #when - const result = resolveCategoryConfig(categoryName, {}) + const result = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL }) // #then expect(result).not.toBeNull() - expect(result!.config.model).toBe("google/gemini-3-pro-preview") + expect(result!.config.model).toBe(SYSTEM_DEFAULT_MODEL) }) }) @@ -289,7 +259,7 @@ describe("sisyphus-task", () => { const mockClient = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, session: { create: async () => ({ data: { id: "test-session" } }), prompt: async () => ({ data: {} }), @@ -348,7 +318,7 @@ describe("sisyphus-task", () => { const mockManager = { launch: async () => ({}) } const mockClient = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, session: { create: async () => ({ data: { id: "test-session" } }), prompt: async () => ({ data: {} }), @@ -391,7 +361,7 @@ describe("sisyphus-task", () => { const mockManager = { launch: async () => ({}) } const mockClient = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, session: { create: async () => ({ data: { id: "test-session" } }), prompt: async () => ({ data: {} }), @@ -438,7 +408,7 @@ describe("sisyphus-task", () => { const mockManager = { launch: async () => ({}) } const mockClient = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, session: { get: async () => ({ data: { directory: "/project" } }), create: async () => ({ data: { id: "test-session" } }), @@ -513,7 +483,7 @@ describe("sisyphus-task", () => { ], }), }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, app: { agents: async () => ({ data: [] }), }, @@ -571,7 +541,7 @@ describe("sisyphus-task", () => { data: [], }), }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, } const tool = createDelegateTask({ @@ -623,7 +593,7 @@ describe("sisyphus-task", () => { messages: async () => ({ data: [] }), status: async () => ({ data: {} }), }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, app: { agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }), }, @@ -683,7 +653,7 @@ describe("sisyphus-task", () => { }), status: async () => ({ data: { "ses_sync_success": { type: "idle" } } }), }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, app: { agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }), }, @@ -736,7 +706,7 @@ describe("sisyphus-task", () => { messages: async () => ({ data: [] }), status: async () => ({ data: {} }), }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, app: { agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }), }, @@ -790,7 +760,7 @@ describe("sisyphus-task", () => { }), status: async () => ({ data: {} }), }, - config: { get: async () => ({}) }, + config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) }, app: { agents: async () => ({ data: [] }) }, } @@ -879,47 +849,41 @@ describe("sisyphus-task", () => { }) describe("modelInfo detection via resolveCategoryConfig", () => { - test("when parentModelString exists but default model wins - modelInfo should report category-default", () => { - // #given - Bug scenario: parentModelString is passed but userModel is undefined, - // and the resolution order is: userModel ?? parentModelString ?? defaultModel - // If parentModelString matches the resolved model, it's "inherited" - // If defaultModel matches, it's "category-default" + test("systemDefaultModel is used when no userModel and no inheritedModel", () => { + // #given - builtin category, no user model, no inherited model const categoryName = "ultrabrain" - const parentModelString = undefined // #when - const resolved = resolveCategoryConfig(categoryName, { parentModelString }) + const resolved = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL }) - // #then - actualModel should be defaultModel, type should be "category-default" + // #then - actualModel should be systemDefaultModel (categories no longer have model defaults) expect(resolved).not.toBeNull() const actualModel = resolved!.config.model - const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model - expect(actualModel).toBe(defaultModel) - expect(actualModel).toBe("openai/gpt-5.2") + expect(actualModel).toBe(SYSTEM_DEFAULT_MODEL) }) - test("category default model takes precedence over parentModelString for builtin category", () => { - // #given - builtin ultrabrain category has default model gpt-5.2 + test("inheritedModel takes precedence over systemDefaultModel for builtin category", () => { + // #given - builtin ultrabrain category, inherited model from parent const categoryName = "ultrabrain" - const parentModelString = "cliproxy/claude-opus-4-5" + const inheritedModel = "cliproxy/claude-opus-4-5" // #when - const resolved = resolveCategoryConfig(categoryName, { parentModelString }) + const resolved = resolveCategoryConfig(categoryName, { inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) - // #then - category default model wins, not the parent model + // #then - inheritedModel wins over systemDefaultModel expect(resolved).not.toBeNull() const actualModel = resolved!.config.model - expect(actualModel).toBe("openai/gpt-5.2") + expect(actualModel).toBe("cliproxy/claude-opus-4-5") }) - test("when user defines model - modelInfo should report user-defined regardless of parentModelString", () => { + test("when user defines model - modelInfo should report user-defined regardless of inheritedModel", () => { // #given const categoryName = "ultrabrain" const userCategories = { "ultrabrain": { model: "my-provider/custom-model" } } - const parentModelString = "cliproxy/claude-opus-4-5" + const inheritedModel = "cliproxy/claude-opus-4-5" // #when - const resolved = resolveCategoryConfig(categoryName, { userCategories, parentModelString }) + const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) // #then - actualModel should be userModel, type should be "user-defined" expect(resolved).not.toBeNull() @@ -931,28 +895,109 @@ describe("sisyphus-task", () => { test("detection logic: actualModel comparison correctly identifies source", () => { // #given - This test verifies the fix for PR #770 bug - // The bug was: checking `if (parentModelString)` instead of `if (actualModel === parentModelString)` + // The bug was: checking `if (inheritedModel)` instead of `if (actualModel === inheritedModel)` const categoryName = "ultrabrain" - const parentModelString = "cliproxy/claude-opus-4-5" + const inheritedModel = "cliproxy/claude-opus-4-5" const userCategories = { "ultrabrain": { model: "user/model" } } // #when - user model wins - const resolved = resolveCategoryConfig(categoryName, { userCategories, parentModelString }) + const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) const actualModel = resolved!.config.model const userDefinedModel = userCategories[categoryName]?.model - const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model // #then - detection should compare against actual resolved model const detectedType = actualModel === userDefinedModel ? "user-defined" - : actualModel === parentModelString + : actualModel === inheritedModel ? "inherited" - : actualModel === defaultModel - ? "category-default" + : actualModel === SYSTEM_DEFAULT_MODEL + ? "system-default" : undefined expect(detectedType).toBe("user-defined") - expect(actualModel).not.toBe(parentModelString) + expect(actualModel).not.toBe(inheritedModel) + }) + + // ===== TESTS FOR resolveModel() INTEGRATION (TDD GREEN) ===== + // These tests verify the NEW behavior where categories do NOT have default models + + test("FIXED: inheritedModel takes precedence over systemDefaultModel", () => { + // #given a builtin category, and an inherited model from parent + // The NEW correct chain: userConfig?.model ?? inheritedModel ?? systemDefaultModel + const categoryName = "ultrabrain" + const inheritedModel = "anthropic/claude-opus-4-5" // inherited from parent session + + // #when userConfig.model is undefined and inheritedModel is set + const resolved = resolveCategoryConfig(categoryName, { inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) + + // #then inheritedModel should be used, NOT systemDefaultModel + expect(resolved).not.toBeNull() + expect(resolved!.model).toBe("anthropic/claude-opus-4-5") + }) + + test("FIXED: systemDefaultModel is used when no userConfig.model and no inheritedModel", () => { + // #given a custom category with no default model + const categoryName = "custom-no-default" + const userCategories = { "custom-no-default": { temperature: 0.5 } } as unknown as Record + const systemDefaultModel = "anthropic/claude-sonnet-4-5" + + // #when no inheritedModel is provided, only systemDefaultModel + const resolved = resolveCategoryConfig(categoryName, { + userCategories, + systemDefaultModel + }) + + // #then systemDefaultModel should be returned + expect(resolved).not.toBeNull() + expect(resolved!.model).toBe("anthropic/claude-sonnet-4-5") + }) + + test("FIXED: userConfig.model always takes priority over everything", () => { + // #given userConfig.model is explicitly set + const categoryName = "ultrabrain" + const userCategories = { "ultrabrain": { model: "custom/user-model" } } + const inheritedModel = "anthropic/claude-opus-4-5" + const systemDefaultModel = "anthropic/claude-sonnet-4-5" + + // #when resolveCategoryConfig is called with all sources + const resolved = resolveCategoryConfig(categoryName, { + userCategories, + inheritedModel, + systemDefaultModel + }) + + // #then userConfig.model should win + expect(resolved).not.toBeNull() + expect(resolved!.model).toBe("custom/user-model") + }) + + test("FIXED: empty string in userConfig.model is treated as unset and falls back", () => { + // #given userConfig.model is empty string "" + const categoryName = "custom-empty-model" + const userCategories = { "custom-empty-model": { model: "", temperature: 0.3 } } + const inheritedModel = "anthropic/claude-opus-4-5" + + // #when resolveCategoryConfig is called + const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) + + // #then should fall back to inheritedModel since "" is normalized to undefined + expect(resolved).not.toBeNull() + expect(resolved!.model).toBe("anthropic/claude-opus-4-5") + }) + + test("FIXED: undefined userConfig.model falls back to inheritedModel", () => { + // #given user explicitly sets a category but leaves model undefined + const categoryName = "visual-engineering" + // Using type assertion since we're testing fallback behavior for categories without model + const userCategories = { "visual-engineering": { temperature: 0.2 } } as unknown as Record + const inheritedModel = "anthropic/claude-opus-4-5" + + // #when resolveCategoryConfig is called + const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL }) + + // #then should use inheritedModel + expect(resolved).not.toBeNull() + expect(resolved!.model).toBe("anthropic/claude-opus-4-5") }) test("systemDefaultModel is used when no other model is available", () => { @@ -969,19 +1014,5 @@ describe("sisyphus-task", () => { expect(resolved).not.toBeNull() expect(resolved!.model).toBe(systemDefaultModel) }) - - test("model is undefined when no model available anywhere", () => { - // #given - custom category with no model, no systemDefaultModel - const categoryName = "my-custom" - // Using type assertion since we're testing fallback behavior for categories without model - const userCategories = { "my-custom": { temperature: 0.5 } } as unknown as Record - - // #when - const resolved = resolveCategoryConfig(categoryName, { userCategories }) - - // #then - model should be undefined - expect(resolved).not.toBeNull() - expect(resolved!.model).toBeUndefined() - }) }) }) diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index ee66f3d1..b55f4d4c 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -11,7 +11,7 @@ import { discoverSkills } from "../../features/opencode-skill-loader" import { getTaskToastManager } from "../../features/task-toast-manager" import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state" -import { log, getAgentToolRestrictions } from "../../shared" +import { log, getAgentToolRestrictions, resolveModel } from "../../shared" type OpencodeClient = PluginInput["client"] @@ -107,15 +107,15 @@ type ToolContextWithMetadata = { metadata?: (input: { title?: string; metadata?: Record }) => void } -function resolveCategoryConfig( +export function resolveCategoryConfig( categoryName: string, options: { userCategories?: CategoriesConfig - parentModelString?: string - systemDefaultModel?: string + inheritedModel?: string + systemDefaultModel: string } -): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null { - const { userCategories, parentModelString, systemDefaultModel } = options +): { config: CategoryConfig; promptAppend: string; model: string } | null { + const { userCategories, inheritedModel, systemDefaultModel } = options const defaultConfig = DEFAULT_CATEGORIES[categoryName] const userConfig = userCategories?.[categoryName] const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? "" @@ -124,8 +124,12 @@ function resolveCategoryConfig( return null } - // Model priority: user override > category default > parent model (fallback) > system default - const model = userConfig?.model ?? defaultConfig?.model ?? parentModelString ?? systemDefaultModel + // Model priority: user override > inherited from parent > system default + const model = resolveModel({ + userModel: userConfig?.model, + inheritedModel, + systemDefault: systemDefaultModel, + }) const config: CategoryConfig = { ...defaultConfig, ...userConfig, @@ -421,16 +425,21 @@ ${textContent || "(No text output)"}` let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined let categoryPromptAppend: string | undefined - const parentModelString = parentModel + const inheritedModel = parentModel ? `${parentModel.providerID}/${parentModel.modelID}` : undefined let modelInfo: ModelFallbackInfo | undefined if (args.category) { + // Guard: require system default model for category delegation + if (!systemDefaultModel) { + return `No default model configured. Set a model in your OpenCode config (model field).` + } + const resolved = resolveCategoryConfig(args.category, { userCategories, - parentModelString, + inheritedModel, systemDefaultModel, }) if (!resolved) { @@ -440,11 +449,6 @@ ${textContent || "(No text output)"}` // Determine model source by comparing against the actual resolved model const actualModel = resolved.model const userDefinedModel = userCategories?.[args.category]?.model - const categoryDefaultModel = DEFAULT_CATEGORIES[args.category]?.model - - if (!actualModel) { - return `No model configured. Set a model in your OpenCode config, plugin config, or use a category with a default model.` - } if (!parseModelString(actualModel)) { return `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").` @@ -454,12 +458,9 @@ ${textContent || "(No text output)"}` case userDefinedModel: modelInfo = { model: actualModel, type: "user-defined" } break - case parentModelString: + case inheritedModel: modelInfo = { model: actualModel, type: "inherited" } break - case categoryDefaultModel: - modelInfo = { model: actualModel, type: "category-default" } - break case systemDefaultModel: modelInfo = { model: actualModel, type: "system-default" } break