refactor(sisyphus-task): use dynamic model fallback from OpenCode config
- Remove hardcoded "anthropic/claude-sonnet-4-5" fallback - Fetch systemDefaultModel from client.config.get() at tool boundary - Add 'category-default' and 'system-default' fallback types - Use switch(actualModel) for cleaner type detection - Add guard clauses and fail-loud validation for invalid models - Wrap config fetch in try/catch for graceful degradation - Update toast messages with typed suffixMap
This commit is contained in:
parent
4a892a9809
commit
8a9ebe1012
@ -144,14 +144,35 @@ describe("TaskToastManager", () => {
|
||||
})
|
||||
|
||||
describe("model fallback info in toast message", () => {
|
||||
test("should display warning when model falls back to default", () => {
|
||||
// #given - a task with model fallback to default
|
||||
test("should display warning when model falls back to category-default", () => {
|
||||
// #given - a task with model fallback to category-default
|
||||
const task = {
|
||||
id: "task_1",
|
||||
description: "Task with default model",
|
||||
description: "Task with category default model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "anthropic/claude-sonnet-4-5", type: "default" as const },
|
||||
modelInfo: { model: "google/gemini-3-pro-preview", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should show warning with model info
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("⚠️")
|
||||
expect(call.body.message).toContain("google/gemini-3-pro-preview")
|
||||
expect(call.body.message).toContain("(category default)")
|
||||
})
|
||||
|
||||
test("should display warning when model falls back to system-default", () => {
|
||||
// #given - a task with model fallback to system-default
|
||||
const task = {
|
||||
id: "task_1b",
|
||||
description: "Task with system default model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "anthropic/claude-sonnet-4-5", type: "system-default" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
@ -162,7 +183,7 @@ describe("TaskToastManager", () => {
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("⚠️")
|
||||
expect(call.body.message).toContain("anthropic/claude-sonnet-4-5")
|
||||
expect(call.body.message).toContain("(default)")
|
||||
expect(call.body.message).toContain("(system default)")
|
||||
})
|
||||
|
||||
test("should display warning when model is inherited from parent", () => {
|
||||
@ -204,7 +225,8 @@ describe("TaskToastManager", () => {
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).not.toContain("⚠️ Model:")
|
||||
expect(call.body.message).not.toContain("(inherited)")
|
||||
expect(call.body.message).not.toContain("(default)")
|
||||
expect(call.body.message).not.toContain("(category default)")
|
||||
expect(call.body.message).not.toContain("(system default)")
|
||||
})
|
||||
|
||||
test("should not display model info when not provided", () => {
|
||||
|
||||
@ -110,7 +110,12 @@ export class TaskToastManager {
|
||||
// Show model fallback warning for the new task if applicable
|
||||
if (newTask.modelInfo && newTask.modelInfo.type !== "user-defined") {
|
||||
const icon = "⚠️"
|
||||
const suffix = newTask.modelInfo.type === "inherited" ? " (inherited)" : " (default)"
|
||||
const suffixMap: Partial<Record<ModelFallbackInfo["type"], string>> = {
|
||||
inherited: " (inherited)",
|
||||
"category-default": " (category default)",
|
||||
"system-default": " (system default)",
|
||||
}
|
||||
const suffix = suffixMap[newTask.modelInfo.type] ?? ""
|
||||
lines.push(`${icon} Model: ${newTask.modelInfo.model}${suffix}`)
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ export type TaskStatus = "running" | "queued" | "completed" | "error"
|
||||
|
||||
export interface ModelFallbackInfo {
|
||||
model: string
|
||||
type: "user-defined" | "inherited" | "default"
|
||||
type: "user-defined" | "inherited" | "category-default" | "system-default"
|
||||
}
|
||||
|
||||
export interface TrackedTask {
|
||||
|
||||
@ -4,9 +4,13 @@ import type { CategoryConfig } from "../../config/schema"
|
||||
|
||||
function resolveCategoryConfig(
|
||||
categoryName: string,
|
||||
userCategories?: Record<string, CategoryConfig>,
|
||||
parentModelString?: string
|
||||
): { config: CategoryConfig; promptAppend: string } | null {
|
||||
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] ?? ""
|
||||
@ -15,10 +19,11 @@ function resolveCategoryConfig(
|
||||
return null
|
||||
}
|
||||
|
||||
const model = userConfig?.model ?? parentModelString ?? defaultConfig?.model ?? systemDefaultModel
|
||||
const config: CategoryConfig = {
|
||||
...defaultConfig,
|
||||
...userConfig,
|
||||
model: userConfig?.model ?? parentModelString ?? defaultConfig?.model ?? "anthropic/claude-sonnet-4-5",
|
||||
model,
|
||||
}
|
||||
|
||||
let promptAppend = defaultPromptAppend
|
||||
@ -28,7 +33,7 @@ function resolveCategoryConfig(
|
||||
: userConfig.prompt_append
|
||||
}
|
||||
|
||||
return { config, promptAppend }
|
||||
return { config, promptAppend, model }
|
||||
}
|
||||
|
||||
describe("sisyphus-task", () => {
|
||||
@ -115,7 +120,7 @@ describe("sisyphus-task", () => {
|
||||
const categoryName = "unknown-category"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName)
|
||||
const result = resolveCategoryConfig(categoryName, {})
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
@ -126,7 +131,7 @@ describe("sisyphus-task", () => {
|
||||
const categoryName = "visual-engineering"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName)
|
||||
const result = resolveCategoryConfig(categoryName, {})
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@ -142,7 +147,7 @@ describe("sisyphus-task", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, userCategories)
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@ -160,7 +165,7 @@ describe("sisyphus-task", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, userCategories)
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@ -180,7 +185,7 @@ describe("sisyphus-task", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, userCategories)
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@ -200,7 +205,7 @@ describe("sisyphus-task", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, userCategories)
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@ -213,7 +218,7 @@ describe("sisyphus-task", () => {
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, undefined, parentModelString)
|
||||
const result = resolveCategoryConfig(categoryName, { parentModelString })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@ -229,7 +234,7 @@ describe("sisyphus-task", () => {
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, userCategories, parentModelString)
|
||||
const result = resolveCategoryConfig(categoryName, { userCategories, parentModelString })
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@ -241,7 +246,7 @@ describe("sisyphus-task", () => {
|
||||
const categoryName = "visual-engineering"
|
||||
|
||||
// #when
|
||||
const result = resolveCategoryConfig(categoryName, undefined, undefined)
|
||||
const result = resolveCategoryConfig(categoryName, {})
|
||||
|
||||
// #then
|
||||
expect(result).not.toBeNull()
|
||||
@ -270,6 +275,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
@ -327,6 +333,7 @@ describe("sisyphus-task", () => {
|
||||
const mockManager = { launch: async () => ({}) }
|
||||
const mockClient = {
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({}) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "test-session" } }),
|
||||
prompt: async () => ({ data: {} }),
|
||||
@ -394,6 +401,7 @@ describe("sisyphus-task", () => {
|
||||
],
|
||||
}),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [] }),
|
||||
},
|
||||
@ -451,6 +459,7 @@ describe("sisyphus-task", () => {
|
||||
data: [],
|
||||
}),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
}
|
||||
|
||||
const tool = createSisyphusTask({
|
||||
@ -502,6 +511,7 @@ describe("sisyphus-task", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
@ -560,6 +570,7 @@ describe("sisyphus-task", () => {
|
||||
}),
|
||||
status: async () => ({ data: { "ses_sync_success": { type: "idle" } } }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
@ -612,6 +623,7 @@ describe("sisyphus-task", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: {
|
||||
agents: async () => ({ data: [{ name: "ultrabrain", mode: "subagent" }] }),
|
||||
},
|
||||
@ -666,6 +678,7 @@ describe("sisyphus-task", () => {
|
||||
}),
|
||||
status: async () => ({ data: {} }),
|
||||
},
|
||||
config: { get: async () => ({}) },
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
}
|
||||
|
||||
@ -707,7 +720,7 @@ describe("sisyphus-task", () => {
|
||||
const { buildSystemContent } = require("./tools")
|
||||
|
||||
// #when
|
||||
const result = buildSystemContent({ skills: undefined, categoryPromptAppend: undefined })
|
||||
const result = buildSystemContent({ skillContent: undefined, categoryPromptAppend: undefined })
|
||||
|
||||
// #then
|
||||
expect(result).toBeUndefined()
|
||||
@ -754,18 +767,18 @@ describe("sisyphus-task", () => {
|
||||
})
|
||||
|
||||
describe("modelInfo detection via resolveCategoryConfig", () => {
|
||||
test("when parentModelString exists but default model wins - modelInfo should report default", () => {
|
||||
test("when parentModelString exists but default model wins - modelInfo should report category-default", () => {
|
||||
// #given - Bug scenario: parentModelString is passed but userModel is undefined,
|
||||
// and the resolution order is: userModel ?? parentModelString ?? defaultModel
|
||||
// If parentModelString matches the resolved model, it's "inherited"
|
||||
// If defaultModel matches, it's "default"
|
||||
// If defaultModel matches, it's "category-default"
|
||||
const categoryName = "ultrabrain"
|
||||
const parentModelString = undefined
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, undefined, parentModelString)
|
||||
const resolved = resolveCategoryConfig(categoryName, { parentModelString })
|
||||
|
||||
// #then - actualModel should be defaultModel, type should be "default"
|
||||
// #then - actualModel should be defaultModel, type should be "category-default"
|
||||
expect(resolved).not.toBeNull()
|
||||
const actualModel = resolved!.config.model
|
||||
const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model
|
||||
@ -779,7 +792,7 @@ describe("sisyphus-task", () => {
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, undefined, parentModelString)
|
||||
const resolved = resolveCategoryConfig(categoryName, { parentModelString })
|
||||
|
||||
// #then - actualModel should be parentModelString, type should be "inherited"
|
||||
expect(resolved).not.toBeNull()
|
||||
@ -794,7 +807,7 @@ describe("sisyphus-task", () => {
|
||||
const parentModelString = "cliproxy/claude-opus-4-5"
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, userCategories, parentModelString)
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, parentModelString })
|
||||
|
||||
// #then - actualModel should be userModel, type should be "user-defined"
|
||||
expect(resolved).not.toBeNull()
|
||||
@ -812,7 +825,7 @@ describe("sisyphus-task", () => {
|
||||
const userCategories = { "ultrabrain": { model: "user/model" } }
|
||||
|
||||
// #when - user model wins
|
||||
const resolved = resolveCategoryConfig(categoryName, userCategories, parentModelString)
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, parentModelString })
|
||||
const actualModel = resolved!.config.model
|
||||
const userDefinedModel = userCategories[categoryName]?.model
|
||||
const defaultModel = DEFAULT_CATEGORIES[categoryName]?.model
|
||||
@ -823,11 +836,40 @@ describe("sisyphus-task", () => {
|
||||
: actualModel === parentModelString
|
||||
? "inherited"
|
||||
: actualModel === defaultModel
|
||||
? "default"
|
||||
? "category-default"
|
||||
: undefined
|
||||
|
||||
expect(detectedType).toBe("user-defined")
|
||||
expect(actualModel).not.toBe(parentModelString)
|
||||
})
|
||||
|
||||
test("systemDefaultModel is used when no other model is available", () => {
|
||||
// #given - custom category with no model, but systemDefaultModel is set
|
||||
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>
|
||||
const systemDefaultModel = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories, systemDefaultModel })
|
||||
|
||||
// #then - actualModel should be systemDefaultModel
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.model).toBe(systemDefaultModel)
|
||||
})
|
||||
|
||||
test("model is undefined when no model available anywhere", () => {
|
||||
// #given - custom category with no model, no systemDefaultModel
|
||||
const categoryName = "my-custom"
|
||||
// Using type assertion since we're testing fallback behavior for categories without model
|
||||
const userCategories = { "my-custom": { temperature: 0.5 } } as unknown as Record<string, CategoryConfig>
|
||||
|
||||
// #when
|
||||
const resolved = resolveCategoryConfig(categoryName, { userCategories })
|
||||
|
||||
// #then - model should be undefined
|
||||
expect(resolved).not.toBeNull()
|
||||
expect(resolved!.model).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -61,9 +61,13 @@ type ToolContextWithMetadata = {
|
||||
|
||||
function resolveCategoryConfig(
|
||||
categoryName: string,
|
||||
userCategories?: CategoriesConfig,
|
||||
parentModelString?: string
|
||||
): { config: CategoryConfig; promptAppend: string } | null {
|
||||
options: {
|
||||
userCategories?: CategoriesConfig
|
||||
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] ?? ""
|
||||
@ -72,12 +76,13 @@ function resolveCategoryConfig(
|
||||
return null
|
||||
}
|
||||
|
||||
// Model priority: user override > parent model (inherit) > category default > hardcoded fallback
|
||||
// Model priority: user override > parent model (inherit) > category default > system default
|
||||
// Parent model takes precedence over category default so custom providers work out-of-box
|
||||
const model = userConfig?.model ?? parentModelString ?? defaultConfig?.model ?? systemDefaultModel
|
||||
const config: CategoryConfig = {
|
||||
...defaultConfig,
|
||||
...userConfig,
|
||||
model: userConfig?.model ?? parentModelString ?? defaultConfig?.model ?? "anthropic/claude-sonnet-4-5",
|
||||
model,
|
||||
}
|
||||
|
||||
let promptAppend = defaultPromptAppend
|
||||
@ -87,7 +92,7 @@ function resolveCategoryConfig(
|
||||
: userConfig.prompt_append
|
||||
}
|
||||
|
||||
return { config, promptAppend }
|
||||
return { config, promptAppend, model }
|
||||
}
|
||||
|
||||
export interface SisyphusTaskToolOptions {
|
||||
@ -329,6 +334,16 @@ ${textContent || "(No text output)"}`
|
||||
return `❌ Invalid arguments: Must provide either category or subagent_type.`
|
||||
}
|
||||
|
||||
// Fetch OpenCode config at boundary to get system default model
|
||||
let systemDefaultModel: string | undefined
|
||||
try {
|
||||
const openCodeConfig = await client.config.get()
|
||||
systemDefaultModel = (openCodeConfig as { model?: string })?.model
|
||||
} catch {
|
||||
// Config fetch failed, proceed without system default
|
||||
systemDefaultModel = undefined
|
||||
}
|
||||
|
||||
let agentToUse: string
|
||||
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
|
||||
let categoryPromptAppend: string | undefined
|
||||
@ -340,26 +355,45 @@ ${textContent || "(No text output)"}`
|
||||
let modelInfo: ModelFallbackInfo | undefined
|
||||
|
||||
if (args.category) {
|
||||
const resolved = resolveCategoryConfig(args.category, userCategories, parentModelString)
|
||||
const resolved = resolveCategoryConfig(args.category, {
|
||||
userCategories,
|
||||
parentModelString,
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolved) {
|
||||
return `❌ Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`
|
||||
}
|
||||
|
||||
// Determine model source by comparing against the actual resolved model
|
||||
const actualModel = resolved.config.model
|
||||
const actualModel = resolved.model
|
||||
const userDefinedModel = userCategories?.[args.category]?.model
|
||||
const defaultModel = DEFAULT_CATEGORIES[args.category]?.model
|
||||
const categoryDefaultModel = DEFAULT_CATEGORIES[args.category]?.model
|
||||
|
||||
if (actualModel === userDefinedModel) {
|
||||
modelInfo = { model: actualModel, type: "user-defined" }
|
||||
} else if (actualModel === parentModelString) {
|
||||
modelInfo = { model: actualModel, type: "inherited" }
|
||||
} else if (actualModel === defaultModel) {
|
||||
modelInfo = { model: actualModel, type: "default" }
|
||||
if (!actualModel) {
|
||||
return `❌ No model configured. Set a model in your OpenCode config, plugin config, or use a category with a default model.`
|
||||
}
|
||||
|
||||
if (!parseModelString(actualModel)) {
|
||||
return `❌ Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
|
||||
}
|
||||
|
||||
switch (actualModel) {
|
||||
case userDefinedModel:
|
||||
modelInfo = { model: actualModel, type: "user-defined" }
|
||||
break
|
||||
case parentModelString:
|
||||
modelInfo = { model: actualModel, type: "inherited" }
|
||||
break
|
||||
case categoryDefaultModel:
|
||||
modelInfo = { model: actualModel, type: "category-default" }
|
||||
break
|
||||
case systemDefaultModel:
|
||||
modelInfo = { model: actualModel, type: "system-default" }
|
||||
break
|
||||
}
|
||||
|
||||
agentToUse = SISYPHUS_JUNIOR_AGENT
|
||||
const parsedModel = parseModelString(resolved.config.model)
|
||||
const parsedModel = parseModelString(actualModel)
|
||||
categoryModel = parsedModel
|
||||
? (resolved.config.variant
|
||||
? { ...parsedModel, variant: resolved.config.variant }
|
||||
@ -367,10 +401,11 @@ ${textContent || "(No text output)"}`
|
||||
: undefined
|
||||
categoryPromptAppend = resolved.promptAppend || undefined
|
||||
} else {
|
||||
agentToUse = args.subagent_type!.trim()
|
||||
if (!agentToUse) {
|
||||
if (!args.subagent_type?.trim()) {
|
||||
return `❌ Agent name cannot be empty.`
|
||||
}
|
||||
const agentName = args.subagent_type.trim()
|
||||
agentToUse = agentName
|
||||
|
||||
// Validate agent exists and is callable (not a primary agent)
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user