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:
commit
4ee7deae14
@ -2060,10 +2060,7 @@
|
|||||||
"prompt_append": {
|
"prompt_append": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"required": [
|
|
||||||
"model"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"claude_code": {
|
"claude_code": {
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
|
|||||||
import { createBuiltinMcps } from "../mcp";
|
import { createBuiltinMcps } from "../mcp";
|
||||||
import type { OhMyOpenCodeConfig } from "../config";
|
import type { OhMyOpenCodeConfig } from "../config";
|
||||||
import { log } from "../shared";
|
import { log } from "../shared";
|
||||||
|
import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir";
|
||||||
import { migrateAgentConfig } from "../shared/permission-compat";
|
import { migrateAgentConfig } from "../shared/permission-compat";
|
||||||
import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus-prompt";
|
import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus-prompt";
|
||||||
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants";
|
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants";
|
||||||
@ -99,6 +100,16 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
|||||||
log(`Plugin load errors`, { errors: pluginComponents.errors });
|
log(`Plugin load errors`, { errors: pluginComponents.errors });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(config.model as string | undefined)?.trim()) {
|
||||||
|
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||||
|
throw new Error(
|
||||||
|
'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 builtinAgents = createBuiltinAgents(
|
const builtinAgents = createBuiltinAgents(
|
||||||
pluginConfig.disabled_agents,
|
pluginConfig.disabled_agents,
|
||||||
pluginConfig.agents,
|
pluginConfig.agents,
|
||||||
@ -200,12 +211,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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
101
src/shared/model-resolver.test.ts
Normal file
101
src/shared/model-resolver.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
35
src/shared/model-resolver.ts
Normal file
35
src/shared/model-resolver.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -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", () => {
|
describe("resolveCategoryConfig", () => {
|
||||||
test("returns null for unknown category without user config", () => {
|
test("returns null for unknown category without user config", () => {
|
||||||
// #given
|
// #given
|
||||||
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 +162,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 +180,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 +200,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 +220,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 +304,7 @@ describe("sisyphus-task", () => {
|
|||||||
|
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
app: { agents: async () => ({ data: [] }) },
|
app: { agents: async () => ({ data: [] }) },
|
||||||
config: { get: async () => ({}) },
|
config: { get: async () => ({ data: { 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 +363,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 () => ({ data: { 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 +406,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 () => ({ data: { 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 +453,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 () => ({ data: { 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 +528,7 @@ describe("sisyphus-task", () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
config: { get: async () => ({}) },
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||||
app: {
|
app: {
|
||||||
agents: async () => ({ data: [] }),
|
agents: async () => ({ data: [] }),
|
||||||
},
|
},
|
||||||
@ -571,7 +586,7 @@ describe("sisyphus-task", () => {
|
|||||||
data: [],
|
data: [],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
config: { get: async () => ({}) },
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||||
}
|
}
|
||||||
|
|
||||||
const tool = createDelegateTask({
|
const tool = createDelegateTask({
|
||||||
@ -623,7 +638,7 @@ describe("sisyphus-task", () => {
|
|||||||
messages: async () => ({ data: [] }),
|
messages: async () => ({ data: [] }),
|
||||||
status: async () => ({ data: {} }),
|
status: async () => ({ data: {} }),
|
||||||
},
|
},
|
||||||
config: { get: async () => ({}) },
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||||
app: {
|
app: {
|
||||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||||
},
|
},
|
||||||
@ -683,7 +698,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 () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||||
app: {
|
app: {
|
||||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||||
},
|
},
|
||||||
@ -736,7 +751,7 @@ describe("sisyphus-task", () => {
|
|||||||
messages: async () => ({ data: [] }),
|
messages: async () => ({ data: [] }),
|
||||||
status: async () => ({ data: {} }),
|
status: async () => ({ data: {} }),
|
||||||
},
|
},
|
||||||
config: { get: async () => ({}) },
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||||
app: {
|
app: {
|
||||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||||
},
|
},
|
||||||
@ -790,7 +805,7 @@ describe("sisyphus-task", () => {
|
|||||||
}),
|
}),
|
||||||
status: async () => ({ data: {} }),
|
status: async () => ({ data: {} }),
|
||||||
},
|
},
|
||||||
config: { get: async () => ({}) },
|
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
|
||||||
app: { agents: async () => ({ data: [] }) },
|
app: { agents: async () => ({ data: [] }) },
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -879,47 +894,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 +940,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 +1059,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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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, getOpenCodeConfigPaths } 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,
|
||||||
@ -411,7 +415,7 @@ ${textContent || "(No text output)"}`
|
|||||||
let systemDefaultModel: string | undefined
|
let systemDefaultModel: string | undefined
|
||||||
try {
|
try {
|
||||||
const openCodeConfig = await client.config.get()
|
const openCodeConfig = await client.config.get()
|
||||||
systemDefaultModel = (openCodeConfig as { model?: string })?.model
|
systemDefaultModel = (openCodeConfig as { data?: { model?: string } })?.data?.model
|
||||||
} catch {
|
} catch {
|
||||||
// Config fetch failed, proceed without system default
|
// Config fetch failed, proceed without system default
|
||||||
systemDefaultModel = undefined
|
systemDefaultModel = undefined
|
||||||
@ -421,16 +425,27 @@ ${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) {
|
||||||
|
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, {
|
const resolved = resolveCategoryConfig(args.category, {
|
||||||
userCategories,
|
userCategories,
|
||||||
parentModelString,
|
inheritedModel,
|
||||||
systemDefaultModel,
|
systemDefaultModel,
|
||||||
})
|
})
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
@ -440,11 +455,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 +464,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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user