fix: remove hardcoded model defaults from categories and agents

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.
This commit is contained in:
Kenny 2026-01-17 12:51:03 -05:00
parent 0ce87085db
commit c698a5b888
31 changed files with 459 additions and 496 deletions

4
.gitignore vendored
View File

@ -5,6 +5,10 @@ node_modules/
# Build output # Build output
dist/ dist/
# Build artifacts in src (should go to dist/)
src/**/*.js
src/**/*.js.map
# Platform binaries (built, not committed) # Platform binaries (built, not committed)
packages/*/bin/oh-my-opencode packages/*/bin/oh-my-opencode
packages/*/bin/oh-my-opencode.exe packages/*/bin/oh-my-opencode.exe

View File

@ -2060,10 +2060,7 @@
"prompt_append": { "prompt_append": {
"type": "string" "type": "string"
} }
}, }
"required": [
"model"
]
} }
}, },
"claude_code": { "claude_code": {

View File

@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types" import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat" import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = { export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
category: "specialist", category: "specialist",
cost: "CHEAP", cost: "CHEAP",
@ -13,9 +11,7 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
], ],
} }
export function createDocumentWriterAgent( export function createDocumentWriterAgent(model: string): AgentConfig {
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolRestrictions([]) const restrictions = createAgentToolRestrictions([])
return { return {
@ -221,4 +217,3 @@ You are a technical writer who creates documentation that developers actually wa
} }
} }
export const documentWriterAgent = createDocumentWriterAgent()

View File

