Files
Kilo-Org_kilocode/packages/opencode/test/kilocode/local-model.test.ts
T
2026-06-09 11:53:08 +02:00

627 lines
19 KiB
TypeScript

// kilocode_change - new file
// Tests for per-agent model persistence in local.tsx (model.json read/write)
//
// 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"
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 mockWorkspace: string | undefined
let mockDirectory = "mock-project"
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 realProject = await import("@tui/context/project")
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,
session: [],
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 () => {},
},
},
event: {
on: () => () => {},
},
}),
}))
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 })
},
}
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("@opencode-ai/core/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 = {}
mockWorkspace = undefined
mockDirectory = "mock-project"
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<string, any> }): 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<any> {
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 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(() => {})
}
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 local.model.flush()
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 local.model.flush()
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 local.model.flush()
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 local.model.flush()
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 local.model.flush()
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 local.model.flush()
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 local.model.flush()
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: 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 {
local.agent.set("plan")
await Bun.sleep(50)
local.model.set(SONNET, { recent: true })
await local.model.flush()
expect(local.model.current()).toEqual(SONNET)
expect(local.model.saved("plan")).toBeUndefined()
const data = await readModelJson()
expect(data.model.plan).toBeUndefined()
expect(data.recent[0]).toEqual(SONNET)
} finally {
dispose()
}
})
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: {} },
]
const { local, dispose } = await initLocal({
prewrite: {
recent: [SONNET, OPUS],
model: { code: SONNET },
favorite: [],
variant: {},
},
})
try {
expect(local.model.recent()).toEqual([SONNET, OPUS])
expect(local.model.current()).toEqual(OPUS)
local.model.set(OPUS, { recent: true })
await local.model.flush()
const data = await readModelJson()
expect(data.model.code).toBeUndefined()
expect(data.recent[0]).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 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: configured agent defaults beat stale persisted picks", () => {
test("13: fresh start - config model resolves without persistence", async () => {
mockAgents = [
{ 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 {
await Bun.sleep(50)
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: configured model wins over stale persisted model", async () => {
mockAgents = [
{ 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: {
recent: [SONNET],
model: { plan: SONNET },
favorite: [],
variant: {},
},
})
try {
expect(local.model.saved("plan")).toEqual(SONNET)
expect(local.model.current()).toEqual(OPUS)
const data = await readModelJson()
expect(data.model.plan).toEqual(SONNET)
} finally {
dispose()
}
})
test("15: user override of a config-model agent resets after re-init", async () => {
mockAgents = [
{ 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 {
expect(local.model.current()).toEqual(OPUS)
local.model.set(SONNET, { recent: true })
await local.model.flush()
expect(local.model.current()).toEqual(SONNET)
expect(local.model.saved("plan")).toBeUndefined()
local.agent.set("code")
await Bun.sleep(50)
local.agent.set("plan")
await local.model.flush()
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 () => {
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)
expect(local.model.saved("plan")).toBeUndefined()
const data = await readModelJsonMaybe()
expect(data?.model?.plan).toBeUndefined()
} finally {
dispose()
}
})
})