diff --git a/.changeset/fix-per-agent-model-override.md b/.changeset/fix-per-agent-model-override.md new file mode 100644 index 00000000000..a58ec1106fd --- /dev/null +++ b/.changeset/fix-per-agent-model-override.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`). diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 895aad8d28e..879b0eb4c72 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -216,6 +216,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ get ready() { return modelStore.ready }, + // kilocode_change start - expose saved per-agent pick for auto-apply guard + saved(name: string) { + return modelStore.model[name] + }, + // kilocode_change end recent() { return modelStore.recent }, @@ -409,21 +414,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Automatically update model when agent changes createEffect(() => { + // kilocode_change start - wait for persistence load and skip when a per-agent pick already exists (#9050) + if (!model.ready) return const value = agent.current() - if (!value) return // kilocode_change - guard against empty agent list during org switch - if (value.model) { - if (isModelValid(value.model)) - model.set({ - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - else - toast.show({ - variant: "warning", - message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, - duration: 3000, - }) - } + if (!value) return // guard against empty agent list during org switch + if (!value.model) return + if (model.saved(value.name)) return + if (isModelValid(value.model)) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + else + toast.show({ + variant: "warning", + message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, + duration: 3000, + }) + // kilocode_change end }) const result = { diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts index 106651478c4..2f72403e929 100644 --- a/packages/opencode/test/kilocode/local-model.test.ts +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -481,3 +481,123 @@ describe("edge cases and error handling", () => { } }) }) + +// ── Regression tests for #9050 ────────────────────────────────────────────── +// The auto-apply createEffect in local.tsx previously clobbered user-selected +// per-agent models whenever it re-fired. The fix gates it on (a) modelStore.ready +// and (b) the absence of an existing saved entry for that agent. + +describe("#9050: auto-apply effect respects saved per-agent selection", () => { + test("13: fresh start — config model for active agent is applied after ready", async () => { + // plan is second; code (first) has no config model. Switch to plan post-init. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal() + try { + // Effect should not touch the code agent (no config model). + expect(local.model.saved("code")).toBeUndefined() + + local.agent.set("plan") + // Give the effect time to re-run now that agent.current() changed. + await Bun.sleep(50) + + // First-time application: no saved entry → config model applied and persisted. + expect(local.model.saved("plan")).toEqual(OPUS) + const data = await readModelJson() + expect(data.model.plan).toEqual(OPUS) + } finally { + dispose() + } + }) + + test("14: saved entry from model.json is preserved over a differing config model", async () => { + // Config says plan → OPUS; saved file says plan → SONNET. Saved must win. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal({ + prewrite: { + recent: [SONNET], + model: { plan: SONNET }, + favorite: [], + variant: {}, + }, + }) + try { + local.agent.set("plan") + await Bun.sleep(50) + + // The fix: effect sees an existing saved entry and leaves it alone. + expect(local.model.saved("plan")).toEqual(SONNET) + const data = await readModelJson() + expect(data.model.plan).toEqual(SONNET) + } finally { + dispose() + } + }) + + test("15: user override of a config-model agent sticks across agent switches", async () => { + // plan has config model OPUS; user picks SONNET for plan; switching away + // and back must not revert to OPUS. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal() + try { + local.agent.set("plan") + await Bun.sleep(50) + // Effect applied config default (no saved entry yet). + expect(local.model.saved("plan")).toEqual(OPUS) + + // User picks a different model. + local.model.set(SONNET, { recent: true }) + await Bun.sleep(50) + expect(local.model.saved("plan")).toEqual(SONNET) + + // Bounce agents. + local.agent.set("code") + await Bun.sleep(50) + local.agent.set("plan") + await Bun.sleep(50) + + // Saved pick survives. + expect(local.model.saved("plan")).toEqual(SONNET) + const data = await readModelJson() + expect(data.model.plan).toEqual(SONNET) + } finally { + dispose() + } + }) + + test("16: invalid config model still emits a warning toast", async () => { + // Ensure the fix didn't silence the existing invalid-model warning path. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { + name: "plan", + mode: "primary", + hidden: false, + model: { providerID: "nonexistent", modelID: "fake-model" }, + color: undefined, + permission: {}, + }, + ] + const { local, dispose } = await initLocal() + try { + toastMessages = [] + local.agent.set("plan") + await Bun.sleep(50) + + const warnings = toastMessages.filter((t) => t.variant === "warning" && t.message.includes("not valid")) + expect(warnings.length).toBeGreaterThan(0) + // And no bogus value was written. + expect(local.model.saved("plan")).toBeUndefined() + } finally { + dispose() + } + }) +})