@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types" import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat" import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "opencode/grok-code"
export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = { export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
category: "exploration", category: "exploration",
cost: "FREE", 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([ const restrictions = createAgentToolRestrictions([
"write", "write",
"edit", "edit",
@ -122,4 +120,3 @@ Flood with parallel calls. Cross-validate findings across multiple tools.`,
} }
} }
export const exploreAgent = createExploreAgent()

View File

@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types" import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat" import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = { export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
category: "specialist", category: "specialist",
cost: "CHEAP", cost: "CHEAP",
@ -19,9 +17,7 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
], ],
} }
export function createFrontendUiUxEngineerAgent( export function createFrontendUiUxEngineerAgent(model: string): AgentConfig {
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolRestrictions([]) const restrictions = createAgentToolRestrictions([])
return { return {
@ -106,4 +102,3 @@ Interpret creatively and make unexpected choices that feel genuinely designed fo
} }
} }
export const frontendUiUxEngineerAgent = createFrontendUiUxEngineerAgent()

View File

@ -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<string, AgentConfig> = {
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 * from "./types"
export { createBuiltinAgents } from "./utils" export { createBuiltinAgents } from "./utils"
export type { AvailableAgent } from "./sisyphus-prompt-builder" 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"

View File

@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types" import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat" import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "opencode/glm-4.7-free"
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = { export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
category: "exploration", category: "exploration",
cost: "CHEAP", 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([ const restrictions = createAgentToolRestrictions([
"write", "write",
"edit", "edit",
@ -326,4 +324,3 @@ grep_app_searchGitHub(query: "useQuery")
} }
} }
export const librarianAgent = createLibrarianAgent()

View File

@ -278,9 +278,7 @@ const metisRestrictions = createAgentToolRestrictions([
"delegate_task", "delegate_task",
]) ])
const DEFAULT_MODEL = "anthropic/claude-opus-4-5" export function createMetisAgent(model: string): AgentConfig {
export function createMetisAgent(model: string = DEFAULT_MODEL): AgentConfig {
return { return {
description: description:
"Pre-planning consultant that analyzes requests to identify hidden intentions, ambiguities, and AI failure points.", "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 } as AgentConfig
} }
export const metisAgent: AgentConfig = createMetisAgent()
export const metisPromptMetadata: AgentPromptMetadata = { export const metisPromptMetadata: AgentPromptMetadata = {
category: "advisor", category: "advisor",

View File

@ -17,8 +17,6 @@ import { createAgentToolRestrictions } from "../shared/permission-compat"
* implementation. * 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. 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**: **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?" **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([ const restrictions = createAgentToolRestrictions([
"write", "write",
"edit", "edit",
@ -416,7 +414,6 @@ export function createMomusAgent(model: string = DEFAULT_MODEL): AgentConfig {
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
} }
export const momusAgent = createMomusAgent()
export const momusPromptMetadata: AgentPromptMetadata = { export const momusPromptMetadata: AgentPromptMetadata = {
category: "advisor", category: "advisor",

View File

@ -2,8 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types" import type { AgentPromptMetadata } from "./types"
import { createAgentToolAllowlist } from "../shared/permission-compat" import { createAgentToolAllowlist } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-flash"
export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = { export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
category: "utility", category: "utility",
cost: "CHEAP", cost: "CHEAP",
@ -11,9 +9,7 @@ export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
triggers: [], triggers: [],
} }
export function createMultimodalLookerAgent( export function createMultimodalLookerAgent(model: string): AgentConfig {
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolAllowlist(["read"]) const restrictions = createAgentToolAllowlist(["read"])
return { return {
@ -58,4 +54,3 @@ Your output goes straight to the main agent for continued work.`,
} }
} }
export const multimodalLookerAgent = createMultimodalLookerAgent()

View File

@ -3,8 +3,6 @@ import type { AgentPromptMetadata } from "./types"
import { isGptModel } from "./types" import { isGptModel } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat" import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "openai/gpt-5.2"
export const ORACLE_PROMPT_METADATA: AgentPromptMetadata = { export const ORACLE_PROMPT_METADATA: AgentPromptMetadata = {
category: "advisor", category: "advisor",
cost: "EXPENSIVE", 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.` 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([ const restrictions = createAgentToolRestrictions([
"write", "write",
"edit", "edit",
@ -122,4 +120,3 @@ export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
} }
export const oracleAgent = createOracleAgent()

View File

@ -1458,9 +1458,10 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
.replace("{SKILLS_SECTION}", skillsSection) .replace("{SKILLS_SECTION}", skillsSection)
} }
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5" export function createOrchestratorSisyphusAgent(ctx: OrchestratorContext): AgentConfig {
if (!ctx.model) {
export function createOrchestratorSisyphusAgent(ctx?: OrchestratorContext): AgentConfig { throw new Error("createOrchestratorSisyphusAgent requires a model in context")
}
const restrictions = createAgentToolRestrictions([ const restrictions = createAgentToolRestrictions([
"task", "task",
"call_omo_agent", "call_omo_agent",
@ -1469,7 +1470,7 @@ export function createOrchestratorSisyphusAgent(ctx?: OrchestratorContext): Agen
description: description:
"Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done", "Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done",
mode: "primary" as const, mode: "primary" as const,
model: ctx?.model ?? DEFAULT_MODEL, model: ctx.model,
temperature: 0.1, temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx), prompt: buildDynamicOrchestratorPrompt(ctx),
thinking: { type: "enabled", budgetTokens: 32000 }, thinking: { type: "enabled", budgetTokens: 32000 },
@ -1478,8 +1479,6 @@ export function createOrchestratorSisyphusAgent(ctx?: OrchestratorContext): Agen
} as AgentConfig } as AgentConfig
} }
export const orchestratorSisyphusAgent: AgentConfig = createOrchestratorSisyphusAgent()
export const orchestratorSisyphusPromptMetadata: AgentPromptMetadata = { export const orchestratorSisyphusPromptMetadata: AgentPromptMetadata = {
category: "advisor", category: "advisor",
cost: "EXPENSIVE", cost: "EXPENSIVE",

View File

@ -138,7 +138,7 @@ export function createSisyphusJuniorAgent(
promptAppend?: string promptAppend?: string
): AgentConfig { ): AgentConfig {
const prompt = buildSisyphusJuniorPrompt(promptAppend) const prompt = buildSisyphusJuniorPrompt(promptAppend)
const model = categoryConfig.model const model = categoryConfig.model ?? SISYPHUS_JUNIOR_DEFAULTS.model
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
const categoryPermission = categoryConfig.tools const categoryPermission = categoryConfig.tools
? Object.fromEntries( ? Object.fromEntries(

View File

@ -14,8 +14,6 @@ import {
categorizeTools, categorizeTools,
} from "./sisyphus-prompt-builder" } from "./sisyphus-prompt-builder"
const DEFAULT_MODEL = "anthropic/claude-opus-4-5"
const SISYPHUS_ROLE_SECTION = `<Role> const SISYPHUS_ROLE_SECTION = `<Role>
You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode. You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
@ -607,7 +605,7 @@ function buildDynamicSisyphusPrompt(
} }
export function createSisyphusAgent( export function createSisyphusAgent(
model: string = DEFAULT_MODEL, model: string,
availableAgents?: AvailableAgent[], availableAgents?: AvailableAgent[],
availableToolNames?: string[], availableToolNames?: string[],
availableSkills?: AvailableSkill[] availableSkills?: AvailableSkill[]
@ -637,4 +635,3 @@ export function createSisyphusAgent(
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
} }
export const sisyphusAgent = createSisyphusAgent()

View File

@ -1,6 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk" 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 * Agent category for grouping in Sisyphus prompt sections

View File

@ -2,12 +2,14 @@ import { describe, test, expect } from "bun:test"
import { createBuiltinAgents } from "./utils" import { createBuiltinAgents } from "./utils"
import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentConfig } from "@opencode-ai/sdk"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
describe("createBuiltinAgents with model overrides", () => { describe("createBuiltinAgents with model overrides", () => {
test("Sisyphus with default model has thinking config", () => { test("Sisyphus with default model has thinking config", () => {
// #given - no overrides // #given - no overrides, using systemDefaultModel
// #when // #when
const agents = createBuiltinAgents() const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then // #then
expect(agents.Sisyphus.model).toBe("anthropic/claude-opus-4-5") expect(agents.Sisyphus.model).toBe("anthropic/claude-opus-4-5")
@ -22,7 +24,7 @@ describe("createBuiltinAgents with model overrides", () => {
} }
// #when // #when
const agents = createBuiltinAgents([], overrides) const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then // #then
expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2") 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", () => { 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 // #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 // #then
expect(agents.oracle.model).toBe("openai/gpt-5.2") expect(agents.oracle.model).toBe("openai/gpt-5.2")
@ -63,7 +81,7 @@ describe("createBuiltinAgents with model overrides", () => {
} }
// #when // #when
const agents = createBuiltinAgents([], overrides) const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then // #then
expect(agents.oracle.model).toBe("anthropic/claude-sonnet-4") expect(agents.oracle.model).toBe("anthropic/claude-sonnet-4")
@ -79,7 +97,7 @@ describe("createBuiltinAgents with model overrides", () => {
} }
// #when // #when
const agents = createBuiltinAgents([], overrides) const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then // #then
expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2") 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", () => { describe("buildAgent with category and skills", () => {
const { buildAgent } = require("./utils") const { buildAgent } = require("./utils")
const TEST_MODEL = "anthropic/claude-opus-4-5"
test("agent with category inherits category settings", () => { test("agent with category inherits category settings", () => {
// #given // #given - agent factory that sets category but no model
const source = { const source = {
"test-agent": () => "test-agent": () =>
({ ({
@ -101,10 +120,11 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"]) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then // #then - DEFAULT_CATEGORIES only has temperature, not model
expect(agent.model).toBe("google/gemini-3-pro-preview") // Model remains undefined since neither factory nor category provides it
expect(agent.model).toBeUndefined()
expect(agent.temperature).toBe(0.7) expect(agent.temperature).toBe(0.7)
}) })
@ -120,7 +140,7 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"]) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then // #then
expect(agent.model).toBe("custom/model") expect(agent.model).toBe("custom/model")
@ -145,7 +165,7 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"], undefined, categories) const agent = buildAgent(source["test-agent"], TEST_MODEL, categories)
// #then // #then
expect(agent.model).toBe("openai/gpt-5.2") expect(agent.model).toBe("openai/gpt-5.2")
@ -164,7 +184,7 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"]) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then // #then
expect(agent.prompt).toContain("Role: Designer-Turned-Developer") expect(agent.prompt).toContain("Role: Designer-Turned-Developer")
@ -184,7 +204,7 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"]) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then // #then
expect(agent.prompt).toContain("Role: Designer-Turned-Developer") expect(agent.prompt).toContain("Role: Designer-Turned-Developer")
@ -204,7 +224,7 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"]) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then // #then
expect(agent.model).toBe("custom/model") expect(agent.model).toBe("custom/model")
@ -225,10 +245,10 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"]) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then // #then - DEFAULT_CATEGORIES["ultrabrain"] only has temperature, not model
expect(agent.model).toBe("openai/gpt-5.2") expect(agent.model).toBeUndefined()
expect(agent.temperature).toBe(0.1) expect(agent.temperature).toBe(0.1)
expect(agent.prompt).toContain("Role: Designer-Turned-Developer") expect(agent.prompt).toContain("Role: Designer-Turned-Developer")
expect(agent.prompt).toContain("Task description") expect(agent.prompt).toContain("Task description")
@ -246,9 +266,11 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"]) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then // #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.model).toBeUndefined()
expect(agent.prompt).toBe("Base prompt") expect(agent.prompt).toBe("Base prompt")
}) })
@ -265,7 +287,7 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"]) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then // #then
expect(agent.prompt).toContain("Role: Designer-Turned-Developer") expect(agent.prompt).toContain("Role: Designer-Turned-Developer")
@ -284,7 +306,7 @@ describe("buildAgent with category and skills", () => {
} }
// #when // #when
const agent = buildAgent(source["test-agent"]) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then // #then
expect(agent.prompt).toBe("Base prompt") expect(agent.prompt).toBe("Base prompt")

View File

@ -9,7 +9,7 @@ import { createFrontendUiUxEngineerAgent, FRONTEND_PROMPT_METADATA } from "./fro
import { createDocumentWriterAgent, DOCUMENT_WRITER_PROMPT_METADATA } from "./document-writer" import { createDocumentWriterAgent, DOCUMENT_WRITER_PROMPT_METADATA } from "./document-writer"
import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker" import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
import { createMetisAgent } from "./metis" import { createMetisAgent } from "./metis"
import { createOrchestratorSisyphusAgent, orchestratorSisyphusAgent } from "./orchestrator-sisyphus" import { createOrchestratorSisyphusAgent } from "./orchestrator-sisyphus"
import { createMomusAgent } from "./momus" import { createMomusAgent } from "./momus"
import type { AvailableAgent } from "./sisyphus-prompt-builder" import type { AvailableAgent } from "./sisyphus-prompt-builder"
import { deepMerge } from "../shared" import { deepMerge } from "../shared"
@ -28,7 +28,9 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
"multimodal-looker": createMultimodalLookerAgent, "multimodal-looker": createMultimodalLookerAgent,
"Metis (Plan Consultant)": createMetisAgent, "Metis (Plan Consultant)": createMetisAgent,
"Momus (Plan Reviewer)": createMomusAgent, "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( export function buildAgent(
source: AgentSource, source: AgentSource,
model?: string, model: string,
categories?: CategoriesConfig, categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig gitMasterConfig?: GitMasterConfig
): AgentConfig { ): AgentConfig {
@ -134,6 +136,10 @@ export function createBuiltinAgents(
categories?: CategoriesConfig, categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig gitMasterConfig?: GitMasterConfig
): Record<string, AgentConfig> { ): Record<string, AgentConfig> {
if (!systemDefaultModel) {
throw new Error("createBuiltinAgents requires systemDefaultModel")
}
const result: Record<string, AgentConfig> = {} const result: Record<string, AgentConfig> = {}
const availableAgents: AvailableAgent[] = [] const availableAgents: AvailableAgent[] = []
@ -149,7 +155,7 @@ export function createBuiltinAgents(
if (disabledAgents.includes(agentName)) continue if (disabledAgents.includes(agentName)) continue
const override = agentOverrides[agentName] const override = agentOverrides[agentName]
const model = override?.model const model = override?.model ?? systemDefaultModel
let config = buildAgent(source, model, mergedCategories, gitMasterConfig) let config = buildAgent(source, model, mergedCategories, gitMasterConfig)

View File

@ -200,85 +200,32 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
}) })
}) })
describe("generateOmoConfig - GitHub Copilot fallback", () => { describe("generateOmoConfig - v3 beta: no hardcoded models", () => {
test("frontend-ui-ux-engineer uses Copilot when no native providers", () => { test("generates minimal config with only $schema", () => {
// #given user has only Copilot (no Claude, ChatGPT, Gemini) // #given any install config
const config: InstallConfig = { const config: InstallConfig = {
hasClaude: false, hasClaude: true,
isMax20: false, isMax20: false,
hasChatGPT: false, hasChatGPT: true,
hasGemini: false, hasGemini: false,
hasCopilot: true, hasCopilot: false,
} }
// #when generating config // #when generating config
const result = generateOmoConfig(config) const result = generateOmoConfig(config)
// #then frontend-ui-ux-engineer should use Copilot Gemini // #then should only contain $schema, no agents or categories
const agents = result.agents as Record<string, { model?: string }> expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
expect(agents["frontend-ui-ux-engineer"]?.model).toBe("github-copilot/gemini-3-pro-preview") expect(result.agents).toBeUndefined()
expect(result.categories).toBeUndefined()
}) })
test("document-writer uses Copilot when no native providers", () => { test("does not include model fields regardless of provider config", () => {
// #given user has only Copilot // #given user has multiple providers
const config: InstallConfig = { const config: InstallConfig = {
hasClaude: false, hasClaude: true,
isMax20: false, isMax20: true,
hasChatGPT: false, hasChatGPT: true,
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<string, { model?: string }>
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<string, { model?: string }>
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<string, { model?: string }>
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,
hasGemini: true, hasGemini: true,
hasCopilot: true, hasCopilot: true,
} }
@ -286,46 +233,27 @@ describe("generateOmoConfig - GitHub Copilot fallback", () => {
// #when generating config // #when generating config
const result = generateOmoConfig(config) const result = generateOmoConfig(config)
// #then native Gemini should be used (NOT Copilot) // #then should not have agents or categories with model fields
const agents = result.agents as Record<string, { model?: string }> expect(result.agents).toBeUndefined()
expect(agents["frontend-ui-ux-engineer"]?.model).toBe("google/antigravity-gemini-3-pro-high") expect(result.categories).toBeUndefined()
}) })
test("native Claude takes priority over Copilot for frontend-ui-ux-engineer", () => { test("does not include model fields when no providers configured", () => {
// #given user has Claude and Copilot but no Gemini // #given user has no providers
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<string, { model?: string }>
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
const config: InstallConfig = { const config: InstallConfig = {
hasClaude: false, hasClaude: false,
isMax20: false, isMax20: false,
hasChatGPT: false, hasChatGPT: false,
hasGemini: false, hasGemini: false,
hasCopilot: true, hasCopilot: false,
} }
// #when generating config // #when generating config
const result = generateOmoConfig(config) const result = generateOmoConfig(config)
// #then categories should use Copilot models // #then should still only contain $schema
const categories = result.categories as Record<string, { model?: string }> expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
expect(categories?.["visual-engineering"]?.model).toBe("github-copilot/gemini-3-pro-preview") expect(result.agents).toBeUndefined()
expect(categories?.["artistry"]?.model).toBe("github-copilot/gemini-3-pro-preview") expect(result.categories).toBeUndefined()
expect(categories?.["writing"]?.model).toBe("github-copilot/gemini-3-flash-preview")
}) })
}) })

View File

@ -306,79 +306,13 @@ function deepMerge<T extends Record<string, unknown>>(target: T, source: Partial
return result return result
} }
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> { export function generateOmoConfig(_installConfig: InstallConfig): Record<string, unknown> {
// 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<string, unknown> = { const config: Record<string, unknown> = {
$schema: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json", $schema: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
} }
const agents: Record<string, Record<string, unknown>> = {}
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 return config
} }
@ -646,11 +580,9 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
} }
} }
interface OmoConfigData {
agents?: Record<string, { model?: string }>
}
export function detectCurrentConfig(): DetectedConfig { 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 = { const result: DetectedConfig = {
isInstalled: false, isInstalled: false,
hasClaude: true, hasClaude: true,
@ -678,53 +610,8 @@ export function detectCurrentConfig(): DetectedConfig {
return result return result
} }
// Gemini auth plugin detection still works via plugin presence
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth")) 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<OmoConfigData>(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 return result
} }

View File

@ -47,18 +47,11 @@ function formatConfigSummary(config: InstallConfig): string {
lines.push(color.dim("─".repeat(40))) lines.push(color.dim("─".repeat(40)))
lines.push("") 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("") lines.push("")
lines.push(` ${SYMBOLS.info} Agents will use your OpenCode default model`)
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : (config.hasCopilot ? "github-copilot/claude-opus-4.5" : "glm-4.7-free") lines.push(` ${SYMBOLS.bullet} Configure specific models in ${color.cyan("oh-my-opencode.json")} if needed`)
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)}`)
return lines.join("\n") return lines.join("\n")
} }

