Merge pull request #9118 from Kilo-Org/kilocode-model-switcher

fix(cli): preserve per-agent model overrides
This commit is contained in:
Marian Alexandru Alecu
2026-04-17 14:43:48 +03:00
committed by GitHub
3 changed files with 148 additions and 14 deletions
@@ -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`).
@@ -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 = {
@@ -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()
}
})
})