diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index d33f3718..e88f2b4e 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -600,6 +600,187 @@ describe("Prometheus direct override priority over category", () => { }) }) +describe("Plan agent model inheritance from prometheus", () => { + test("plan agent inherits all model-related settings from resolved prometheus config", async () => { + //#given - prometheus resolves to claude-opus-4-6 with model settings + spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({ + model: "anthropic/claude-opus-4-6", + provenance: "provider-fallback", + variant: "max", + }) + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + replace_plan: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: { + plan: { + name: "plan", + mode: "primary", + prompt: "original plan prompt", + }, + }, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then - plan inherits model and variant from prometheus, but NOT prompt + const agents = config.agent as Record + expect(agents.plan).toBeDefined() + expect(agents.plan.mode).toBe("subagent") + expect(agents.plan.model).toBe("anthropic/claude-opus-4-6") + expect(agents.plan.variant).toBe("max") + expect(agents.plan.prompt).toBeUndefined() + }) + + test("plan agent inherits temperature, reasoningEffort, and other model settings from prometheus", async () => { + //#given - prometheus configured with category that has temperature and reasoningEffort + spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({ + model: "openai/gpt-5.2", + provenance: "override", + variant: "high", + }) + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + replace_plan: true, + }, + agents: { + prometheus: { + model: "openai/gpt-5.2", + variant: "high", + temperature: 0.3, + top_p: 0.9, + maxTokens: 16000, + reasoningEffort: "high", + textVerbosity: "medium", + thinking: { type: "enabled", budgetTokens: 8000 }, + }, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then - plan inherits ALL model-related settings from resolved prometheus + const agents = config.agent as Record> + expect(agents.plan).toBeDefined() + expect(agents.plan.mode).toBe("subagent") + expect(agents.plan.model).toBe("openai/gpt-5.2") + expect(agents.plan.variant).toBe("high") + expect(agents.plan.temperature).toBe(0.3) + expect(agents.plan.top_p).toBe(0.9) + expect(agents.plan.maxTokens).toBe(16000) + expect(agents.plan.reasoningEffort).toBe("high") + expect(agents.plan.textVerbosity).toBe("medium") + expect(agents.plan.thinking).toEqual({ type: "enabled", budgetTokens: 8000 }) + }) + + test("plan agent user override takes priority over prometheus inherited settings", async () => { + //#given - prometheus resolves to opus, but user has plan override for gpt-5.2 + spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({ + model: "anthropic/claude-opus-4-6", + provenance: "provider-fallback", + variant: "max", + }) + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + replace_plan: true, + }, + agents: { + plan: { + model: "openai/gpt-5.2", + variant: "high", + temperature: 0.5, + }, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then - plan uses its own override, not prometheus settings + const agents = config.agent as Record> + expect(agents.plan.model).toBe("openai/gpt-5.2") + expect(agents.plan.variant).toBe("high") + expect(agents.plan.temperature).toBe(0.5) + }) + + test("plan agent does NOT inherit prompt, description, or color from prometheus", async () => { + //#given + spyOn(shared, "resolveModelPipeline" as any).mockReturnValue({ + model: "anthropic/claude-opus-4-6", + provenance: "provider-fallback", + variant: "max", + }) + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + replace_plan: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then - plan has model settings but NOT prompt/description/color + const agents = config.agent as Record> + expect(agents.plan.model).toBe("anthropic/claude-opus-4-6") + expect(agents.plan.prompt).toBeUndefined() + expect(agents.plan.description).toBeUndefined() + expect(agents.plan.color).toBeUndefined() + }) +}) + describe("Deadlock prevention - fetchAvailableModels must not receive client", () => { test("fetchAvailableModels should be called with undefined client to prevent deadlock during plugin init", async () => { // given - This test ensures we don't regress on issue #1301 diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 41adbaf2..ea7c2856 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -32,6 +32,7 @@ import { AGENT_NAME_MAP } from "../shared/migration"; import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements"; import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus"; import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"; +import { buildPlanDemoteConfig } from "./plan-model-inheritance"; import type { ModelCacheState } from "../plugin-state"; import type { CategoryConfig } from "../config/schema"; @@ -385,8 +386,10 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { : {}; const planDemoteConfig = shouldDemotePlan - ? { mode: "subagent" as const - } + ? buildPlanDemoteConfig( + agentConfig["prometheus"] as Record | undefined, + pluginConfig.agents?.plan as Record | undefined, + ) : undefined; config.agent = { diff --git a/src/plugin-handlers/plan-model-inheritance.test.ts b/src/plugin-handlers/plan-model-inheritance.test.ts new file mode 100644 index 00000000..3b68f0a1 --- /dev/null +++ b/src/plugin-handlers/plan-model-inheritance.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect } from "bun:test" +import { buildPlanDemoteConfig } from "./plan-model-inheritance" + +describe("buildPlanDemoteConfig", () => { + test("returns only mode when prometheus and plan override are both undefined", () => { + //#given + const prometheusConfig = undefined + const planOverride = undefined + + //#when + const result = buildPlanDemoteConfig(prometheusConfig, planOverride) + + //#then + expect(result).toEqual({ mode: "subagent" }) + }) + + test("extracts all model settings from prometheus config", () => { + //#given + const prometheusConfig = { + name: "prometheus", + model: "anthropic/claude-opus-4-6", + variant: "max", + mode: "all", + prompt: "You are Prometheus...", + permission: { edit: "allow" }, + description: "Plan agent (Prometheus)", + color: "#FF5722", + temperature: 0.1, + top_p: 0.95, + maxTokens: 32000, + thinking: { type: "enabled", budgetTokens: 10000 }, + reasoningEffort: "high", + textVerbosity: "medium", + providerOptions: { key: "value" }, + } + + //#when + const result = buildPlanDemoteConfig(prometheusConfig, undefined) + + //#then - picks model settings, NOT prompt/permission/description/color/name/mode + expect(result.mode).toBe("subagent") + expect(result.model).toBe("anthropic/claude-opus-4-6") + expect(result.variant).toBe("max") + expect(result.temperature).toBe(0.1) + expect(result.top_p).toBe(0.95) + expect(result.maxTokens).toBe(32000) + expect(result.thinking).toEqual({ type: "enabled", budgetTokens: 10000 }) + expect(result.reasoningEffort).toBe("high") + expect(result.textVerbosity).toBe("medium") + expect(result.providerOptions).toEqual({ key: "value" }) + expect(result.prompt).toBeUndefined() + expect(result.permission).toBeUndefined() + expect(result.description).toBeUndefined() + expect(result.color).toBeUndefined() + expect(result.name).toBeUndefined() + }) + + test("plan override takes priority over prometheus for all model settings", () => { + //#given + const prometheusConfig = { + model: "anthropic/claude-opus-4-6", + variant: "max", + temperature: 0.1, + reasoningEffort: "high", + } + const planOverride = { + model: "openai/gpt-5.2", + variant: "high", + temperature: 0.5, + reasoningEffort: "low", + } + + //#when + const result = buildPlanDemoteConfig(prometheusConfig, planOverride) + + //#then + expect(result.model).toBe("openai/gpt-5.2") + expect(result.variant).toBe("high") + expect(result.temperature).toBe(0.5) + expect(result.reasoningEffort).toBe("low") + }) + + test("falls back to prometheus when plan override has partial settings", () => { + //#given + const prometheusConfig = { + model: "anthropic/claude-opus-4-6", + variant: "max", + temperature: 0.1, + reasoningEffort: "high", + } + const planOverride = { + model: "openai/gpt-5.2", + } + + //#when + const result = buildPlanDemoteConfig(prometheusConfig, planOverride) + + //#then - plan model wins, rest inherits from prometheus + expect(result.model).toBe("openai/gpt-5.2") + expect(result.variant).toBe("max") + expect(result.temperature).toBe(0.1) + expect(result.reasoningEffort).toBe("high") + }) + + test("skips undefined values from both sources", () => { + //#given + const prometheusConfig = { + model: "anthropic/claude-opus-4-6", + } + + //#when + const result = buildPlanDemoteConfig(prometheusConfig, undefined) + + //#then + expect(result).toEqual({ mode: "subagent", model: "anthropic/claude-opus-4-6" }) + expect(Object.keys(result)).toEqual(["mode", "model"]) + }) +}) diff --git a/src/plugin-handlers/plan-model-inheritance.ts b/src/plugin-handlers/plan-model-inheritance.ts new file mode 100644 index 00000000..bb32483c --- /dev/null +++ b/src/plugin-handlers/plan-model-inheritance.ts @@ -0,0 +1,27 @@ +const MODEL_SETTINGS_KEYS = [ + "model", + "variant", + "temperature", + "top_p", + "maxTokens", + "thinking", + "reasoningEffort", + "textVerbosity", + "providerOptions", +] as const + +export function buildPlanDemoteConfig( + prometheusConfig: Record | undefined, + planOverride: Record | undefined, +): Record { + const modelSettings: Record = {} + + for (const key of MODEL_SETTINGS_KEYS) { + const value = planOverride?.[key] ?? prometheusConfig?.[key] + if (value !== undefined) { + modelSettings[key] = value + } + } + + return { mode: "subagent" as const, ...modelSettings } +}