View File

@ -154,7 +154,7 @@ export const SisyphusAgentConfigSchema = z.object({
}) })
export const CategoryConfigSchema = z.object({ export const CategoryConfigSchema = z.object({
model: z.string(), model: z.string().optional(),
variant: z.string().optional(), variant: z.string().optional(),
temperature: z.number().min(0).max(2).optional(), temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(), top_p: z.number().min(0).max(1).optional(),

View File

@ -10,9 +10,9 @@ describe("Prometheus category config resolution", () => {
// #when // #when
const config = resolveCategoryConfig(categoryName) const config = resolveCategoryConfig(categoryName)
// #then // #then - DEFAULT_CATEGORIES only has temperature, not model
expect(config).toBeDefined() expect(config).toBeDefined()
expect(config?.model).toBe("openai/gpt-5.2") expect(config?.model).toBeUndefined()
expect(config?.temperature).toBe(0.1) expect(config?.temperature).toBe(0.1)
}) })
@ -23,9 +23,9 @@ describe("Prometheus category config resolution", () => {
// #when // #when
const config = resolveCategoryConfig(categoryName) const config = resolveCategoryConfig(categoryName)
// #then // #then - DEFAULT_CATEGORIES only has temperature, not model
expect(config).toBeDefined() expect(config).toBeDefined()
expect(config?.model).toBe("google/gemini-3-pro-preview") expect(config?.model).toBeUndefined()
expect(config?.temperature).toBe(0.7) expect(config?.temperature).toBe(0.7)
}) })
@ -71,9 +71,9 @@ describe("Prometheus category config resolution", () => {
// #when // #when
const config = resolveCategoryConfig(categoryName, userCategories) const config = resolveCategoryConfig(categoryName, userCategories)
// #then // #then - falls back to DEFAULT_CATEGORIES which has no model
expect(config).toBeDefined() expect(config).toBeDefined()
expect(config?.model).toBe("openai/gpt-5.2") expect(config?.model).toBeUndefined()
expect(config?.temperature).toBe(0.1) expect(config?.temperature).toBe(0.1)
}) })

