diff --git a/src/features/task-toast-manager/manager.test.ts b/src/features/task-toast-manager/manager.test.ts index 9558fe8d..069f1851 100644 --- a/src/features/task-toast-manager/manager.test.ts +++ b/src/features/task-toast-manager/manager.test.ts @@ -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", () => { diff --git a/src/features/task-toast-manager/manager.ts b/src/features/task-toast-manager/manager.ts index 20086c6a..5cb5a7b1 100644 --- a/src/features/task-toast-manager/manager.ts +++ b/src/features/task-toast-manager/manager.ts @@ -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> = { + 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("") } diff --git a/src/features/task-toast-manager/types.ts b/src/features/task-toast-manager/types.ts index 5132e147..33d6f451 100644 --- a/src/features/task-toast-manager/types.ts +++ b/src/features/task-toast-manager/types.ts @@ -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 { diff --git a/src/tools/sisyphus-task/tools.test.ts b/src/tools/sisyphus-task/tools.test.ts index 87fddc39..7b3cae68 100644 --- a/src/tools/sisyphus-task/tools.test.ts +++ b/src/tools/sisyphus-task/tools.test.ts @@ -4,9 +4,13 @@ import type { CategoryConfig } from "../../config/schema" function resolveCategoryConfig( categoryName: string, - userCategories?: Record, - parentModelString?: string -): { config: CategoryConfig; promptAppend: string } | null { + options: { + userCategories?: Record + 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 + 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 + + // #when + const resolved = resolveCategoryConfig(categoryName, { userCategories }) + + // #then - model should be undefined + expect(resolved).not.toBeNull() + expect(resolved!.model).toBeUndefined() + }) }) }) diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index 413d27b0..b8a519ef 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -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 {