From 1dc54945d57757bc003c1686cc0bc7fb21216f73 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 7 Jul 2026 22:34:52 +0000 Subject: [PATCH] fix(tui): sync durable session model changes --- packages/tui/src/component/prompt/index.tsx | 7 +- packages/tui/src/context/local.tsx | 21 +++ .../tui/test/cli/tui/local-model.test.tsx | 146 ++++++++++++++++++ 3 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 packages/tui/test/cli/tui/local-model.test.tsx diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 1e5a9132c9..e0996bae3f 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -340,7 +340,8 @@ export function Prompt(props: PromptProps) { ), ) - // Initialize agent/model/variant from the durable V2 Session state. + // Initialize the agent from the durable V2 Session state. The model context + // follows durable Session model changes while preserving unsent local picks. let syncedSessionID: string | undefined createEffect(() => { const sessionID = props.sessionID @@ -349,10 +350,6 @@ export function Prompt(props: PromptProps) { if (!session) return const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent) if (agent && !args.agent) local.agent.set(agent.id) - if (session.model) { - local.model.set({ providerID: session.model.providerID, modelID: session.model.id }) - local.model.variant.set(session.model.variant) - } syncedSessionID = sessionID }) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 5c16acdaf8..c82ab39c1f 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -245,6 +245,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ ) }) + let syncedSessionModel: string | undefined + createEffect(() => { + if (route.data.type !== "session") { + syncedSessionModel = undefined + return + } + const session = data.session.get(route.data.sessionID) + const selected = session?.model + const a = agent.current() + if (!selected || !a) return + const model = { providerID: selected.providerID, modelID: selected.id } + if (!isModelValid(model)) return + const fingerprint = [session.id, a.id, selected.providerID, selected.id, selected.variant ?? "default"].join(":") + if (fingerprint === syncedSessionModel) return + syncedSessionModel = fingerprint + batch(() => { + setModelStore("model", a.id, model) + setModelStore("variant", `${selected.providerID}/${selected.id}`, selected.variant ?? "default") + }) + }) + return { current: currentModel, get ready() { diff --git a/packages/tui/test/cli/tui/local-model.test.tsx b/packages/tui/test/cli/tui/local-model.test.tsx new file mode 100644 index 0000000000..d2da3daf68 --- /dev/null +++ b/packages/tui/test/cli/tui/local-model.test.tsx @@ -0,0 +1,146 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../src/context/args" +import { DataProvider } from "../../../src/context/data" +import { KVProvider } from "../../../src/context/kv" +import { LocalProvider, useLocal } from "../../../src/context/local" +import { PermissionProvider } from "../../../src/context/permission" +import { ProjectProvider } from "../../../src/context/project" +import { RouteProvider } from "../../../src/context/route" +import { SDKProvider } from "../../../src/context/sdk" +import { SyncProvider } from "../../../src/context/sync" +import { ThemeProvider } from "../../../src/context/theme" +import { TuiConfigProvider } from "../../../src/config" +import { ToastProvider } from "../../../src/ui/toast" +import { tmpdir } from "../../fixture/fixture" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +test("follows durable model switches for the active session", async () => { + await using tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + await Bun.write(path.join(state, "kv.json"), "{}") + const events = createEventStream() + const session = { + id: "ses_test", + projectID: "proj_test", + agent: "build", + model: { providerID: "repro", id: "alpha" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Test session", + location: { directory }, + } + const calls = createFetch((url) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === "/api/model") + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: [ + { providerID: "repro", id: "alpha", name: "Alpha" }, + { providerID: "repro", id: "beta", name: "Beta" }, + { providerID: "repro", id: "gamma", name: "Gamma", variants: [{ id: "high" }] }, + ], + }) + if (url.pathname === "/api/agent") + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: [{ id: "build", mode: "primary", hidden: false }], + }) + return undefined + }, events) + let local!: ReturnType + let mounted!: () => void + const ready = new Promise((resolve) => { + mounted = resolve + }) + + function Probe() { + local = useLocal() + onMount(mounted) + return {local.model.current()?.modelID} + } + + const app = await testRender(() => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + )) + + try { + await ready + await wait(() => local.model.ready && local.model.current()?.modelID === "alpha") + local.model.set({ providerID: "repro", modelID: "beta" }) + + events.emit({ + id: "evt_model_alpha", + created: 1, + type: "session.model.selected", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + location: { directory }, + data: { + sessionID: session.id, + model: { providerID: "repro", id: "alpha" }, + }, + }) + await Bun.sleep(20) + expect(local.model.current()?.modelID).toBe("beta") + + events.emit({ + id: "evt_model_gamma", + created: 2, + type: "session.model.selected", + durable: { aggregateID: session.id, seq: 2, version: 1 }, + location: { directory }, + data: { + sessionID: session.id, + model: { providerID: "repro", id: "gamma", variant: "high" }, + }, + }) + + await wait(() => local.model.current()?.modelID === "gamma") + expect(local.model.current()).toEqual({ providerID: "repro", modelID: "gamma" }) + expect(local.model.variant.current()).toBe("high") + } finally { + app.renderer.destroy() + } +})