// 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. import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { createRoot } from "solid-js" import path from "path" import fs from "fs/promises" // ── Mutable mock state ────────────────────────────────────────────────────── const mockProviders = [ { id: "anthropic", name: "Anthropic", source: "env" as const, env: ["ANTHROPIC_API_KEY"], options: {}, models: { "claude-sonnet": { id: "claude-sonnet", providerID: "anthropic", name: "Claude Sonnet", capabilities: {}, }, "claude-opus": { id: "claude-opus", providerID: "anthropic", name: "Claude Opus", capabilities: {}, }, }, }, ] let mockAgents = [ { name: "code", mode: "primary" as const, hidden: false, model: undefined as any, color: undefined as any, permission: {}, }, { name: "plan", mode: "primary" as const, hidden: false, model: undefined as any, color: undefined as any, permission: {}, }, ] let mockConfig: { model?: string } = {} let mockArgs: { model?: string } = {} let toastMessages: Array<{ variant: string; message: string }> = [] // ── Mocks ──────────────────────────────────────────────────────────────────── // Bun's mock.module() is process-wide and permanent — it replaces the module // for ALL test files in the same runner process. To avoid breaking other tests // that import these modules, we spread the real exports and only override the // specific hooks this test needs. const realHelper = await import("@tui/context/helper") 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 realToast = await import("@tui/ui/toast") let capturedInit: (() => any) | undefined mock.module("@tui/context/helper", () => ({ ...realHelper, createSimpleContext: (input: { name: string; init: () => any }) => { capturedInit = input.init return { use: () => {}, provider: () => {} } }, })) mock.module("@tui/context/sync", () => ({ ...realSync, useSync: () => ({ data: { provider: mockProviders, provider_default: { anthropic: "claude-sonnet" }, agent: mockAgents, config: mockConfig, mcp: {}, }, }), })) mock.module("@tui/context/theme", () => ({ ...realTheme, useTheme: () => ({ theme: { primary: { buffer: new Float32Array(4) }, secondary: { buffer: new Float32Array(4) }, accent: { buffer: new Float32Array(4) }, success: { buffer: new Float32Array(4) }, warning: { buffer: new Float32Array(4) }, error: { buffer: new Float32Array(4) }, info: { buffer: new Float32Array(4) }, }, }), })) mock.module("@tui/context/args", () => ({ ...realArgs, useArgs: () => mockArgs, })) mock.module("@tui/context/sdk", () => ({ ...realSdk, useSDK: () => ({ client: { mcp: { disconnect: async () => {}, connect: async () => {}, }, }, }), })) const toastMock = { show: (opts: { variant: string; message: string; duration?: number }) => { toastMessages.push({ variant: opts.variant, message: opts.message }) }, } mock.module("@tui/ui/toast", () => ({ ...realToast, useToast: () => toastMock, })) // ── Import under test (after mocks) ──────────────────────────────────────── await import("@tui/context/local") // Import the real Global to get the state path (set by test preload via XDG_STATE_HOME) const { Global } = await import("@/global") const modelJsonPath = path.join(Global.Path.state, "model.json") // ── Helpers ───────────────────────────────────────────────────────────────── function resetMockState() { mockAgents = [ { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, { name: "plan", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, ] mockConfig = {} mockArgs = {} toastMessages = [] } function runInRoot(): { local: any; dispose: () => void } { let local: any let dispose!: () => void createRoot((d) => { dispose = d local = capturedInit!() }) return { local: local!, dispose } } async function initLocal(options?: { prewrite?: Record }): Promise<{ local: any; dispose: () => void }> { if (options?.prewrite) { await fs.writeFile(modelJsonPath, JSON.stringify(options.prewrite)) } if (!capturedInit) throw new Error("capturedInit not set — mock.module for helper failed") const { local, dispose } = runInRoot() // Poll until model.ready is true const deadline = Date.now() + 2000 while (!local.model.ready && Date.now() < deadline) { await Bun.sleep(10) } if (!local.model.ready) throw new Error("model.ready never became true within 2s") return { local, dispose } } async function readModelJson(): Promise { const until = Date.now() + 2000 while (true) { try { const text = await fs.readFile(modelJsonPath, "utf-8") return JSON.parse(text) } catch (err) { if (Date.now() >= until) throw err await Bun.sleep(10) } } } async function removeModelJson() { await fs.rm(modelJsonPath, { force: true }).catch(() => {}) } const SONNET = { providerID: "anthropic", modelID: "claude-sonnet" } const OPUS = { providerID: "anthropic", modelID: "claude-opus" } // ── Setup ─────────────────────────────────────────────────────────────────── beforeEach(async () => { resetMockState() await removeModelJson() }) afterEach(async () => { await removeModelJson() }) // ── Tests ─────────────────────────────────────────────────────────────────── describe("model.set persists per-agent model", () => { test("1: model.set for agent 'code' writes model.code to model.json", async () => { const { local, dispose } = await initLocal() try { local.model.set(OPUS, { recent: true }) await Bun.sleep(50) const data = await readModelJson() expect(data.model.code).toEqual(OPUS) expect(data.recent).toBeArrayOfSize(1) expect(data.recent[0]).toEqual(OPUS) } finally { dispose() } }) test("2: set code→sonnet, plan→opus persists both in model.json", async () => { const { local, dispose } = await initLocal() try { local.model.set(SONNET, { recent: true }) local.agent.set("plan") local.model.set(OPUS, { recent: true }) await Bun.sleep(50) const data = await readModelJson() expect(data.model.code).toEqual(SONNET) expect(data.model.plan).toEqual(OPUS) } finally { dispose() } }) test("3: save → dispose → re-init from same dir loads persisted data", async () => { // First session: set and save { const { local, dispose } = runInRoot() const deadline = Date.now() + 2000 while (!local.model.ready && Date.now() < deadline) await Bun.sleep(10) local.model.set(OPUS, { recent: true }) await Bun.sleep(50) dispose() } // Verify file was written correctly const fileData = await readModelJson() expect(fileData.model.code).toEqual(OPUS) expect(fileData.recent[0]).toEqual(OPUS) // Second session: re-init from same dir — verify file data was loaded { const { local, dispose } = runInRoot() const deadline = Date.now() + 2000 while (!local.model.ready && Date.now() < deadline) await Bun.sleep(10) // recent() reads the store directly (not a memo), so it reflects the loaded file expect(local.model.recent()).toEqual([OPUS]) // Setting a new model on top of loaded data should produce correct file local.model.set(SONNET, { recent: true }) await Bun.sleep(50) const data2 = await readModelJson() expect(data2.model.code).toEqual(SONNET) expect(data2.recent[0]).toEqual(SONNET) expect(data2.recent[1]).toEqual(OPUS) dispose() } }) }) describe("model.cycle and model.cycleFavorite", () => { test("4: cycle(1) advances to next recent model, persists per-agent", async () => { const { local, dispose } = await initLocal({ prewrite: { recent: [SONNET, OPUS], model: { code: SONNET }, favorite: [], variant: {}, }, }) try { local.model.cycle(1) await Bun.sleep(50) const data = await readModelJson() expect(data.model.code).toEqual(OPUS) } finally { dispose() } }) test("5: cycleFavorite(1) cycles to next favorite and persists", async () => { const { local, dispose } = await initLocal({ prewrite: { recent: [SONNET], model: { code: SONNET }, favorite: [SONNET, OPUS], variant: {}, }, }) try { local.model.cycleFavorite(1) await Bun.sleep(50) const data = await readModelJson() expect(data.model.code).toEqual(OPUS) expect(data.recent.some((r: any) => r.modelID === "claude-opus")).toBe(true) } finally { dispose() } }) }) describe("edge cases and error handling", () => { test("6: model.json without 'model' field does not crash, falls back", async () => { const { local, dispose } = await initLocal({ prewrite: { recent: [SONNET], favorite: [], variant: {}, }, }) try { expect(local.model.ready).toBe(true) // recent() reads the store directly and should have loaded expect(local.model.recent()).toEqual([SONNET]) // current() evaluates once at init before file loads — falls to provider default expect(local.model.current()).toBeDefined() } finally { dispose() } }) test("7: corrupt model.json does not crash, defaults to empty state", async () => { await fs.writeFile(modelJsonPath, "{{{not valid json") const { local, dispose } = runInRoot() const deadline = Date.now() + 2000 while (!local.model.ready && Date.now() < deadline) await Bun.sleep(10) try { expect(local.model.ready).toBe(true) // Should not have loaded any data expect(local.model.recent()).toEqual([]) expect(local.model.favorite()).toEqual([]) } finally { dispose() } }) test("8: pre-written model.code with invalid provider — valid models still load", async () => { const { local, dispose } = await initLocal({ prewrite: { recent: [SONNET], model: { code: { providerID: "nonexistent", modelID: "fake-model" } }, favorite: [], variant: {}, }, }) try { // recent loaded correctly despite invalid model.code expect(local.model.recent()).toEqual([SONNET]) // The initial current() evaluates before file loads, so it falls to default const current = local.model.current() expect(current).toBeDefined() expect(current!.providerID).toBe("anthropic") } finally { dispose() } }) test("9: model.set before ready still persists after ready", async () => { const { local, dispose } = runInRoot() // Immediately set before ready (ready is false because file load is async) const wasReadyBefore = local.model.ready local.model.set(OPUS, { recent: true }) // Wait for ready const deadline = Date.now() + 2000 while (!local.model.ready && Date.now() < deadline) await Bun.sleep(10) await Bun.sleep(50) try { expect(wasReadyBefore).toBe(false) expect(local.model.ready).toBe(true) const data = await readModelJson() expect(data.model.code).toEqual(OPUS) } finally { dispose() } }) 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. 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) const data = await readModelJson() expect(data.model.plan).toEqual(OPUS) } finally { dispose() } }) test("11: user override from file is retained after load", async () => { mockAgents = [ { name: "code", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, { name: "plan", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, ] const { local, dispose } = await initLocal({ prewrite: { recent: [SONNET, OPUS], model: { code: SONNET }, favorite: [], variant: {}, }, }) 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]) // 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) } finally { dispose() } }) test("12: switching agents without config models produces no warning toasts", async () => { const { local, dispose } = await initLocal() try { toastMessages = [] local.agent.set("plan") local.agent.set("code") await Bun.sleep(50) const warnings = toastMessages.filter((t) => t.message.includes("configured model")) expect(warnings).toHaveLength(0) } finally { dispose() } }) }) // ── 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() } }) })