From f85c3be6b7bc39c6fbb75d3612a9fe972df3e5bf Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 24 Sep 2026 03:13:55 +0800 Subject: [PATCH 1/2] fix(tui): store a copy of the selected model, not the caller's object `selectModel()` stored the given model object in the model store by reference. A Solid store keeps the first object set at a path and merges later sets into it, so when that first object was a conversation's recorded model from the sync store, opening a second conversation wrote its model into the first conversation's record, and returning to the first selected the second's model. Store a copy instead. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- packages/tui/src/context/local.tsx | 5 +- .../context/model-store-aliasing.test.tsx | 210 ++++++++++++++++++ .../tui/test/context/stale-zen-cycle.test.tsx | 3 +- 3 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 packages/tui/test/context/model-store-aliasing.test.tsx diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 647a69d8d..ab9634e35 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -729,7 +729,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const a = agent.current() if (!a) return - setModelStore("model", a.name, model) + // Store a copy: the store keeps the first object set here by reference and merges later + // selections into it, so storing a caller's object (e.g. a message's recorded model from + // the sync store) would rewrite that record on every later selection. + setModelStore("model", a.name, { providerID: model.providerID, modelID: model.modelID }) if (options?.explicit) setExplicitPicks(pickKey(model), true) if (options?.recent) setRecent(recentModels(model, modelStore.recent)) // A picker-driven selection, as opposed to session restore or programmatic migration — diff --git a/packages/tui/test/context/model-store-aliasing.test.tsx b/packages/tui/test/context/model-store-aliasing.test.tsx new file mode 100644 index 000000000..24af6296d --- /dev/null +++ b/packages/tui/test/context/model-store-aliasing.test.tsx @@ -0,0 +1,210 @@ +// Opening a conversation hands the model store that conversation's recorded model, an object owned +// by the sync store's message record. The model store must never write into it: a Solid store keeps +// the first object set at a path by reference and merges later sets into it, so storing the record +// itself let opening a second conversation rewrite the first one's recorded model. +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { createEventSource, createFetch, directory, json } from "../fixture/tui-sdk" + +async function waitUntil(predicate: () => boolean, timeout = 2_000) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +// Always pass copies: the model store merges a newly set object into the one already there, +// so handing it these constants directly would let one call overwrite another's constant. +const STALE_ZEN = { providerID: "opencode", modelID: "model-a" } +const BASE = { providerID: "altimate-free", modelID: "altimate-base" } +const OWN = { providerID: "anthropic", modelID: "own-model" } + +function makeModel(id: string, providerID = "opencode") { + return { + id, + providerID, + name: id, + family: providerID, + status: "active", + capabilities: {}, + cost: { input: 0, output: 0 }, + limit: { context: 65_536, output: 4_096 }, + } +} + +async function mount(agentModel?: { providerID: string; modelID: string }) { + const [ + { KVProvider }, + { LocalProvider, useLocal }, + { ArgsProvider }, + { ThemeProvider }, + { ToastProvider }, + { SDKProvider }, + { ProjectProvider }, + { SyncProvider, useSync }, + { RouteProvider }, + { ExitProvider }, + { TuiConfigProvider }, + ] = await Promise.all([ + import("../../src/context/kv"), + import("../../src/context/local"), + import("../../src/context/args"), + import("../../src/context/theme"), + import("../../src/ui/toast"), + import("../../src/context/sdk"), + import("../../src/context/project"), + import("../../src/context/sync"), + import("../../src/context/route"), + import("../../src/context/exit"), + import("../../src/config"), + ]) + + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + // The only persisted history is a stale keyless-Zen pick followed by the user's own model. + await Bun.write(path.join(state, "model.json"), JSON.stringify({ recent: [STALE_ZEN, OWN] })) + + const zenProvider = { + id: "opencode", + name: "Zen", + options: { apiKey: "public" }, + models: { "model-a": makeModel("model-a") }, + env: [], + } + const baseProvider = { + id: "altimate-free", + name: "Altimate Base", + models: { "altimate-base": makeModel("altimate-base", "altimate-free") }, + env: [], + } + const ownProvider = { + id: "anthropic", + name: "Anthropic", + models: { "own-model": makeModel("own-model", "anthropic") }, + env: [], + } + const providers = [zenProvider, baseProvider, ownProvider] + const agent = { + name: "build", + mode: "primary" as const, + hidden: false, + permission: {}, + options: {}, + ...(agentModel ? { model: agentModel } : {}), + } + const inner = createFetch((url) => { + if (url.pathname === "/instance/dispose") return json({}) + if (url.pathname === "/config/providers") return json({ providers, default: {} }) + if (url.pathname === "/provider") + return json({ all: providers, default: {}, connected: ["opencode", "altimate-free", "anthropic"] }) + if (url.pathname === "/agent") return json([agent, { ...agent, name: "plan" }]) + if (url.pathname === "/project/proj_test/directories") return json([]) + return undefined + }) + const source = createEventSource() + + let localAccessor: ReturnType | undefined + let syncAccessor: ReturnType | undefined + function Capture() { + localAccessor = useLocal() + syncAccessor = useSync() + return null + } + + const app = await testRender(() => ( + + {}}> + + + + + + + + + + + + + + + + + + + + + + + + )) + await app.renderOnce() + await waitUntil(() => localAccessor !== undefined && localAccessor.model.ready) + const local = localAccessor! + + return { + local, + sync: syncAccessor!, + emit: source.emit, + async cleanup() { + app.renderer.destroy() + await local.model.persisted().catch(() => {}) + await tmp[Symbol.asyncDispose]() + }, + } +} + + +const SESSION_A = "ses_alias_a" +const SESSION_B = "ses_alias_b" + +function userMessage(sessionID: string, id: string, model: { providerID: string; modelID: string }) { + return { + directory, + project: "proj_test", + payload: { + id: `evt_${id}`, + type: "message.updated", + properties: { + sessionID, + info: { id, sessionID, role: "user", agent: "build", model: { ...model }, time: { created: 1 } }, + }, + }, + } as never +} + +test("switching conversations keeps each conversation's recorded model and restores it on return", async () => { + const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME + await using isolatedState = await tmpdir() + process.env.OPENCODE_TEST_STATE_HOME = isolatedState.path + const { local, sync, emit, cleanup } = await mount() + try { + await waitUntil(() => local.model.ready) + emit(userMessage(SESSION_A, "msg_a", OWN)) + emit(userMessage(SESSION_B, "msg_b", BASE)) + await waitUntil(() => !!sync.data.message[SESSION_A]?.[0] && !!sync.data.message[SESSION_B]?.[0]) + const recordedA = () => sync.data.message[SESSION_A]![0] as { model: { providerID: string; modelID: string } } + const recordedB = () => sync.data.message[SESSION_B]![0] as { model: { providerID: string; modelID: string } } + + // Exactly what the prompt does on opening a conversation: restore its last user message's model. + local.model.restoreSession(recordedA().model) // open conversation A + local.model.restoreSession(recordedB().model) // open conversation B + + // Back to conversation A: the prompt restores A's recorded model again. + local.model.restoreSession(recordedA().model) + expect({ ...recordedA().model }).toMatchObject(OWN) // A was recorded on OWN + expect(local.model.current()).toMatchObject(OWN) + } finally { + await cleanup() + if (originalStateHome === undefined) delete process.env.OPENCODE_TEST_STATE_HOME + else process.env.OPENCODE_TEST_STATE_HOME = originalStateHome + } +}) diff --git a/packages/tui/test/context/stale-zen-cycle.test.tsx b/packages/tui/test/context/stale-zen-cycle.test.tsx index 971a7a952..250c90d1f 100644 --- a/packages/tui/test/context/stale-zen-cycle.test.tsx +++ b/packages/tui/test/context/stale-zen-cycle.test.tsx @@ -18,8 +18,7 @@ async function waitUntil(predicate: () => boolean, timeout = 2_000) { } } -// Always pass copies: the model store merges a newly set object into the one already there, -// so handing it these constants directly would let one call overwrite another's constant. +// Pass copies so no call can alias these shared constants. const STALE_ZEN = { providerID: "opencode", modelID: "model-a" } const BASE = { providerID: "altimate-free", modelID: "altimate-base" } const OWN = { providerID: "anthropic", modelID: "own-model" } From 68fcbd164a70c7d9ae8041ac588f5437b6d1d21c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Thu, 24 Sep 2026 05:36:21 +0800 Subject: [PATCH 2/2] test(tui): share the model-selection harness between its two tests Move the LocalProvider and SyncProvider mount, the model fixtures and `waitUntil` into `test/fixture/local-model.tsx`, so the cycling tests and the conversation-switching test use one copy. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016x3nbZU5Vb6HnSTqE7vELm --- .../context/model-store-aliasing.test.tsx | 160 +---------------- .../tui/test/context/stale-zen-cycle.test.tsx | 154 +---------------- packages/tui/test/fixture/local-model.tsx | 161 ++++++++++++++++++ 3 files changed, 164 insertions(+), 311 deletions(-) create mode 100644 packages/tui/test/fixture/local-model.tsx diff --git a/packages/tui/test/context/model-store-aliasing.test.tsx b/packages/tui/test/context/model-store-aliasing.test.tsx index 24af6296d..f0cd0307a 100644 --- a/packages/tui/test/context/model-store-aliasing.test.tsx +++ b/packages/tui/test/context/model-store-aliasing.test.tsx @@ -2,166 +2,10 @@ // by the sync store's message record. The model store must never write into it: a Solid store keeps // the first object set at a path by reference and merges later sets into it, so storing the record // itself let opening a second conversation rewrite the first one's recorded model. -import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" -import { mkdir } from "node:fs/promises" -import path from "node:path" import { tmpdir } from "../fixture/fixture" -import { TestTuiContexts } from "../fixture/tui-environment" -import { createTuiResolvedConfig } from "../fixture/tui-runtime" -import { createEventSource, createFetch, directory, json } from "../fixture/tui-sdk" - -async function waitUntil(predicate: () => boolean, timeout = 2_000) { - const started = Date.now() - while (!predicate()) { - if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(5) - } -} - -// Always pass copies: the model store merges a newly set object into the one already there, -// so handing it these constants directly would let one call overwrite another's constant. -const STALE_ZEN = { providerID: "opencode", modelID: "model-a" } -const BASE = { providerID: "altimate-free", modelID: "altimate-base" } -const OWN = { providerID: "anthropic", modelID: "own-model" } - -function makeModel(id: string, providerID = "opencode") { - return { - id, - providerID, - name: id, - family: providerID, - status: "active", - capabilities: {}, - cost: { input: 0, output: 0 }, - limit: { context: 65_536, output: 4_096 }, - } -} - -async function mount(agentModel?: { providerID: string; modelID: string }) { - const [ - { KVProvider }, - { LocalProvider, useLocal }, - { ArgsProvider }, - { ThemeProvider }, - { ToastProvider }, - { SDKProvider }, - { ProjectProvider }, - { SyncProvider, useSync }, - { RouteProvider }, - { ExitProvider }, - { TuiConfigProvider }, - ] = await Promise.all([ - import("../../src/context/kv"), - import("../../src/context/local"), - import("../../src/context/args"), - import("../../src/context/theme"), - import("../../src/ui/toast"), - import("../../src/context/sdk"), - import("../../src/context/project"), - import("../../src/context/sync"), - import("../../src/context/route"), - import("../../src/context/exit"), - import("../../src/config"), - ]) - - const tmp = await tmpdir() - const state = path.join(tmp.path, "state") - await mkdir(state, { recursive: true }) - await Bun.write(path.join(state, "kv.json"), "{}") - // The only persisted history is a stale keyless-Zen pick followed by the user's own model. - await Bun.write(path.join(state, "model.json"), JSON.stringify({ recent: [STALE_ZEN, OWN] })) - - const zenProvider = { - id: "opencode", - name: "Zen", - options: { apiKey: "public" }, - models: { "model-a": makeModel("model-a") }, - env: [], - } - const baseProvider = { - id: "altimate-free", - name: "Altimate Base", - models: { "altimate-base": makeModel("altimate-base", "altimate-free") }, - env: [], - } - const ownProvider = { - id: "anthropic", - name: "Anthropic", - models: { "own-model": makeModel("own-model", "anthropic") }, - env: [], - } - const providers = [zenProvider, baseProvider, ownProvider] - const agent = { - name: "build", - mode: "primary" as const, - hidden: false, - permission: {}, - options: {}, - ...(agentModel ? { model: agentModel } : {}), - } - const inner = createFetch((url) => { - if (url.pathname === "/instance/dispose") return json({}) - if (url.pathname === "/config/providers") return json({ providers, default: {} }) - if (url.pathname === "/provider") - return json({ all: providers, default: {}, connected: ["opencode", "altimate-free", "anthropic"] }) - if (url.pathname === "/agent") return json([agent, { ...agent, name: "plan" }]) - if (url.pathname === "/project/proj_test/directories") return json([]) - return undefined - }) - const source = createEventSource() - - let localAccessor: ReturnType | undefined - let syncAccessor: ReturnType | undefined - function Capture() { - localAccessor = useLocal() - syncAccessor = useSync() - return null - } - - const app = await testRender(() => ( - - {}}> - - - - - - - - - - - - - - - - - - - - - - - - )) - await app.renderOnce() - await waitUntil(() => localAccessor !== undefined && localAccessor.model.ready) - const local = localAccessor! - - return { - local, - sync: syncAccessor!, - emit: source.emit, - async cleanup() { - app.renderer.destroy() - await local.model.persisted().catch(() => {}) - await tmp[Symbol.asyncDispose]() - }, - } -} - +import { directory } from "../fixture/tui-sdk" +import { BASE, OWN, mount, waitUntil } from "../fixture/local-model" const SESSION_A = "ses_alias_a" const SESSION_B = "ses_alias_b" diff --git a/packages/tui/test/context/stale-zen-cycle.test.tsx b/packages/tui/test/context/stale-zen-cycle.test.tsx index 250c90d1f..1d25ebd24 100644 --- a/packages/tui/test/context/stale-zen-cycle.test.tsx +++ b/packages/tui/test/context/stale-zen-cycle.test.tsx @@ -1,161 +1,9 @@ // A stale keyless-Zen entry at the front of `recent` is shown as Altimate Base by // `currentModel()`. `cycle()` must resolve that entry the same way, or the repaired current model // is missing from its order and cycling does nothing. -import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" -import { mkdir } from "node:fs/promises" -import path from "node:path" import { tmpdir } from "../fixture/fixture" -import { TestTuiContexts } from "../fixture/tui-environment" -import { createTuiResolvedConfig } from "../fixture/tui-runtime" -import { createEventSource, createFetch, directory, json } from "../fixture/tui-sdk" - -async function waitUntil(predicate: () => boolean, timeout = 2_000) { - const started = Date.now() - while (!predicate()) { - if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(5) - } -} - -// Pass copies so no call can alias these shared constants. -const STALE_ZEN = { providerID: "opencode", modelID: "model-a" } -const BASE = { providerID: "altimate-free", modelID: "altimate-base" } -const OWN = { providerID: "anthropic", modelID: "own-model" } - -function makeModel(id: string, providerID = "opencode") { - return { - id, - providerID, - name: id, - family: providerID, - status: "active", - capabilities: {}, - cost: { input: 0, output: 0 }, - limit: { context: 65_536, output: 4_096 }, - } -} - -async function mount(agentModel?: { providerID: string; modelID: string }) { - const [ - { KVProvider }, - { LocalProvider, useLocal }, - { ArgsProvider }, - { ThemeProvider }, - { ToastProvider }, - { SDKProvider }, - { ProjectProvider }, - { SyncProvider }, - { RouteProvider }, - { ExitProvider }, - { TuiConfigProvider }, - ] = await Promise.all([ - import("../../src/context/kv"), - import("../../src/context/local"), - import("../../src/context/args"), - import("../../src/context/theme"), - import("../../src/ui/toast"), - import("../../src/context/sdk"), - import("../../src/context/project"), - import("../../src/context/sync"), - import("../../src/context/route"), - import("../../src/context/exit"), - import("../../src/config"), - ]) - - const tmp = await tmpdir() - const state = path.join(tmp.path, "state") - await mkdir(state, { recursive: true }) - await Bun.write(path.join(state, "kv.json"), "{}") - // The only persisted history is a stale keyless-Zen pick followed by the user's own model. - await Bun.write(path.join(state, "model.json"), JSON.stringify({ recent: [STALE_ZEN, OWN] })) - - const zenProvider = { - id: "opencode", - name: "Zen", - options: { apiKey: "public" }, - models: { "model-a": makeModel("model-a") }, - env: [], - } - const baseProvider = { - id: "altimate-free", - name: "Altimate Base", - models: { "altimate-base": makeModel("altimate-base", "altimate-free") }, - env: [], - } - const ownProvider = { - id: "anthropic", - name: "Anthropic", - models: { "own-model": makeModel("own-model", "anthropic") }, - env: [], - } - const providers = [zenProvider, baseProvider, ownProvider] - const agent = { - name: "build", - mode: "primary" as const, - hidden: false, - permission: {}, - options: {}, - ...(agentModel ? { model: agentModel } : {}), - } - const inner = createFetch((url) => { - if (url.pathname === "/instance/dispose") return json({}) - if (url.pathname === "/config/providers") return json({ providers, default: {} }) - if (url.pathname === "/provider") - return json({ all: providers, default: {}, connected: ["opencode", "altimate-free", "anthropic"] }) - if (url.pathname === "/agent") return json([agent, { ...agent, name: "plan" }]) - if (url.pathname === "/project/proj_test/directories") return json([]) - return undefined - }) - const source = createEventSource() - - let localAccessor: ReturnType | undefined - function Capture() { - localAccessor = useLocal() - return null - } - - const app = await testRender(() => ( - - {}}> - - - - - - - - - - - - - - - - - - - - - - - - )) - await app.renderOnce() - await waitUntil(() => localAccessor !== undefined && localAccessor.model.ready) - const local = localAccessor! - - return { - local, - async cleanup() { - app.renderer.destroy() - await local.model.persisted().catch(() => {}) - await tmp[Symbol.asyncDispose]() - }, - } -} - +import { OWN, STALE_ZEN, BASE, mount, waitUntil } from "../fixture/local-model" test("cycle() still moves off an explicitly chosen keyless-Zen model", async () => { const originalStateHome = process.env.OPENCODE_TEST_STATE_HOME diff --git a/packages/tui/test/fixture/local-model.tsx b/packages/tui/test/fixture/local-model.tsx new file mode 100644 index 000000000..52f6d18cd --- /dev/null +++ b/packages/tui/test/fixture/local-model.tsx @@ -0,0 +1,161 @@ +/** @jsxImportSource @opentui/solid */ +// Mounts the real LocalProvider and SyncProvider over a mocked server with three models (a keyless +// Zen model, Altimate Base and a model of the user's own) and two primary agents, for tests of the +// TUI's model selection. `sync` and `emit` let a test feed messages through the real sync store. +import { testRender } from "@opentui/solid" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "./fixture" +import { TestTuiContexts } from "./tui-environment" +import { createTuiResolvedConfig } from "./tui-runtime" +import { createEventSource, createFetch, directory, json } from "./tui-sdk" + +export async function waitUntil(predicate: () => boolean, timeout = 2_000) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(5) + } +} + +// Pass copies so no call can alias these shared constants. +export const STALE_ZEN = { providerID: "opencode", modelID: "model-a" } +export const BASE = { providerID: "altimate-free", modelID: "altimate-base" } +export const OWN = { providerID: "anthropic", modelID: "own-model" } + +function makeModel(id: string, providerID = "opencode") { + return { + id, + providerID, + name: id, + family: providerID, + status: "active", + capabilities: {}, + cost: { input: 0, output: 0 }, + limit: { context: 65_536, output: 4_096 }, + } +} + +export async function mount(agentModel?: { providerID: string; modelID: string }) { + const [ + { KVProvider }, + { LocalProvider, useLocal }, + { ArgsProvider }, + { ThemeProvider }, + { ToastProvider }, + { SDKProvider }, + { ProjectProvider }, + { SyncProvider, useSync }, + { RouteProvider }, + { ExitProvider }, + { TuiConfigProvider }, + ] = await Promise.all([ + import("../../src/context/kv"), + import("../../src/context/local"), + import("../../src/context/args"), + import("../../src/context/theme"), + import("../../src/ui/toast"), + import("../../src/context/sdk"), + import("../../src/context/project"), + import("../../src/context/sync"), + import("../../src/context/route"), + import("../../src/context/exit"), + import("../../src/config"), + ]) + + const tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + // The only persisted history is a stale keyless-Zen pick followed by the user's own model. + await Bun.write(path.join(state, "model.json"), JSON.stringify({ recent: [STALE_ZEN, OWN] })) + + const zenProvider = { + id: "opencode", + name: "Zen", + options: { apiKey: "public" }, + models: { "model-a": makeModel("model-a") }, + env: [], + } + const baseProvider = { + id: "altimate-free", + name: "Altimate Base", + models: { "altimate-base": makeModel("altimate-base", "altimate-free") }, + env: [], + } + const ownProvider = { + id: "anthropic", + name: "Anthropic", + models: { "own-model": makeModel("own-model", "anthropic") }, + env: [], + } + const providers = [zenProvider, baseProvider, ownProvider] + const agent = { + name: "build", + mode: "primary" as const, + hidden: false, + permission: {}, + options: {}, + ...(agentModel ? { model: agentModel } : {}), + } + const inner = createFetch((url) => { + if (url.pathname === "/instance/dispose") return json({}) + if (url.pathname === "/config/providers") return json({ providers, default: {} }) + if (url.pathname === "/provider") + return json({ all: providers, default: {}, connected: ["opencode", "altimate-free", "anthropic"] }) + if (url.pathname === "/agent") return json([agent, { ...agent, name: "plan" }]) + if (url.pathname === "/project/proj_test/directories") return json([]) + return undefined + }) + const source = createEventSource() + + let localAccessor: ReturnType | undefined + let syncAccessor: ReturnType | undefined + function Capture() { + localAccessor = useLocal() + syncAccessor = useSync() + return null + } + + const app = await testRender(() => ( + + {}}> + + + + + + + + + + + + + + + + + + + + + + + + )) + await app.renderOnce() + await waitUntil(() => localAccessor !== undefined && localAccessor.model.ready) + const local = localAccessor! + + return { + local, + sync: syncAccessor!, + emit: source.emit, + async cleanup() { + app.renderer.destroy() + await local.model.persisted().catch(() => {}) + await tmp[Symbol.asyncDispose]() + }, + } +}