23b49c4a5c
* fix: expand override.category and explicit reasoningEffort priority (#1219) Two bugs fixed: 1. createBuiltinAgents(): override.category was never expanded into concrete config properties (model, variant, reasoningEffort, etc.). Added applyCategoryOverride() helper and applied it in the standard agent loop, Sisyphus path, and Atlas path. 2. Prometheus config-handler: reasoningEffort/textVerbosity/thinking from direct override now use explicit priority chains (direct > category) matching the existing variant pattern, instead of relying on spread ordering. Priority order (highest to lowest): 1. Direct override properties 2. Override category properties 3. Resolved variant from model fallback chain 4. Factory base defaults Closes #1219 * fix: use undefined check for thinking to allow explicit false
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
|
import { resolveCategoryConfig, createConfigHandler } from "./config-handler"
|
|
import type { CategoryConfig } from "../config/schema"
|
|
import type { OhMyOpenCodeConfig } from "../config"
|
|
|
|
mock.module("../agents", () => ({
|
|
createBuiltinAgents: async () => ({
|
|
sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" },
|
|
oracle: { name: "oracle", prompt: "test", mode: "subagent" },
|
|
}),
|
|
}))
|
|
|
|
mock.module("../agents/sisyphus-junior", () => ({
|
|
createSisyphusJuniorAgentWithOverrides: () => ({
|
|
name: "sisyphus-junior",
|
|
prompt: "test",
|
|
mode: "subagent",
|
|
}),
|
|
}))
|
|
|
|
mock.module("../features/claude-code-command-loader", () => ({
|
|
loadUserCommands: async () => ({}),
|
|
loadProjectCommands: async () => ({}),
|
|
loadOpencodeGlobalCommands: async () => ({}),
|
|
loadOpencodeProjectCommands: async () => ({}),
|
|
}))
|
|
|
|
mock.module("../features/builtin-commands", () => ({
|
|
loadBuiltinCommands: () => ({}),
|
|
}))
|
|
|
|
mock.module("../features/opencode-skill-loader", () => ({
|
|
loadUserSkills: async () => ({}),
|
|
loadProjectSkills: async () => ({}),
|
|
loadOpencodeGlobalSkills: async () => ({}),
|
|
loadOpencodeProjectSkills: async () => ({}),
|
|
discoverUserClaudeSkills: async () => [],
|
|
discoverProjectClaudeSkills: async () => [],
|
|
discoverOpencodeGlobalSkills: async () => [],
|
|
discoverOpencodeProjectSkills: async () => [],
|
|
}))
|
|
|
|
mock.module("../features/claude-code-agent-loader", () => ({
|
|
loadUserAgents: () => ({}),
|
|
loadProjectAgents: () => ({}),
|
|
}))
|
|
|
|
mock.module("../features/claude-code-mcp-loader", () => ({
|
|
loadMcpConfigs: async () => ({ servers: {} }),
|
|
}))
|
|
|
|
mock.module("../features/claude-code-plugin-loader", () => ({
|
|
loadAllPluginComponents: async () => ({
|
|
commands: {},
|
|
skills: {},
|
|
agents: {},
|
|
mcpServers: {},
|
|
hooksConfigs: [],
|
|
plugins: [],
|
|
errors: [],
|
|
}),
|
|
}))
|
|
|
|
mock.module("../mcp", () => ({
|
|
createBuiltinMcps: () => ({}),
|
|
}))
|
|
|
|
mock.module("../shared", () => ({
|
|
log: () => {},
|
|
fetchAvailableModels: async () => new Set(["anthropic/claude-opus-4-5"]),
|
|
readConnectedProvidersCache: () => null,
|
|
}))
|
|
|
|
mock.module("../shared/opencode-config-dir", () => ({
|
|
getOpenCodeConfigPaths: () => ({
|
|
global: "/tmp/.config/opencode",
|
|
project: "/tmp/.opencode",
|
|
}),
|
|
}))
|
|
|
|
mock.module("../shared/permission-compat", () => ({
|
|
migrateAgentConfig: (config: Record<string, unknown>) => config,
|
|
}))
|
|
|
|
mock.module("../shared/migration", () => ({
|
|
AGENT_NAME_MAP: {},
|
|
}))
|
|
|
|
mock.module("../shared/model-resolver", () => ({
|
|
resolveModelWithFallback: () => ({ model: "anthropic/claude-opus-4-5" }),
|
|
}))
|
|
|
|
mock.module("../shared/model-requirements", () => ({
|
|
AGENT_MODEL_REQUIREMENTS: {
|
|
sisyphus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
|
oracle: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
|
|
librarian: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
|
explore: { fallbackChain: [{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }] },
|
|
"multimodal-looker": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
|
|
prometheus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
|
metis: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
|
momus: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
|
|
atlas: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
|
},
|
|
CATEGORY_MODEL_REQUIREMENTS: {
|
|
"visual-engineering": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
|
|
ultrabrain: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex" }] },
|
|
artistry: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
|
|
quick: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" }] },
|
|
"unspecified-low": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
|
"unspecified-high": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
|
writing: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
|
|
},
|
|
}))
|
|
|
|
describe("Plan agent demote behavior", () => {
|
|
test("plan agent should be demoted to subagent mode when replacePlan is true", async () => {
|
|
// #given
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
replace_plan: true,
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {
|
|
plan: {
|
|
name: "plan",
|
|
mode: "primary",
|
|
prompt: "original plan prompt",
|
|
},
|
|
},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then
|
|
const agents = config.agent as Record<string, { mode?: string; name?: string }>
|
|
expect(agents.plan).toBeDefined()
|
|
expect(agents.plan.mode).toBe("subagent")
|
|
expect(agents.plan.name).toBe("plan")
|
|
})
|
|
|
|
test("prometheus should have mode 'all' to be callable via delegate_task", async () => {
|
|
// #given
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then
|
|
const agents = config.agent as Record<string, { mode?: string }>
|
|
expect(agents.prometheus).toBeDefined()
|
|
expect(agents.prometheus.mode).toBe("all")
|
|
})
|
|
})
|
|
|
|
describe("Prometheus category config resolution", () => {
|
|
test("resolves ultrabrain category config", () => {
|
|
// #given
|
|
const categoryName = "ultrabrain"
|
|
|
|
// #when
|
|
const config = resolveCategoryConfig(categoryName)
|
|
|
|
// #then
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("openai/gpt-5.2-codex")
|
|
expect(config?.variant).toBe("xhigh")
|
|
})
|
|
|
|
test("resolves visual-engineering category config", () => {
|
|
// #given
|
|
const categoryName = "visual-engineering"
|
|
|
|
// #when
|
|
const config = resolveCategoryConfig(categoryName)
|
|
|
|
// #then
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("google/gemini-3-pro")
|
|
})
|
|
|
|
test("user categories override default categories", () => {
|
|
// #given
|
|
const categoryName = "ultrabrain"
|
|
const userCategories: Record<string, CategoryConfig> = {
|
|
ultrabrain: {
|
|
model: "google/antigravity-claude-opus-4-5-thinking",
|
|
temperature: 0.1,
|
|
},
|
|
}
|
|
|
|
// #when
|
|
const config = resolveCategoryConfig(categoryName, userCategories)
|
|
|
|
// #then
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("google/antigravity-claude-opus-4-5-thinking")
|
|
expect(config?.temperature).toBe(0.1)
|
|
})
|
|
|
|
test("returns undefined for unknown category", () => {
|
|
// #given
|
|
const categoryName = "nonexistent-category"
|
|
|
|
// #when
|
|
const config = resolveCategoryConfig(categoryName)
|
|
|
|
// #then
|
|
expect(config).toBeUndefined()
|
|
})
|
|
|
|
test("falls back to default when user category has no entry", () => {
|
|
// #given
|
|
const categoryName = "ultrabrain"
|
|
const userCategories: Record<string, CategoryConfig> = {
|
|
"visual-engineering": {
|
|
model: "custom/visual-model",
|
|
},
|
|
}
|
|
|
|
// #when
|
|
const config = resolveCategoryConfig(categoryName, userCategories)
|
|
|
|
// #then - falls back to DEFAULT_CATEGORIES
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("openai/gpt-5.2-codex")
|
|
expect(config?.variant).toBe("xhigh")
|
|
})
|
|
|
|
test("preserves all category properties (temperature, top_p, tools, etc.)", () => {
|
|
// #given
|
|
const categoryName = "custom-category"
|
|
const userCategories: Record<string, CategoryConfig> = {
|
|
"custom-category": {
|
|
model: "test/model",
|
|
temperature: 0.5,
|
|
top_p: 0.9,
|
|
maxTokens: 32000,
|
|
tools: { tool1: true, tool2: false },
|
|
},
|
|
}
|
|
|
|
// #when
|
|
const config = resolveCategoryConfig(categoryName, userCategories)
|
|
|
|
// #then
|
|
expect(config).toBeDefined()
|
|
expect(config?.model).toBe("test/model")
|
|
expect(config?.temperature).toBe(0.5)
|
|
expect(config?.top_p).toBe(0.9)
|
|
expect(config?.maxTokens).toBe(32000)
|
|
expect(config?.tools).toEqual({ tool1: true, tool2: false })
|
|
})
|
|
})
|
|
|
|
describe("Prometheus direct override priority over category", () => {
|
|
test("direct reasoningEffort takes priority over category reasoningEffort", async () => {
|
|
// #given - category has reasoningEffort=xhigh, direct override says "low"
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
categories: {
|
|
"test-planning": {
|
|
model: "openai/gpt-5.2",
|
|
reasoningEffort: "xhigh",
|
|
},
|
|
},
|
|
agents: {
|
|
prometheus: {
|
|
category: "test-planning",
|
|
reasoningEffort: "low",
|
|
},
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then - direct override's reasoningEffort wins
|
|
const agents = config.agent as Record<string, { reasoningEffort?: string }>
|
|
expect(agents.prometheus).toBeDefined()
|
|
expect(agents.prometheus.reasoningEffort).toBe("low")
|
|
})
|
|
|
|
test("category reasoningEffort applied when no direct override", async () => {
|
|
// #given - category has reasoningEffort but no direct override
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
categories: {
|
|
"reasoning-cat": {
|
|
model: "openai/gpt-5.2",
|
|
reasoningEffort: "high",
|
|
},
|
|
},
|
|
agents: {
|
|
prometheus: {
|
|
category: "reasoning-cat",
|
|
},
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then - category's reasoningEffort is applied
|
|
const agents = config.agent as Record<string, { reasoningEffort?: string }>
|
|
expect(agents.prometheus).toBeDefined()
|
|
expect(agents.prometheus.reasoningEffort).toBe("high")
|
|
})
|
|
|
|
test("direct temperature takes priority over category temperature", async () => {
|
|
// #given
|
|
const pluginConfig: OhMyOpenCodeConfig = {
|
|
sisyphus_agent: {
|
|
planner_enabled: true,
|
|
},
|
|
categories: {
|
|
"temp-cat": {
|
|
model: "openai/gpt-5.2",
|
|
temperature: 0.8,
|
|
},
|
|
},
|
|
agents: {
|
|
prometheus: {
|
|
category: "temp-cat",
|
|
temperature: 0.1,
|
|
},
|
|
},
|
|
}
|
|
const config: Record<string, unknown> = {
|
|
model: "anthropic/claude-opus-4-5",
|
|
agent: {},
|
|
}
|
|
const handler = createConfigHandler({
|
|
ctx: { directory: "/tmp" },
|
|
pluginConfig,
|
|
modelCacheState: {
|
|
anthropicContext1MEnabled: false,
|
|
modelContextLimitsCache: new Map(),
|
|
},
|
|
})
|
|
|
|
// #when
|
|
await handler(config)
|
|
|
|
// #then - direct temperature wins over category
|
|
const agents = config.agent as Record<string, { temperature?: number }>
|
|
expect(agents.prometheus).toBeDefined()
|
|
expect(agents.prometheus.temperature).toBe(0.1)
|
|
})
|
|
})
|