Merge pull request #883 from code-yeongyu/fix/remove-hardcoded-model-defaults
fix: remove hardcoded model defaults from categories and agents
This commit is contained in:
@@ -185,31 +185,24 @@ The more explicit your prompt, the better the results.
|
||||
|
||||
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<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 }
|
||||
}
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
@@ -114,32 +84,77 @@ describe("sisyphus-task", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("category delegation config validation", () => {
|
||||
test("returns error when systemDefaultModel is not configured", async () => {
|
||||
// #given a mock client with no model in config
|
||||
const { createDelegateTask } = require("./tools")
|
||||
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) }, // No model configured
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
manager: mockManager,
|
||||
client: mockClient,
|
||||
})
|
||||
|
||||
const toolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "Sisyphus",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
// #when delegating with a category
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: "Test task",
|
||||
prompt: "Do something",
|
||||
category: "ultrabrain",
|
||||
run_in_background: false,
|
||||
skills: [],
|
||||
},
|
||||
toolContext
|
||||
)
|
||||
|
||||
// #then returns descriptive error message
|
||||
expect(result).toContain("oh-my-opencode requires a default model")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveCategoryConfig", () => {
|
||||
test("returns null for unknown category without user config", () => {
|
||||
// #given
|
||||
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 +162,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 +180,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 +200,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 +220,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<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
|
||||
// #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<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
|
||||
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 +304,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
@@ -348,7 +363,7 @@ describe("sisyphus-task", () => {
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
@@ -391,7 +406,7 @@ describe("sisyphus-task", () => {
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
@@ -438,7 +453,7 @@ describe("sisyphus-task", () => {
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
@@ -513,7 +528,7 @@ describe("sisyphus-task", () => {
|
||||
],
|
||||
}),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
app: {
|
||||
agents: async () => ({ data: [] }),
|
||||
},
|
||||
@@ -571,7 +586,7 @@ describe("sisyphus-task", () => {
|
||||
data: [],
|
||||
}),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
}
|
||||
|
||||
const tool = createDelegateTask({
|
||||
@@ -623,7 +638,7 @@ describe("sisyphus-task", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
@@ -683,7 +698,7 @@ describe("sisyphus-task", () => {
|
||||
}),
|
||||
status: async () => ({ data: { "ses_sync_success": { type: "idle" } } }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
@@ -736,7 +751,7 @@ describe("sisyphus-task", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
@@ -790,7 +805,7 @@ describe("sisyphus-task", () => {
|
||||
}),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
}
|
||||
|
||||
@@ -879,47 +894,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 +940,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<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", () => {
|
||||
@@ -969,19 +1059,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<string, CategoryConfig>
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then - model should be undefined
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.model).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, getOpenCodeConfigPaths } from "../../shared"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -107,15 +107,15 @@ type ToolContextWithMetadata = {
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => 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,
|
||||
@@ -411,7 +415,7 @@ ${textContent || "(No text output)"}`
|
||||
let systemDefaultModel: string | undefined
|
||||
try {
|
||||
const openCodeConfig = await client.config.get()
|
||||
systemDefaultModel = (openCodeConfig as { model?: string })?.model
|
||||
systemDefaultModel = (openCodeConfig as { data?: { model?: string } })?.data?.model
|
||||
} catch {
|
||||
// Config fetch failed, proceed without system default
|
||||
systemDefaultModel = undefined
|
||||
@@ -421,16 +425,27 @@ ${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) {
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
return (
|
||||
'oh-my-opencode requires a default model.\n\n' +
|
||||
`Add this to ${paths.configJsonc}:\n\n` +
|
||||
' "model": "anthropic/claude-sonnet-4-5"\n\n' +
|
||||
'(Replace with your preferred provider/model)'
|
||||
)
|
||||
}
|
||||
|
||||
const resolved = resolveCategoryConfig(args.category, {
|
||||
userCategories,
|
||||
parentModelString,
|
||||
inheritedModel,
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolved) {
|
||||
@@ -440,11 +455,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 +464,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
|
||||
|
||||
Reference in New Issue
Block a user