View File

@ -200,12 +200,13 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
) )
: undefined; : 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 = { const prometheusBase = {
model: // Only include model if one was resolved - let OpenCode apply its own default if none
prometheusOverride?.model ?? ...(resolvedModel ? { model: resolvedModel } : {}),
categoryConfig?.model ??
defaultModel ??
"anthropic/claude-opus-4-5",
mode: "primary" as const, mode: "primary" as const,
prompt: PROMETHEUS_SYSTEM_PROMPT, prompt: PROMETHEUS_SYSTEM_PROMPT,
permission: PROMETHEUS_PERMISSION, permission: PROMETHEUS_PERMISSION,

View File

@ -26,3 +26,4 @@ export * from "./session-cursor"
export * from "./shell-env" export * from "./shell-env"
export * from "./system-directive" export * from "./system-directive"
export * from "./agent-tool-restrictions" export * from "./agent-tool-restrictions"
export * from "./model-resolver"

View File

@ -370,9 +370,9 @@ describe("shouldDeleteAgentConfig", () => {
test("returns true when all fields match category defaults", () => { test("returns true when all fields match category defaults", () => {
// #given: Config with fields matching category defaults // #given: Config with fields matching category defaults
// Note: DEFAULT_CATEGORIES only has temperature, not model
const config = { const config = {
category: "visual-engineering", category: "visual-engineering",
model: "google/gemini-3-pro-preview",
temperature: 0.7, temperature: 0.7,
} }

View File

@ -44,7 +44,19 @@ export const HOOK_NAME_MAP: Record<string, string> = {
"anthropic-auto-compact": "anthropic-context-window-limit-recovery", "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<string, string> = { export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
"google/gemini-3-pro-preview": "visual-engineering", "google/gemini-3-pro-preview": "visual-engineering",
"openai/gpt-5.2": "ultrabrain", "openai/gpt-5.2": "ultrabrain",

View File

@ -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);
});
});
});

View File

@ -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
);
}

