From 73c0d924ed993e802ad2721b4f4dfbadacd43ab4 Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Tue, 24 Feb 2026 11:49:16 -0500 Subject: [PATCH] fix(cli): save and load per-agent model selection --- .../src/cli/cmd/tui/context/local.tsx | 5 +- .../test/kilocode/local-model.test.ts | 450 ++++++++++++++++++ 2 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/kilocode/local-model.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index d93079f12a4..9fd6bb4f1bc 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -132,6 +132,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } state.pending = false Filesystem.writeJson(filePath, { + model: modelStore.model, // kilocode_change recent: modelStore.recent, favorite: modelStore.favorite, variant: modelStore.variant, @@ -142,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .then((x: any) => { if (Array.isArray(x.recent)) setModelStore("recent", x.recent) if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite) + if (typeof x.model === "object" && x.model !== null) setModelStore("model", x.model) // kilocode_change if (typeof x.variant === "object" && x.variant !== null) setModelStore("variant", x.variant) }) .catch(() => {}) @@ -241,6 +243,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const val = recent[next] if (!val) return setModelStore("model", agent.current().name, { ...val }) + save() // kilocode_change }, cycleFavorite(direction: 1 | -1) { const favorites = modelStore.favorite.filter((item) => isModelValid(item)) @@ -293,8 +296,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ "recent", uniq.map((x) => ({ providerID: x.providerID, modelID: x.modelID })), ) - save() } + save() // kilocode_change }) }, toggleFavorite(model: { providerID: string; modelID: string }) { diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts new file mode 100644 index 00000000000..e0bfed8099d --- /dev/null +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -0,0 +1,450 @@ +// 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 ──────────────────────────────────────────────────────────────────── +// Only mock TUI context modules that are specific to the CLI layer and not +// used by other test files. Do NOT mock widely-used modules like @/global, +// @/provider/provider, or @opentui/core — they persist process-wide in Bun +// and would break other test files. + +let capturedInit: (() => any) | undefined + +mock.module("@tui/context/helper", () => ({ + createSimpleContext: (input: { name: string; init: () => any }) => { + capturedInit = input.init + return { use: () => {}, provider: () => {} } + }, +})) + +mock.module("@tui/context/sync", () => ({ + useSync: () => ({ + data: { + provider: mockProviders, + provider_default: { anthropic: "claude-sonnet" }, + agent: mockAgents, + config: mockConfig, + mcp: {}, + }, + }), +})) + +mock.module("@tui/context/theme", () => ({ + 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", () => ({ + useArgs: () => mockArgs, +})) + +mock.module("@tui/context/sdk", () => ({ + 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", () => ({ + 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 text = await fs.readFile(modelJsonPath, "utf-8") + return JSON.parse(text) +} + +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() + } + }) +})