Merge pull request #9625 from Kilo-Org/fix/model-switching-v2

Respect configured agent models on CLI restart
This commit is contained in:
Marian Alexandru Alecu
2026-04-29 09:28:53 +03:00
committed by GitHub
4 changed files with 154 additions and 99 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Respect configured agent models when reopening the CLI or switching projects.
@@ -241,11 +241,11 @@ export function Prompt(props: PromptProps) {
syncedKey = key
// Only set agent if it's a primary agent (not a subagent)
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
if (msg.agent && isPrimaryAgent) {
const primary = local.agent.list().find((x) => x.name === msg.agent)
if (msg.agent && primary) {
// Keep command line --agent if specified.
if (!args.agent) local.agent.set(msg.agent)
if (msg.model) {
if (msg.model && !primary.model && !args.agent) {
local.model.set(msg.model)
local.model.variant.set(msg.model.variant)
}
@@ -10,6 +10,7 @@ import { iife } from "@/util/iife"
import { useToast } from "../ui/toast"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { useProject } from "./project" // kilocode_change
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util"
@@ -26,6 +27,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
init: () => {
const sync = useSync()
const sdk = useSDK()
const project = useProject() // kilocode_change
const toast = useToast()
function isModelValid(model: { providerID: string; modelID: string }) {
@@ -110,13 +112,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const model = iife(() => {
const [modelStore, setModelStore] = createStore<{
ready: boolean
// kilocode_change start - persisted picks plus process-local overrides
model: Record<
string,
{
providerID: string
modelID: string
}
| {
providerID: string
modelID: string
}
| undefined
>
override: Record<
string,
| {
providerID: string
modelID: string
}
| undefined
>
// kilocode_change end
recent: {
providerID: string
modelID: string
@@ -129,6 +142,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}>({
ready: false,
model: {},
override: {}, // kilocode_change
recent: [],
favorite: [],
variant: {},
@@ -139,6 +153,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
pending: false,
}
// kilocode_change start - keep configured-agent selections process-local
const scope = createMemo(() => project.workspace.current() ?? project.instance.directory())
function key(name: string) {
return [scope(), name].join(":")
}
function clear(name: string) {
setModelStore("model", name, undefined)
}
function apply(name: string, value: { providerID: string; modelID: string }, persist: boolean) {
setModelStore("override", key(name), { ...value })
if (persist) {
setModelStore("model", name, { ...value })
return
}
clear(name)
}
// kilocode_change end
function save() {
if (!modelStore.ready) {
state.pending = true
@@ -209,13 +244,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const currentModel = createMemo(() => {
const a = agent.current()
if (!a) return fallbackModel() // kilocode_change - guard against empty agent list
// kilocode_change start - configured models beat stale persisted picks
return (
getFirstValidModel(
() => a && modelStore.model[a.name],
() => a && modelStore.override[key(a.name)],
() => a && a.model,
() => a && modelStore.model[a.name],
fallbackModel,
) ?? undefined
)
// kilocode_change end
})
return {
@@ -223,7 +261,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
get ready() {
return modelStore.ready
},
// kilocode_change start - expose saved per-agent pick for auto-apply guard
// kilocode_change start - expose persisted per-agent pick separately from overrides
saved(name: string) {
return modelStore.model[name]
},
@@ -264,7 +302,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!val) return
const a = agent.current()
if (!a) return
setModelStore("model", a.name, { ...val })
apply(a.name, val, !a.model) // kilocode_change
save() // kilocode_change
},
cycleFavorite(direction: 1 | -1) {
@@ -293,7 +331,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!next) return
const a = agent.current()
if (!a) return
setModelStore("model", a.name, { ...next })
apply(a.name, next, !a.model) // kilocode_change
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
setModelStore(
@@ -314,7 +352,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const a = agent.current()
if (!a) return
setModelStore("model", a.name, model)
apply(a.name, model, !a.model) // kilocode_change
if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
@@ -413,27 +451,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}
// Automatically update model when agent changes
// kilocode_change - validate configured agent model when agent changes
createEffect(() => {
// kilocode_change start - wait for persistence load and skip when a per-agent pick already exists (#9050)
// kilocode_change start - configured models resolve directly without persistence
if (!model.ready) return
const value = agent.current()
if (!value) return // guard against empty agent list during org switch
if (model.saved(value.name)) return
if (!value.model) return
if (isModelValid(value.model)) return
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
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,
})
}
})
const result = {
@@ -1,11 +1,9 @@
// kilocode_change - new file
// Tests for per-agent model persistence in local.tsx (model.json read/write)
//
// NOTE: Bun test uses solid-js/dist/server.js (SSR build) where createMemo
// evaluates once and never re-evaluates. The @opentui/solid preload plugin
// that swaps server→client build is only in the top-level bunfig preload,
// not the [test] section. Assertions therefore verify persistence via
// model.json file contents rather than model.current() reactive state.
// NOTE: The package test preload swaps Solid to the client build so effects
// and memos re-run. Tests assert both in-memory current model state and the
// persisted model.json contents.
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { createRoot } from "solid-js"
@@ -59,6 +57,8 @@ let mockAgents = [
let mockConfig: { model?: string } = {}
let mockArgs: { model?: string } = {}
let mockWorkspace: string | undefined
let mockDirectory = "mock-project"
let toastMessages: Array<{ variant: string; message: string }> = []
// ── Mocks ────────────────────────────────────────────────────────────────────
@@ -72,6 +72,7 @@ const realSync = await import("@tui/context/sync")
const realTheme = await import("@tui/context/theme")
const realArgs = await import("@tui/context/args")
const realSdk = await import("@tui/context/sdk")
const realProject = await import("@tui/context/project")
const realToast = await import("@tui/ui/toast")
let capturedInit: (() => any) | undefined
@@ -129,6 +130,18 @@ mock.module("@tui/context/sdk", () => ({
}),
}))
mock.module("@tui/context/project", () => ({
...realProject,
useProject: () => ({
workspace: {
current: () => mockWorkspace,
},
instance: {
directory: () => mockDirectory,
},
}),
}))
const toastMock = {
show: (opts: { variant: string; message: string; duration?: number }) => {
toastMessages.push({ variant: opts.variant, message: opts.message })
@@ -156,6 +169,8 @@ function resetMockState() {
]
mockConfig = {}
mockArgs = {}
mockWorkspace = undefined
mockDirectory = "mock-project"
toastMessages = []
}
@@ -201,6 +216,17 @@ async function readModelJson(): Promise<any> {
}
}
async function readModelJsonMaybe(): Promise<any | undefined> {
try {
const text = await fs.readFile(modelJsonPath, "utf-8")
return JSON.parse(text)
} catch (err) {
const code = err && typeof err === "object" && "code" in err ? err.code : undefined
if (code === "ENOENT") return undefined
throw err
}
}
async function removeModelJson() {
await fs.rm(modelJsonPath, { force: true }).catch(() => {})
}
@@ -413,31 +439,30 @@ describe("edge cases and error handling", () => {
}
})
test("10: agent with config model persists when applied", async () => {
// NOTE: In production, a createEffect in local.tsx auto-applies agent config
// models when switching agents. In bun test, createEffect is a no-op (SSR build).
// This test verifies the underlying persistence: when an agent has a config model,
// model.set (what the effect would call) correctly persists it.
test("10: model.set for configured agent is in-process only", async () => {
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 {
// Switch to "plan" agent which has config model OPUS
local.agent.set("plan")
// Simulate what createEffect would do: apply the agent's config model
local.model.set(OPUS)
await Bun.sleep(50)
local.model.set(SONNET, { recent: true })
await Bun.sleep(50)
expect(local.model.current()).toEqual(SONNET)
expect(local.model.saved("plan")).toBeUndefined()
const data = await readModelJson()
expect(data.model.plan).toEqual(OPUS)
expect(data.model.plan).toBeUndefined()
expect(data.recent[0]).toEqual(SONNET)
} finally {
dispose()
}
})
test("11: user override from file is retained after load", async () => {
test("11: stale persisted model is ignored when agent has config model", async () => {
mockAgents = [
{ name: "code", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} },
{ name: "plan", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} },
@@ -451,16 +476,14 @@ describe("edge cases and error handling", () => {
},
})
try {
// The file had model.code = SONNET, recent = [SONNET, OPUS]
// Verify file data was loaded into the store
expect(local.model.recent()).toEqual([SONNET, OPUS])
expect(local.model.current()).toEqual(OPUS)
// Setting a new model should layer on top of the loaded state
local.model.set(OPUS, { recent: true })
await Bun.sleep(50)
const data = await readModelJson()
// model.code should now be OPUS (the new set)
expect(data.model.code).toEqual(OPUS)
expect(data.model.code).toBeUndefined()
expect(data.recent[0]).toEqual(OPUS)
} finally {
dispose()
}
@@ -482,41 +505,33 @@ 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.
// ── Regression tests for #9050 follow-up ────────────────────────────────────
// Configured agent defaults should win on restart/project switch, while manual
// selections for those agents remain active only in the current process.
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.
describe("#9050: configured agent defaults beat stale persisted picks", () => {
test("13: fresh start - config model resolves without persistence", async () => {
mockAgents = [
{ name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} },
{ name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} },
{ name: "code", mode: "primary", hidden: false, model: undefined, 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)
expect(local.model.current()).toEqual(OPUS)
expect(local.model.saved("plan")).toBeUndefined()
const data = await readModelJsonMaybe()
expect(data?.model?.plan).toBeUndefined()
} 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.
test("14: configured model wins over stale persisted model", async () => {
mockAgents = [
{ name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} },
{ name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} },
{ name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} },
]
const { local, dispose } = await initLocal({
prewrite: {
@@ -527,11 +542,8 @@ describe("#9050: auto-apply effect respects saved per-agent selection", () => {
},
})
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)
expect(local.model.current()).toEqual(OPUS)
const data = await readModelJson()
expect(data.model.plan).toEqual(SONNET)
} finally {
@@ -539,42 +551,48 @@ describe("#9050: auto-apply effect respects saved per-agent selection", () => {
}
})
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.
test("15: user override of a config-model agent resets after re-init", async () => {
mockAgents = [
{ name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} },
{ name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} },
{ name: "code", mode: "primary", hidden: false, model: undefined, 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)
{
const { local, dispose } = await initLocal()
try {
expect(local.model.current()).toEqual(OPUS)
// Bounce agents.
local.agent.set("code")
await Bun.sleep(50)
local.agent.set("plan")
await Bun.sleep(50)
local.model.set(SONNET, { recent: true })
await Bun.sleep(50)
expect(local.model.current()).toEqual(SONNET)
expect(local.model.saved("plan")).toBeUndefined()
// Saved pick survives.
expect(local.model.saved("plan")).toEqual(SONNET)
const data = await readModelJson()
expect(data.model.plan).toEqual(SONNET)
} finally {
dispose()
local.agent.set("code")
await Bun.sleep(50)
local.agent.set("plan")
await Bun.sleep(50)
expect(local.model.current()).toEqual(SONNET)
const data = await readModelJson()
expect(data.model.plan).toBeUndefined()
expect(data.recent[0]).toEqual(SONNET)
} finally {
dispose()
}
}
{
const { local, dispose } = await initLocal()
try {
expect(local.model.current()).toEqual(OPUS)
expect(local.model.saved("plan")).toBeUndefined()
} 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: {} },
{
@@ -594,8 +612,9 @@ describe("#9050: auto-apply effect respects saved per-agent selection", () => {
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()
const data = await readModelJsonMaybe()
expect(data?.model?.plan).toBeUndefined()
} finally {
dispose()
}