View File

@ -185,31 +185,24 @@ The more explicit your prompt, the better the results.
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = { export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
"visual-engineering": { "visual-engineering": {
model: "google/gemini-3-pro-preview",
temperature: 0.7, temperature: 0.7,
}, },
ultrabrain: { ultrabrain: {
model: "openai/gpt-5.2",
temperature: 0.1, temperature: 0.1,
}, },
artistry: { artistry: {
model: "google/gemini-3-pro-preview",
temperature: 0.9, temperature: 0.9,
}, },
quick: { quick: {
model: "anthropic/claude-haiku-4-5",
temperature: 0.3, temperature: 0.3,
}, },
"most-capable": { "most-capable": {
model: "anthropic/claude-opus-4-5",
temperature: 0.1, temperature: 0.1,
}, },
writing: { writing: {
model: "google/gemini-3-flash-preview",
temperature: 0.5, temperature: 0.5,
}, },
general: { general: {
model: "anthropic/claude-sonnet-4-5",
temperature: 0.3, temperature: 0.3,
}, },
} }

View File

@ -1,60 +1,30 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, DELEGATE_TASK_DESCRIPTION } from "./constants" import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, DELEGATE_TASK_DESCRIPTION } from "./constants"
import { resolveCategoryConfig } from "./tools"
import type { CategoryConfig } from "../../config/schema" import type { CategoryConfig } from "../../config/schema"
function resolveCategoryConfig( // Test constants - systemDefaultModel is required by resolveCategoryConfig
categoryName: string, const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
options: {
userCategories?: Record<string, CategoryConfig>
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 }
}
describe("sisyphus-task", () => { describe("sisyphus-task", () => {
describe("DEFAULT_CATEGORIES", () => { describe("DEFAULT_CATEGORIES", () => {
test("visual-engineering category has gemini model", () => { test("visual-engineering category has temperature config only (model removed)", () => {
// #given // #given
const category = DEFAULT_CATEGORIES["visual-engineering"] const category = DEFAULT_CATEGORIES["visual-engineering"]
// #when / #then // #when / #then
expect(category).toBeDefined() expect(category).toBeDefined()
expect(category.model).toBe("google/gemini-3-pro-preview") expect(category.model).toBeUndefined()
expect(category.temperature).toBe(0.7) expect(category.temperature).toBe(0.7)
}) })
test("ultrabrain category has gpt model", () => { test("ultrabrain category has temperature config only (model removed)", () => {
// #given // #given
const category = DEFAULT_CATEGORIES["ultrabrain"] const category = DEFAULT_CATEGORIES["ultrabrain"]
// #when / #then // #when / #then
expect(category).toBeDefined() expect(category).toBeDefined()
expect(category.model).toBe("openai/gpt-5.2") expect(category.model).toBeUndefined()
expect(category.temperature).toBe(0.1) expect(category.temperature).toBe(0.1)
}) })
}) })
@ -120,26 +90,26 @@ describe("sisyphus-task", () => {
const categoryName = "unknown-category" const categoryName = "unknown-category"
// #when // #when
const result = resolveCategoryConfig(categoryName, {}) const result = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// #then // #then
expect(result).toBeNull() expect(result).toBeNull()
}) })
test("returns default config for builtin category", () => { test("returns systemDefaultModel for builtin category (categories no longer have default models)", () => {
// #given // #given
const categoryName = "visual-engineering" const categoryName = "visual-engineering"
// #when // #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).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") expect(result!.promptAppend).toContain("VISUAL/UI")
}) })
test("user config overrides default model", () => { test("user config overrides systemDefaultModel", () => {
// #given // #given
const categoryName = "visual-engineering" const categoryName = "visual-engineering"
const userCategories = { const userCategories = {
@ -147,7 +117,7 @@ describe("sisyphus-task", () => {
} }
// #when // #when
const result = resolveCategoryConfig(categoryName, { userCategories }) const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// #then // #then
expect(result).not.toBeNull() expect(result).not.toBeNull()
@ -165,7 +135,7 @@ describe("sisyphus-task", () => {
} }
// #when // #when
const result = resolveCategoryConfig(categoryName, { userCategories }) const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// #then // #then
expect(result).not.toBeNull() expect(result).not.toBeNull()
@ -185,7 +155,7 @@ describe("sisyphus-task", () => {
} }
// #when // #when
const result = resolveCategoryConfig(categoryName, { userCategories }) const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// #then // #then
expect(result).not.toBeNull() expect(result).not.toBeNull()
@ -205,66 +175,66 @@ describe("sisyphus-task", () => {
} }
// #when // #when
const result = resolveCategoryConfig(categoryName, { userCategories }) const result = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// #then // #then
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result!.config.temperature).toBe(0.3) expect(result!.config.temperature).toBe(0.3)
}) })
test("category default model takes precedence over parentModelString", () => { test("inheritedModel takes precedence over systemDefaultModel", () => {
// #given - builtin category has default model, parent model should NOT override it // #given - builtin category, parent model provided
const categoryName = "visual-engineering" const categoryName = "visual-engineering"
const parentModelString = "cliproxy/claude-opus-4-5" const inheritedModel = "cliproxy/claude-opus-4-5"
// #when // #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 // #then - inheritedModel wins over systemDefaultModel
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<string, CategoryConfig>
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
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result!.config.model).toBe("cliproxy/claude-opus-4-5") 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<string, CategoryConfig>
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 // #given
const categoryName = "visual-engineering" const categoryName = "visual-engineering"
const userCategories = { const userCategories = {
"visual-engineering": { model: "my-provider/my-model" }, "visual-engineering": { model: "my-provider/my-model" },
} }
const parentModelString = "cliproxy/claude-opus-4-5" const inheritedModel = "cliproxy/claude-opus-4-5"
// #when // #when
const result = resolveCategoryConfig(categoryName, { userCategories, parentModelString }) const result = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// #then // #then
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(result!.config.model).toBe("my-provider/my-model") 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 // #given
const categoryName = "visual-engineering" const categoryName = "visual-engineering"
// #when // #when
const result = resolveCategoryConfig(categoryName, {}) const result = resolveCategoryConfig(categoryName, { systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// #then // #then
expect(result).not.toBeNull() 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 = { const mockClient = {
app: { agents: async () => ({ data: [] }) }, app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
session: { session: {
create: async () => ({ data: { id: "test-session" } }), create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }), prompt: async () => ({ data: {} }),
@ -348,7 +318,7 @@ describe("sisyphus-task", () => {
const mockManager = { launch: async () => ({}) } const mockManager = { launch: async () => ({}) }
const mockClient = { const mockClient = {
app: { agents: async () => ({ data: [] }) }, app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
session: { session: {
create: async () => ({ data: { id: "test-session" } }), create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }), prompt: async () => ({ data: {} }),
@ -391,7 +361,7 @@ describe("sisyphus-task", () => {
const mockManager = { launch: async () => ({}) } const mockManager = { launch: async () => ({}) }
const mockClient = { const mockClient = {
app: { agents: async () => ({ data: [] }) }, app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
session: { session: {
create: async () => ({ data: { id: "test-session" } }), create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }), prompt: async () => ({ data: {} }),
@ -438,7 +408,7 @@ describe("sisyphus-task", () => {
const mockManager = { launch: async () => ({}) } const mockManager = { launch: async () => ({}) }
const mockClient = { const mockClient = {
app: { agents: async () => ({ data: [] }) }, app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
session: { session: {
get: async () => ({ data: { directory: "/project" } }), get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "test-session" } }), create: async () => ({ data: { id: "test-session" } }),
@ -513,7 +483,7 @@ describe("sisyphus-task", () => {
], ],
}), }),
}, },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
app: { app: {
agents: async () => ({ data: [] }), agents: async () => ({ data: [] }),
}, },
@ -571,7 +541,7 @@ describe("sisyphus-task", () => {
data: [], data: [],
}), }),
}, },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
} }
const tool = createDelegateTask({ const tool = createDelegateTask({
@ -623,7 +593,7 @@ describe("sisyphus-task", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
status: async () => ({ data: {} }), status: async () => ({ data: {} }),
}, },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
app: { app: {
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }), agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
}, },
@ -683,7 +653,7 @@ describe("sisyphus-task", () => {
}), }),
status: async () => ({ data: { "ses_sync_success": { type: "idle" } } }), status: async () => ({ data: { "ses_sync_success": { type: "idle" } } }),
}, },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
app: { app: {
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }), agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
}, },
@ -736,7 +706,7 @@ describe("sisyphus-task", () => {
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
status: async () => ({ data: {} }), status: async () => ({ data: {} }),
}, },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
app: { app: {
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }), agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
}, },
@ -790,7 +760,7 @@ describe("sisyphus-task", () => {
}), }),
status: async () => ({ data: {} }), status: async () => ({ data: {} }),
}, },
config: { get: async () => ({}) }, config: { get: async () => ({ model: SYSTEM_DEFAULT_MODEL }) },
app: { agents: async () => ({ data: [] }) }, app: { agents: async () => ({ data: [] }) },
} }
@ -879,47 +849,41 @@ describe("sisyphus-task", () => {
}) })
describe("modelInfo detection via resolveCategoryConfig", () => { describe("modelInfo detection via resolveCategoryConfig", () => {
test("when parentModelString exists but default model wins - modelInfo should report category-default", () => { test("systemDefaultModel is used when no userModel and no inheritedModel", () => {
// #given - Bug scenario: parentModelString is passed but userModel is undefined, // #given - builtin category, no user model, no inherited model
// and the resolution order is: userModel ?? parentModelString ?? defaultModel
// If parentModelString matches the resolved model, it's "inherited"
// If defaultModel matches, it's "category-default"
const categoryName = "ultrabrain" const categoryName = "ultrabrain"
const parentModelString = undefined
// #when // #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() expect(resolved).not.toBeNull()
const actualModel = resolved!.config.model const actualModel = resolved!.config.model
const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model expect(actualModel).toBe(SYSTEM_DEFAULT_MODEL)
expect(actualModel).toBe(defaultModel)
expect(actualModel).toBe("openai/gpt-5.2")
}) })
test("category default model takes precedence over parentModelString for builtin category", () => { test("inheritedModel takes precedence over systemDefaultModel for builtin category", () => {
// #given - builtin ultrabrain category has default model gpt-5.2 // #given - builtin ultrabrain category, inherited model from parent
const categoryName = "ultrabrain" const categoryName = "ultrabrain"
const parentModelString = "cliproxy/claude-opus-4-5" const inheritedModel = "cliproxy/claude-opus-4-5"
// #when // #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() expect(resolved).not.toBeNull()
const actualModel = resolved!.config.model 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 // #given
const categoryName = "ultrabrain" const categoryName = "ultrabrain"
const userCategories = { "ultrabrain": { model: "my-provider/custom-model" } } const userCategories = { "ultrabrain": { model: "my-provider/custom-model" } }
const parentModelString = "cliproxy/claude-opus-4-5" const inheritedModel = "cliproxy/claude-opus-4-5"
// #when // #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" // #then - actualModel should be userModel, type should be "user-defined"
expect(resolved).not.toBeNull() expect(resolved).not.toBeNull()
@ -931,28 +895,109 @@ describe("sisyphus-task", () => {
test("detection logic: actualModel comparison correctly identifies source", () => { test("detection logic: actualModel comparison correctly identifies source", () => {
// #given - This test verifies the fix for PR #770 bug // #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 categoryName = "ultrabrain"
const parentModelString = "cliproxy/claude-opus-4-5" const inheritedModel = "cliproxy/claude-opus-4-5"
const userCategories = { "ultrabrain": { model: "user/model" } } const userCategories = { "ultrabrain": { model: "user/model" } }
// #when - user model wins // #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 actualModel = resolved!.config.model
const userDefinedModel = userCategories[categoryName]?.model const userDefinedModel = userCategories[categoryName]?.model
const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model
// #then - detection should compare against actual resolved model // #then - detection should compare against actual resolved model
const detectedType = actualModel === userDefinedModel const detectedType = actualModel === userDefinedModel
? "user-defined" ? "user-defined"
: actualModel === parentModelString : actualModel === inheritedModel
? "inherited" ? "inherited"
: actualModel === defaultModel : actualModel === SYSTEM_DEFAULT_MODEL
? "category-default" ? "system-default"
: undefined : undefined
expect(detectedType).toBe("user-defined") 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<string, CategoryConfig>
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<string, CategoryConfig>
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", () => { test("systemDefaultModel is used when no other model is available", () => {
@ -969,19 +1014,5 @@ describe("sisyphus-task", () => {
expect(resolved).not.toBeNull() expect(resolved).not.toBeNull()
expect(resolved!.model).toBe(systemDefaultModel) 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<string, CategoryConfig>
// #when
const resolved = resolveCategoryConfig(categoryName, { userCategories })
// #then - model should be undefined
expect(resolved).not.toBeNull()
expect(resolved!.model).toBeUndefined()
})
}) })
}) })

View File

@ -11,7 +11,7 @@ import { discoverSkills } from "../../features/opencode-skill-loader"
import { getTaskToastManager } from "../../features/task-toast-manager" import { getTaskToastManager } from "../../features/task-toast-manager"
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state" import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
import { log, getAgentToolRestrictions } from "../../shared" import { log, getAgentToolRestrictions, resolveModel } from "../../shared"
type OpencodeClient = PluginInput["client"] type OpencodeClient = PluginInput["client"]
@ -107,15 +107,15 @@ type ToolContextWithMetadata = {
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
} }
function resolveCategoryConfig( export function resolveCategoryConfig(
categoryName: string, categoryName: string,
options: { options: {
userCategories?: CategoriesConfig userCategories?: CategoriesConfig
parentModelString?: string inheritedModel?: string
systemDefaultModel?: string systemDefaultModel: string
} }
): { config: CategoryConfig; promptAppend: string; model: string | undefined } | null { ): { config: CategoryConfig; promptAppend: string; model: string } | null {
const { userCategories, parentModelString, systemDefaultModel } = options const { userCategories, inheritedModel, systemDefaultModel } = options
const defaultConfig = DEFAULT_CATEGORIES[categoryName] const defaultConfig = DEFAULT_CATEGORIES[categoryName]
const userConfig = userCategories?.[categoryName] const userConfig = userCategories?.[categoryName]
const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? "" const defaultPromptAppend = CATEGORY_PROMPT_APPENDS[categoryName] ?? ""
@ -124,8 +124,12 @@ function resolveCategoryConfig(
return null return null
} }
// Model priority: user override > category default > parent model (fallback) > system default // Model priority: user override > inherited from parent > system default
const model = userConfig?.model ?? defaultConfig?.model ?? parentModelString ?? systemDefaultModel const model = resolveModel({
userModel: userConfig?.model,
inheritedModel,
systemDefault: systemDefaultModel,
})
const config: CategoryConfig = { const config: CategoryConfig = {
...defaultConfig, ...defaultConfig,
...userConfig, ...userConfig,
@ -421,16 +425,21 @@ ${textContent || "(No text output)"}`
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
let categoryPromptAppend: string | undefined let categoryPromptAppend: string | undefined
const parentModelString = parentModel const inheritedModel = parentModel
? `${parentModel.providerID}/${parentModel.modelID}` ? `${parentModel.providerID}/${parentModel.modelID}`
: undefined : undefined
let modelInfo: ModelFallbackInfo | undefined let modelInfo: ModelFallbackInfo | undefined
if (args.category) { 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, { const resolved = resolveCategoryConfig(args.category, {
userCategories, userCategories,
parentModelString, inheritedModel,
systemDefaultModel, systemDefaultModel,
}) })
if (!resolved) { if (!resolved) {
@ -440,11 +449,6 @@ ${textContent || "(No text output)"}`
// Determine model source by comparing against the actual resolved model // Determine model source by comparing against the actual resolved model
const actualModel = resolved.model const actualModel = resolved.model
const userDefinedModel = userCategories?.[args.category]?.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)) { if (!parseModelString(actualModel)) {
return `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").` 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: case userDefinedModel:
modelInfo = { model: actualModel, type: "user-defined" } modelInfo = { model: actualModel, type: "user-defined" }
break break
case parentModelString: case inheritedModel:
modelInfo = { model: actualModel, type: "inherited" } modelInfo = { model: actualModel, type: "inherited" }
break break
case categoryDefaultModel:
modelInfo = { model: actualModel, type: "category-default" }
break
case systemDefaultModel: case systemDefaultModel:
modelInfo = { model: actualModel, type: "system-default" } modelInfo = { model: actualModel, type: "system-default" }
break break