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