diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 37d8e264d1d..c03b9243fc3 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -6,6 +6,7 @@ import type { AgentInfo, CommandInfo, + ConfigEntry, FormCancelInput, FormInfo, FormReplyInput, @@ -84,6 +85,7 @@ type LocationData = { vcs?: VcsInfo agent?: AgentInfo[] command?: CommandInfo[] + config?: ConfigEntry[] integration?: IntegrationInfo[] mcpServer?: McpServer[] mcpResource?: McpResource[] @@ -1215,6 +1217,11 @@ export function createData(config: CreateDataInput) { ) break case "config.updated": + result.location.config.invalidate(location) + if (result.location.config.list(location) !== undefined || sync.has(`location.config:${locationKey(location)}`)) + refresh(() => result.location.config.sync(location)) + refresh(() => result.location.websearch.refresh(location)) + break case "websearch.updated": refresh(() => result.location.websearch.refresh(location)) break @@ -1790,6 +1797,7 @@ export function createData(config: CreateDataInput) { result.location.vcs.invalidate(location) result.location.agent.invalidate(location) result.location.command.invalidate(location) + result.location.config.invalidate(location) result.location.integration.invalidate(location) result.location.mcp.server.invalidate(location) result.location.mcp.resource.invalidate(location) @@ -1803,6 +1811,10 @@ export function createData(config: CreateDataInput) { vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate }, agent: locationResource("agent", (location) => api().agent.list({ location })), command: locationResource("command", (location) => api().command.list({ location })), + config: locationResource("config", async (location) => ({ + location: { directory: location.directory, workspaceID: location.workspace }, + data: await api().config.get({ location }), + })), integration: locationResource("integration", (location) => api().integration.list({ location })), mcp: { server: locationResource("mcpServer", (location) => api().mcp.list({ location })), diff --git a/packages/client/test/solid-refresh.test.ts b/packages/client/test/solid-refresh.test.ts index 246aad94a81..266c594fdcc 100644 --- a/packages/client/test/solid-refresh.test.ts +++ b/packages/client/test/solid-refresh.test.ts @@ -3,6 +3,56 @@ import { createRoot } from "solid-js" import { createData, type CreateDataInput } from "../src/solid" import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise" +test("config reads and update refreshes are opt-in", async () => { + const listeners = new Set[0]>() + const requests: string[] = [] + const location = { directory: "/project" } + let model = "provider/first" + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = new URL(request.url) + requests.push(url.pathname) + if (url.pathname === "/api/location") return Response.json(location) + if (url.pathname === "/api/config") return Response.json([{ type: "document", info: { model } }]) + if (url.pathname === "/api/mcp/resource") + return Response.json({ location, data: { resources: [], templates: [] } }) + return Response.json({ location, data: [] }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: location.directory, + event: { + on: () => () => {}, + listen(handler) { + listeners.add(handler) + return () => listeners.delete(handler) + }, + }, + }), + dispose, + })) + const event: OpenCodeEvent = { id: "evt_config", created: 1, type: "config.updated", location, data: {} } + try { + await setup.data.location.sync() + listeners.forEach((listener) => listener({ name: event.type, details: event })) + expect(requests).not.toContain("/api/config") + + await setup.data.location.config.sync() + expect(requests.filter((path) => path === "/api/config")).toHaveLength(1) + model = "provider/second" + listeners.forEach((listener) => listener({ name: event.type, details: event })) + await setup.data.location.config.sync() + expect(requests.filter((path) => path === "/api/config")).toHaveLength(2) + expect(setup.data.location.config.list()).toEqual([{ type: "document", info: { model } }]) + } finally { + setup.dispose() + } +}) + test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => { const listeners = new Set[0]>() const reported = Promise.withResolvers() diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 8895847cb6b..39c7e11e918 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -737,6 +737,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater" slash: { name: "new", aliases: ["clear"] }, run: () => { const model = local.model.current() + const agent = local.agent.current() const current = route.data.type === "session" ? (data.session.get(route.data.sessionID)?.location ?? location.ref) @@ -750,6 +751,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater" location.error?.location, ), }) + if (agent) local.agent.set(agent.id) if (model) local.model.set(model) dialog.clear() }, diff --git a/packages/tui/src/component/dialog-variant.tsx b/packages/tui/src/component/dialog-variant.tsx index 1ca224254d3..cfb703f6600 100644 --- a/packages/tui/src/component/dialog-variant.tsx +++ b/packages/tui/src/component/dialog-variant.tsx @@ -7,22 +7,33 @@ export function DialogVariant() { const local = useLocal() const dialog = useDialog() - const options = createMemo(() => - local.model.variant.list().map((variant) => ({ - value: variant, - title: variant, + const options = createMemo(() => [ + { + value: "default", + title: "Default", onSelect: () => { dialog.clear() - local.model.variant.set(variant) + local.model.variant.set(undefined) }, - })), - ) + }, + ...local.model.variant + .list() + .filter((variant) => variant !== "default") + .map((variant) => ({ + value: variant, + title: variant, + onSelect: () => { + dialog.clear() + local.model.variant.set(variant) + }, + })), + ]) return ( options={options()} title={"Select variant"} - current={local.model.variant.current()} + current={local.model.variant.current() ?? "default"} flat={true} /> ) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 80c5bef7249..0e5275f5f1e 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -48,7 +48,6 @@ import { useConnected } from "../use-connected" import { useToast } from "../../ui/toast" import { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" -import { useArgs } from "../../context/args" import { useConfig } from "../../config" import { usePromptMove } from "./move" import { resolvePastedAttachments } from "./local-attachment" @@ -190,7 +189,6 @@ export function Prompt(props: PromptProps) { const leader = Keymap.useLeaderActive() const muted = () => leader() || props.muted const local = useLocal() - const args = useArgs() const paths = useTuiPaths() const terminalEnvironment = useTuiTerminalEnvironment() const clipboard = useClipboard() @@ -412,18 +410,6 @@ export function Prompt(props: PromptProps) { ), ) - // Initialize agent/model/variant from the durable V2 Session state. - let syncedSessionID: string | undefined - createEffect(() => { - const sessionID = props.sessionID - if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return - const session = data.session.get(sessionID) - 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) - syncedSessionID = sessionID - }) - const promptCommands = createMemo(() => [ { @@ -1265,6 +1251,23 @@ export function Prompt(props: PromptProps) { } const target = sessionID + const prepareAgent = async () => { + if (!session) { + await data.session.sync(target) + session = data.session.get(target) + } + if (session?.agent !== agent.id) { + await client.api.session.switchAgent({ sessionID: target, agent: agent.id }) + } + } + const commitModel = () => { + const model = { providerID: selection.providerID, id: selection.modelID, variant } + const cancelCommit = local.model.trackSessionCommit(target, model, agent.id) + return client.api.session.switchModel({ sessionID: target, model }).catch((error) => { + cancelCommit() + throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error }) + }) + } history.append(entry) const dispatch = (send: () => Promise) => { const setup = newSession @@ -1276,8 +1279,12 @@ export function Prompt(props: PromptProps) { dispatch(() => client.api.session.shell({ sessionID: target, command: inputText })) setStore("mode", "normal") } else if (slashHead && isCommand) { - const send = () => - client.api.session.command({ + const send = async () => { + await prepareAgent() + // Commands inherit the composer selection; command-specific overrides + // remain server-owned and run after this preparation. + await commitModel() + return client.api.session.command({ sessionID: target, command: slashHead.name, text: slashHead.arguments, @@ -1286,6 +1293,7 @@ export function Prompt(props: PromptProps) { skills: entry.skills?.length ? entry.skills : undefined, delivery, }) + } const setup = newSession void (setup ? setup.gate.then(send) : send()).catch((error) => { if (setup) return setup.recover(error) @@ -1298,19 +1306,12 @@ export function Prompt(props: PromptProps) { } else { move.startSubmit() try { - if (!session) { - await data.session.sync(target) - session = data.session.get(target) - } - if (session?.agent !== agent.id) { - await client.api.session.switchAgent({ sessionID: target, agent: agent.id }) - } + await prepareAgent() } catch (error) { toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" }) restoreEntry() return true } - const model = { providerID: selection.providerID, id: selection.modelID, variant } if (session?.revert) { const error = await client.api.session.revert.commit({ sessionID: target }).then( () => undefined, @@ -1359,16 +1360,10 @@ export function Prompt(props: PromptProps) { skills: entry.skills?.length ? entry.skills : undefined, delivery, gate: newSession?.gate, - prepare: () => { - // Commit the captured selection after earlier admissions, including - // compaction setup. Cached state may still precede their SSE echoes; - // the server makes an unchanged selection a no-op. - const cancelCommit = local.model.trackSessionCommit(target, model) - return client.api.session.switchModel({ sessionID: target, model }).catch((error) => { - cancelCommit() - throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error }) - }) - }, + // Commit the captured selection after earlier admissions, including + // compaction setup. Cached state may still precede their SSE echoes; + // the server makes an unchanged selection a no-op. + prepare: commitModel, }) .catch((error) => { if (newSession) return newSession.recover(error) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index ea1890535c1..fdab4581e8e 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,4 +1,5 @@ import { createData } from "@opencode-ai/client/solid" +import type { LocationRef } from "@opencode-ai/client" import type { Plugin } from "@opencode-ai/plugin/tui" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -21,6 +22,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const [generatingTitles, setGeneratingTitles] = createStore>({}) return { ...data, + location: { + ...data.location, + async sync(ref?: LocationRef) { + await data.location.syncInfo(ref) + await Promise.all([data.location.sync(ref), data.location.config.sync(ref)]) + }, + }, session: { ...data.session, title: { diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 7e6da553f47..c10baed3090 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -6,7 +6,6 @@ import { useEvent } from "./event" import path from "path" import { useTuiPaths } from "./runtime" import { useArgs } from "./args" -import { useClient } from "./client" import { RGBA } from "@opentui/core" import { readJson, writeJsonAtomic } from "../util/persistence" import { @@ -23,14 +22,7 @@ import { useRoute } from "./route" import { useData } from "./data" import { usePermission } from "./permission" import { useLocation } from "./location" - -export function parseModel(model: string) { - const [providerID, ...rest] = model.split("/") - return { - providerID: providerID, - modelID: rest.join("/"), - } -} +import { parse } from "../util/model" export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) { const seen = new Set() @@ -49,7 +41,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { const data = useData() - const client = useClient() const toast = useToast() const theme = useTheme() const { mode } = useThemes() @@ -83,7 +74,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ ) const [agentStore, setAgentStore] = createStore({ current: undefined as string | undefined, + draftBySession: {} as Record, }) + onCleanup(event.on("session.deleted", (evt) => setAgentStore("draftBySession", evt.data.sessionID, undefined))) + onCleanup( + event.on("session.agent.selected", (evt) => { + // Keep an entry after acknowledgment: CLI defaults must not override a user's later choice. + if (agentStore.draftBySession[evt.data.sessionID]?.agent === evt.data.agent) + setAgentStore("draftBySession", evt.data.sessionID, { agent: undefined }) + }), + ) const colors = createMemo(() => { const step = mode() === "light" ? 800 : 200 return dedupeWith( @@ -96,7 +96,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return agents() }, current() { - return agents().find((agent) => agent.id === agentStore.current) ?? agents().at(0) + const draft = route.data.type === "session" ? agentStore.draftBySession[route.data.sessionID] : undefined + const selected = + route.data.type === "session" + ? (draft?.agent ?? + (draft ? undefined : args.agent) ?? + data.session.get(route.data.sessionID)?.agent ?? + agentStore.current) + : agentStore.current + return agents().find((agent) => agent.id === selected) ?? agents().at(0) }, set(id: string) { if (!agents().some((agent) => agent.id === id)) @@ -105,7 +113,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ message: `Agent not found: ${id}`, duration: 3000, }) - setAgentStore("current", id) + batch(() => { + const changed = this.current()?.id !== id + if (changed) model.remember() + setAgentStore("current", id) + if (route.data.type === "session") setAgentStore("draftBySession", route.data.sessionID, { agent: id }) + // Retain both selections while agent and model commits arrive separately. + const selected = changed && route.data.type === "session" ? model.current() : undefined + if (selected) model.set(selected) + }) }, move(direction: 1 | -1) { batch(() => { @@ -115,7 +131,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (next < 0) next = agents().length - 1 if (next >= agents().length) next = 0 const value = agents()[next] - setAgentStore("current", value.id) + this.set(value.id) }) }, color(id: string) { @@ -141,14 +157,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const [selectionState, setSelectionState] = createStore<{ newSessionModelByLocationAgent: Record - draftBySession: Record + selectionBySessionAgent: Record | undefined> }>({ newSessionModelByLocationAgent: {}, - draftBySession: {}, + selectionBySessionAgent: {}, }) const repository = createModelPreferenceRepository(path.join(paths.state, "model.json")) - const pendingSelectionCommits = new Map() + const pendingSelectionCommits = new Map() const selectionKey = (value: ModelSelection) => `${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}` const saveState = { @@ -183,9 +199,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (saveState.pending) savePreferences() }) + const configuredModel = createMemo(() => { + const entry = data.location.config + .list(location.ref) + ?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined) + const configured = entry?.type === "document" ? entry.info.model : undefined + if (!configured) return + return typeof configured === "string" + ? { ...parse(configured), variant: undefined } + : { providerID: configured.providerID, modelID: configured.model, variant: configured.variant } + }) + const fallbackModel = createMemo(() => { if (args.model) { - const { providerID, modelID } = parseModel(args.model) + const { providerID, modelID } = parse(args.model) if (isModelValid({ providerID, modelID })) { return { providerID, @@ -194,6 +221,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } + const configured = configuredModel() + if (configured && isModelValid(configured)) return configured + for (const item of preferences.recent) { if (isModelValid(item)) { return item @@ -221,7 +251,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (route.data.type === "session") return sessionSelection(route.data.sessionID) const model = newSessionModel() if (!model) return - return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) } + return preferredSelection(model) }) const currentModel = createMemo(() => { @@ -235,6 +265,23 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}` } + function preferredSelection(model: ModelPreferenceModel): ModelSelection { + const configured = agent.current()?.model + const fallback = configuredModel() + const preferred = preferences.variant[modelPreferenceKey(model)] + const variant = normalizeModelVariant( + preferred ?? + (configured?.providerID === model.providerID && configured.id === model.modelID + ? configured.variant + : undefined) ?? + (fallback?.providerID === model.providerID && fallback.modelID === model.modelID + ? fallback.variant + : undefined), + ) + const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID) + return { ...model, variant: info?.variants.some((item) => item.id === variant) ? variant : undefined } + } + function durableSelection(sessionID: string): ModelSelection | undefined { const model = data.session.get(sessionID)?.model if (!model) return @@ -246,15 +293,44 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } function sessionSelection(sessionID: string) { - return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID) + const current = agent.current() + if (!current) return + const session = data.session.get(sessionID) + const selected = [ + selectionState.selectionBySessionAgent[sessionID]?.[current.id], + !session?.agent || session.agent === current.id ? durableSelection(sessionID) : undefined, + ].find((selection) => selection && isModelValid(selection)) + if (selected) { + const info = models()?.find((item) => item.providerID === selected.providerID && item.id === selected.modelID) + return { + ...selected, + variant: info?.variants.some((variant) => variant.id === selected.variant) ? selected.variant : undefined, + } + } + const model = newSessionModel() + return model && preferredSelection(model) + } + + function setSessionSelection(sessionID: string, agentID: string, selection: ModelSelection | undefined) { + setSelectionState("selectionBySessionAgent", sessionID, { + ...selectionState.selectionBySessionAgent[sessionID], + [agentID]: selection, + }) } function setSessionDraft(sessionID: string, selection: ModelSelection) { + const current = agent.current() + if (!current) return const durable = durableSelection(sessionID) - setSelectionState( - "draftBySession", + const session = data.session.get(sessionID) + setSessionSelection( sessionID, - durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection, + current.id, + (!session?.agent || session.agent === current.id) && + durable && + selectionKey(durable) === selectionKey(selection) + ? undefined + : selection, ) } @@ -262,14 +338,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (route.data.type === "session") { const sessionID = route.data.sessionID const current = sessionSelection(sessionID) - const preferred = normalizeModelVariant( + setSessionDraft( + sessionID, current?.providerID === model.providerID && current.modelID === model.modelID - ? current.variant - : preferences.variant[modelPreferenceKey(model)], + ? current + : preferredSelection(model), ) - const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID) - const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined - setSessionDraft(sessionID, { ...model, variant }) return true } const current = agent.current() @@ -278,33 +352,43 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return true } - onCleanup( - event.on("session.model.selected", (evt) => { - const expected = pendingSelectionCommits.get(evt.data.sessionID) - if (!expected) return - const committed = selectionKey({ - providerID: evt.data.model.providerID, - modelID: evt.data.model.id, - variant: evt.data.model.variant, - }) - if (committed !== expected) return - pendingSelectionCommits.delete(evt.data.sessionID) - const draft = selectionState.draftBySession[evt.data.sessionID] - if (draft && selectionKey(draft) === committed) - setSelectionState("draftBySession", evt.data.sessionID, undefined) - }), - ) + function reconcileSessionSelection(sessionID: string) { + const expected = pendingSelectionCommits.get(sessionID) + const durable = durableSelection(sessionID) + if (!expected || !durable || data.session.get(sessionID)?.agent !== expected.agentID) return + if (selectionKey(durable) !== expected.selection) return + pendingSelectionCommits.delete(sessionID) + // Inactive agents keep their remembered choices after another agent commits. + if ( + route.data.type !== "session" || + route.data.sessionID !== sessionID || + agent.current()?.id !== expected.agentID + ) + return + const draft = selectionState.selectionBySessionAgent[sessionID]?.[expected.agentID] + if (draft && selectionKey(draft) === expected.selection) + setSessionSelection(sessionID, expected.agentID, undefined) + } + + onCleanup(event.on("session.model.selected", (evt) => reconcileSessionSelection(evt.data.sessionID))) + onCleanup(event.on("session.agent.selected", (evt) => reconcileSessionSelection(evt.data.sessionID))) onCleanup( event.on("session.deleted", (evt) => { pendingSelectionCommits.delete(evt.data.sessionID) - setSelectionState("draftBySession", evt.data.sessionID, undefined) + setSelectionState("selectionBySessionAgent", evt.data.sessionID, undefined) }), ) return { current: currentModel, selection: currentSelection, + remember() { + const current = agent.current() + const selection = currentSelection() + if (route.data.type !== "session" || !current || !selection) return + setSessionSelection(route.data.sessionID, current.id, { ...selection }) + }, available(model = currentModel()) { return model ? isModelValid(model) : false }, @@ -315,9 +399,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ id: string variant?: string }, + agentID: string, ) { - const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant }) + const committed = { + agentID, + selection: selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant }), + } pendingSelectionCommits.set(sessionID, committed) + // An unchanged model emits no event; the agent may be the only durable change. + reconcileSessionSelection(sessionID) return () => { if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID) } @@ -354,7 +444,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ cycle(direction: 1 | -1) { const current = currentSelection() if (!current) return - const recent = recentModels(current, preferences.recent).filter(isModelValid) + const recent = preferences.recent.filter(isModelValid) const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID) let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction if (next < 0) next = recent.length - 1 @@ -422,9 +512,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return currentSelection()?.variant }, current() { - const v = this.selected() - if (v && this.list().includes(v)) return v - return undefined + return this.selected() }, list() { const m = currentSelection() @@ -438,7 +526,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (route.data.type === "session") { setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) }) } - setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value)) + setPreferences("variant", modelPreferenceKey(m), value ?? "default") savePreferences() }, cycle() { diff --git a/packages/tui/src/model-preference.ts b/packages/tui/src/model-preference.ts index 5f9ce566c05..b980c235264 100644 --- a/packages/tui/src/model-preference.ts +++ b/packages/tui/src/model-preference.ts @@ -29,8 +29,8 @@ function variants(value: unknown) { return Object.fromEntries( Object.entries(value).flatMap(([key, item]) => { if (key.length === 0 || typeof item !== "string" || item.length === 0) return [] - const variant = normalizeModelVariant(item) - return variant === undefined ? [] : ([[key, variant]] as const) + // Preserve explicit "default" so it can override an agent's configured variant. + return [[key, item]] as const }), ) } @@ -108,17 +108,12 @@ export function createModelPreferenceRepository(filePath: string) { return update(() => value) }, async resolveVariant(model: ModelPreferenceModel) { - return (await load()).variant[modelPreferenceKey(model)] + return normalizeModelVariant((await load()).variant[modelPreferenceKey(model)]) }, saveVariant(model: ModelPreferenceModel, value: string | undefined) { const key = modelPreferenceKey(model) - const next = normalizeModelVariant(value) - return update((current) => { - const variant = { ...current.variant } - if (next === undefined) delete variant[key] - if (next !== undefined) variant[key] = next - return { variant } - }) + const next = normalizeModelVariant(value) ?? "default" + return update((current) => ({ variant: { ...current.variant, [key]: next } })) }, } } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 8b8d0df66f4..e13d5724255 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -5,10 +5,9 @@ import { Effect, FileSystem } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/util/global" import path from "node:path" -import { createEventStream, createFetch, directory, json, type FetchHandler } from "./fixture/tui-client" +import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" import { tmpdir } from "./fixture/fixture" -import type { TuiInput } from "../src/app" -import type { Config } from "../src/config" +import { createAppFixture } from "./fixture/app" import type { PluginInfo } from "@opencode-ai/client" test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => { @@ -1530,54 +1529,3 @@ test("server plugin failures share one notice and use source names before an ID expect(setup.captureCharFrame()).toContain("/fixture/broken.ts") expect(setup.captureCharFrame()).toContain("Open plugins") }) - -async function createAppFixture( - input: { - width?: number - height?: number - state?: string - config?: Config.Info - args?: TuiInput["args"] - fetch?: FetchHandler - } = {}, -) { - const { run } = await import("../src/app") - const setup = await createTestRenderer({ - width: input.width ?? 100, - height: input.height ?? 30, - useThread: false, - kittyKeyboard: true, - }) - setup.renderer.start() - const ready = Promise.withResolvers() - const events = createEventStream() - const calls = createFetch(input.fetch, events) - const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) - const task = Effect.runPromise( - run({ - app: { name: "test", version: "test", channel: "test" }, - server: { endpoint: { url: server.url.toString() } }, - config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) }, - packages: { prepare: async () => ({ directory: "" }) }, - terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }), - args: input.args ?? {}, - log: () => {}, - }).pipe( - Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)), - Effect.provide(FileSystem.layerNoop({})), - ), - ) - return { - ...setup, - events, - ready: ready.promise, - async [Symbol.asyncDispose]() { - try { - if (!setup.renderer.isDestroyed) setup.renderer.destroy() - await task - } finally { - await server.stop() - } - }, - } -} diff --git a/packages/tui/test/command-selection.test.tsx b/packages/tui/test/command-selection.test.tsx new file mode 100644 index 00000000000..f1cb831b844 --- /dev/null +++ b/packages/tui/test/command-selection.test.tsx @@ -0,0 +1,108 @@ +import { expect, test } from "bun:test" +import { InputRenderable, TextareaRenderable } from "@opentui/core" +import { directory, json } from "./fixture/tui-client" +import { tmpdir } from "./fixture/fixture" +import { createAppFixture } from "./fixture/app" + +test("custom commands commit the captured agent, model and variant before execution", async () => { + await using state = await tmpdir() + const agent = Promise.withResolvers() + const model = Promise.withResolvers() + const mutations: { type: string; body: unknown }[] = [] + const location = { directory, project: { id: "project", directory, canonical: directory } } + const session = { + id: `ses_${crypto.randomUUID()}`, + projectID: "project", + title: "Command selection fixture", + agent: "build", + model: { providerID: "demo", id: "first" }, + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + await using setup = await createAppFixture({ + state: state.path, + config: { + animations: false, + keybinds: { "agent.cycle": "f6", "variant.cycle": "f7", "model.list": "f8" }, + }, + args: { sessionID: session.id }, + fetch: async (url, request) => { + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/agent") + return json({ + location, + data: ["build", "plan"].map((id) => ({ id, mode: "primary", hidden: false, permissions: [] })), + }) + if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] }) + if (url.pathname === "/api/model") + return json({ + location, + data: ["first", "second"].map((id) => ({ + id, + providerID: "demo", + name: `${id} model`, + variants: [{ id: "low" }, { id: "high" }], + cost: [], + time: { released: 0 }, + })), + }) + if (url.pathname === "/api/command") + return json({ location, data: [{ name: "review", description: "Review the input" }] }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname)) + return json({ data: [], cursor: {} }) + const type = url.pathname.match(/^\/api\/session\/[^/]+\/(agent|model|command)$/)?.[1] + if (!type) return + mutations.push({ type, body: await request.json() }) + return type === "agent" ? agent.promise : type === "model" ? model.promise : new Response(null, { status: 204 }) + }, + }) + try { + await setup.ready + await setup.waitForFrame((frame) => frame.includes("Build · first model")) + setup.mockInput.pressKey("F6") + await setup.waitForFrame((frame) => frame.includes("Plan ·")) + setup.mockInput.pressKey("F8") + await setup.waitForFrame( + (frame) => frame.includes("Select model") && setup.renderer.currentFocusedRenderable instanceof InputRenderable, + ) + await setup.mockInput.typeText("second") + await setup.renderOnce() + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => frame.includes("Select variant") && frame.includes("low")) + await setup.mockInput.typeText("low") + await setup.renderOnce() + setup.mockInput.pressEnter() + await setup.waitForFrame( + (frame) => + frame.includes("Plan · second model Demo · low") && + setup.renderer.currentFocusedRenderable instanceof TextareaRenderable, + ) + await setup.mockInput.typeText("/review selected input") + setup.mockInput.pressEscape() + setup.mockInput.pressEnter() + await setup.waitFor(() => mutations.length > 0) + expect(mutations).toEqual([{ type: "agent", body: { agent: "plan" } }]) + + // A later local edit must not change the in-flight command's selection. + setup.mockInput.pressKey("F7") + await setup.waitForFrame((frame) => frame.includes("high")) + agent.resolve(new Response(null, { status: 204 })) + await setup.waitFor(() => mutations.length === 2) + expect(mutations[1]).toEqual({ + type: "model", + body: { model: { providerID: "demo", id: "second", variant: "low" } }, + }) + model.resolve(new Response(null, { status: 204 })) + await setup.waitFor(() => mutations.length === 3) + expect(mutations[2]).toEqual({ + type: "command", + body: { command: "review", text: "selected input", files: [], agents: [], delivery: "steer" }, + }) + } finally { + agent.resolve(new Response(null, { status: 204 })) + model.resolve(new Response(null, { status: 204 })) + } +}) diff --git a/packages/tui/test/component/dialog-variant.test.tsx b/packages/tui/test/component/dialog-variant.test.tsx new file mode 100644 index 00000000000..a6be4fc705c --- /dev/null +++ b/packages/tui/test/component/dialog-variant.test.tsx @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { DialogVariant } from "../../src/component/dialog-variant" +import { agent, model, renderLocal } from "../fixture/local" + +test("variant picker can explicitly reset an agent variant", async () => { + await using setup = await renderLocal({ + models: [model("first", ["low", "high"])], + agents: [agent("build", { providerID: "provider", id: "first", variant: "high" })], + }) + expect(setup.local.model.variant.current()).toBe("high") + setup.dialog.replace(() => ) + await setup.waitForFrame((frame) => frame.includes("Select variant") && frame.includes("Default")) + await setup.mockInput.typeText("Default") + await setup.renderOnce() + setup.mockInput.pressEnter() + await setup.waitFor(() => setup.dialog.stack.length === 0) + expect(setup.local.model.variant.current()).toBeUndefined() +}) diff --git a/packages/tui/test/context/local-selection.test.tsx b/packages/tui/test/context/local-selection.test.tsx new file mode 100644 index 00000000000..4c7376476ba --- /dev/null +++ b/packages/tui/test/context/local-selection.test.tsx @@ -0,0 +1,189 @@ +import { expect, test } from "bun:test" +import { agent, model, renderLocal, session } from "../fixture/local" +import { json } from "../fixture/tui-client" + +test("cycles all recent models in a stable order in both directions", async () => { + await using setup = await renderLocal({ + models: [model("first"), model("second"), model("third")], + preferences: { recent: ["first", "second", "third"].map((modelID) => ({ providerID: "provider", modelID })) }, + }) + expect(setup.local.model.current()?.modelID).toBe("first") + for (const id of ["second", "third", "first"]) { + setup.local.model.cycle(1) + expect(setup.local.model.current()?.modelID).toBe(id) + } + for (const id of ["third", "second", "first"]) { + setup.local.model.cycle(-1) + expect(setup.local.model.current()?.modelID).toBe(id) + } +}) + +test("uses the last configured model and variant ahead of recents", async () => { + await using setup = await renderLocal({ + models: [model("first"), model("second", ["low", "high"]), model("third")], + preferences: { recent: [{ providerID: "provider", modelID: "third" }] }, + fetch: (url) => { + if (url.pathname === "/api/config") + return json([ + { type: "document", info: { model: "provider/first" } }, + { type: "document", info: { model: { providerID: "provider", model: "second", variant: "high" } } }, + { type: "document", info: {} }, + ]) + }, + }) + expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "second", variant: "high" }) +}) + +test("switching agents restores their model and variant within the session", async () => { + await using setup = await renderLocal({ + models: [model("first", ["low", "high"]), model("second", ["low", "high"]), model("third", ["low", "high"])], + agents: [ + agent("build", { providerID: "provider", id: "first", variant: "high" }), + agent("plan", { providerID: "provider", id: "second", variant: "low" }), + ], + sessions: [session("ses_first", { providerID: "provider", id: "first", variant: "low" })], + }) + await setup.data.session.sync("ses_first") + setup.route.navigate({ type: "session", sessionID: "ses_first" }) + expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" }) + setup.local.agent.move(1) + expect(setup.local.agent.current()?.id).toBe("plan") + expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "second", variant: "low" }) + setup.local.model.set({ providerID: "provider", modelID: "third" }) + setup.local.model.variant.set("high") + setup.local.agent.move(-1) + expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" }) + setup.local.agent.set("plan") + expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "third", variant: "high" }) +}) + +test("agent and model drafts are isolated across sessions and survive navigation", async () => { + await using setup = await renderLocal({ + models: [model("first", ["low", "high"]), model("second", ["low", "high"])], + agents: [agent("build"), agent("plan", { providerID: "provider", id: "second" })], + sessions: [ + session("ses_first", { providerID: "provider", id: "first", variant: "low" }), + session("ses_second", { providerID: "provider", id: "second", variant: "high" }, "plan"), + ], + }) + await Promise.all([setup.data.session.sync("ses_first"), setup.data.session.sync("ses_second")]) + setup.route.navigate({ type: "session", sessionID: "ses_first" }) + setup.local.agent.set("plan") + setup.local.model.variant.set("low") + setup.route.navigate({ type: "session", sessionID: "ses_second" }) + expect(setup.local.agent.current()?.id).toBe("plan") + expect(setup.local.model.variant.current()).toBe("high") + setup.route.navigate({ type: "session", sessionID: "ses_first" }) + expect(setup.local.agent.current()?.id).toBe("plan") + expect(setup.local.model.variant.current()).toBe("low") + setup.local.agent.set("build") + expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" }) +}) + +test("falls back from an unavailable session model without changing durable state", async () => { + const selected = { providerID: "provider", id: "missing", variant: "high" } + await using setup = await renderLocal({ + models: [model("first", ["low", "high"]), model("second")], + agents: [agent("build", { providerID: "provider", id: "first", variant: "low" })], + sessions: [session("ses_first", selected)], + }) + await setup.data.session.sync("ses_first") + setup.route.navigate({ type: "session", sessionID: "ses_first" }) + expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" }) + expect(setup.local.model.available()).toBe(true) + expect(setup.data.session.get("ses_first")?.model).toEqual(selected) +}) + +test("a manual agent switch supersedes the CLI agent after its commit", async () => { + await using setup = await renderLocal({ + args: { agent: "build" }, + agents: [agent("build"), agent("plan")], + sessions: [session("ses_first", { providerID: "provider", id: "first" })], + fetch: selectionMessage, + }) + await setup.data.session.sync("ses_first") + setup.route.navigate({ type: "session", sessionID: "ses_first" }) + setup.local.agent.set("plan") + await publishSelection(setup, "plan", "first") + expect(setup.local.agent.current()?.id).toBe("plan") + setup.route.navigate({ type: "home" }) + setup.route.navigate({ type: "session", sessionID: "ses_first" }) + expect(setup.local.agent.current()?.id).toBe("plan") +}) + +test("a late inactive-agent acknowledgment preserves its choice after the active agent commits", async () => { + await using setup = await renderLocal({ + models: [model("first"), model("second"), model("third")], + agents: [agent("build"), agent("plan", { providerID: "provider", id: "second" })], + sessions: [session("ses_first", { providerID: "provider", id: "first" })], + fetch: selectionMessage, + }) + await setup.data.session.sync("ses_first") + setup.route.navigate({ type: "session", sessionID: "ses_first" }) + setup.local.agent.set("plan") + setup.local.model.set({ providerID: "provider", modelID: "third" }) + setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "third" }, "plan") + setup.local.agent.set("build") + await publishSelection(setup, "plan", "third") + setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "first" }, "build") + await publishSelection(setup, "build", "first") + setup.local.agent.set("plan") + expect(setup.local.model.current()?.modelID).toBe("third") +}) + +test("same-model agent switches clear drafts without a model acknowledgment", async () => { + await using setup = await renderLocal({ + models: [model("first"), model("second")], + agents: [agent("build"), agent("plan")], + sessions: [session("ses_first", { providerID: "provider", id: "first" })], + fetch: selectionMessage, + }) + await setup.data.session.sync("ses_first") + setup.route.navigate({ type: "session", sessionID: "ses_first" }) + setup.local.agent.set("plan") + setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "first" }, "plan") + await publishSelection(setup, "plan", "first", false) + await publishSelection(setup, "plan", "second") + expect(setup.local.model.current()?.modelID).toBe("second") +}) + +async function publishSelection( + setup: Awaited>, + agent: string, + modelID: string, + changed = true, +) { + setup.events.emit({ + id: `evt_${crypto.randomUUID()}`, + type: "session.agent.selected", + created: 1, + durable: { aggregateID: "ses_first", seq: 1, version: 1 }, + data: { sessionID: "ses_first", agent }, + }) + if (changed) + setup.events.emit({ + id: `evt_${crypto.randomUUID()}_${modelID}`, + type: "session.model.selected", + created: 2, + durable: { aggregateID: "ses_first", seq: 2, version: 1 }, + data: { sessionID: "ses_first", model: { providerID: "provider", id: modelID } }, + }) + await setup.waitFor(async () => { + await Bun.sleep(10) + const session = setup.data.session.get("ses_first") + return session?.agent === agent && session.model?.id === modelID + }) +} + +function selectionMessage(url: URL) { + if (!url.pathname.includes("/message/")) return + const id = url.pathname.split("/").at(-1)! + return json({ + data: { + id, + type: "model-switched", + model: { providerID: "provider", id: id.split("_").at(-1) }, + time: { created: 2 }, + }, + }) +} diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index e2f1e45f75a..5cbdfccd1ad 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -1,12 +1,5 @@ import { expect, test } from "bun:test" -import { parseModel, recentModels } from "../../src/context/local" - -test("parses model IDs containing slashes", () => { - expect(parseModel("provider/family/model")).toEqual({ - providerID: "provider", - modelID: "family/model", - }) -}) +import { recentModels } from "../../src/context/local" test("moves a model to the front, deduplicates, and limits recents", () => { const recent = Array.from({ length: 12 }, (_, index) => ({ diff --git a/packages/tui/test/fixture/app.ts b/packages/tui/test/fixture/app.ts new file mode 100644 index 00000000000..25a37410c7c --- /dev/null +++ b/packages/tui/test/fixture/app.ts @@ -0,0 +1,58 @@ +import { createTestRenderer } from "@opentui/core/testing" +import { Effect, FileSystem } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/util/global" +import type { TuiInput } from "../../src/app" +import type { Config } from "../../src/config" +import { createEventStream, createFetch, type FetchHandler } from "./tui-client" + +export async function createAppFixture( + input: { + width?: number + height?: number + state?: string + config?: Config.Info + args?: TuiInput["args"] + fetch?: FetchHandler + } = {}, +) { + const { run } = await import("../../src/app") + const setup = await createTestRenderer({ + width: input.width ?? 100, + height: input.height ?? 30, + useThread: false, + kittyKeyboard: true, + }) + setup.renderer.start() + const ready = Promise.withResolvers() + const events = createEventStream() + const calls = createFetch(input.fetch, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) }, + packages: { prepare: async () => ({ directory: "" }) }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }), + args: input.args ?? {}, + log: () => {}, + }).pipe( + Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)), + Effect.provide(FileSystem.layerNoop({})), + ), + ) + return { + ...setup, + events, + ready: ready.promise, + async [Symbol.asyncDispose]() { + try { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await task + } finally { + await server.stop() + } + }, + } +} diff --git a/packages/tui/test/fixture/local.tsx b/packages/tui/test/fixture/local.tsx new file mode 100644 index 00000000000..a0033efe097 --- /dev/null +++ b/packages/tui/test/fixture/local.tsx @@ -0,0 +1,149 @@ +import { testRender } from "@opentui/solid" +import type { AgentInfo, ModelInfo, SessionInfo } from "@opencode-ai/client" +import path from "node:path" +import { ConfigProvider } from "../../src/config" +import { ArgsProvider, type Args } from "../../src/context/args" +import { ClientProvider } from "../../src/context/client" +import { DataProvider, useData } from "../../src/context/data" +import { LocalProvider, useLocal } from "../../src/context/local" +import { Keymap } from "../../src/context/keymap" +import { LocationProvider, useLocation } from "../../src/context/location" +import { PermissionProvider } from "../../src/context/permission" +import { RouteProvider, useRoute } from "../../src/context/route" +import { ThemeProvider } from "../../src/context/theme" +import { ToastProvider } from "../../src/ui/toast" +import { DialogProvider, useDialog } from "../../src/ui/dialog" +import type { ModelPreference } from "../../src/model-preference" +import { tmpdir } from "./fixture" +import { createApi, createEventStream, createFetch, directory, json, type FetchHandler } from "./tui-client" +import { TestTuiContexts } from "./tui-environment" +import { createTuiResolvedConfig } from "./tui-runtime" + +export async function renderLocal( + input: { + models?: ModelInfo[] + agents?: AgentInfo[] + sessions?: SessionInfo[] + preferences?: Partial + args?: Args + fetch?: FetchHandler + } = {}, +) { + const temporary = await tmpdir() + await Bun.write(path.join(temporary.path, "model.json"), JSON.stringify(input.preferences ?? {})) + const events = createEventStream() + const calls = createFetch(async (url, request) => { + const response = await input.fetch?.(url, request) + if (response) return response + const location = { directory: url.searchParams.get("location[directory]") ?? directory } + if (url.pathname === "/api/agent") return json({ location, data: input.agents ?? [agent("build")] }) + if (url.pathname === "/api/model") return json({ location, data: input.models ?? [model("first")] }) + const session = input.sessions?.find((session) => url.pathname === `/api/session/${session.id}`) + if (session) return json({ data: session }) + }, events) + let local!: ReturnType + let route!: ReturnType + let data!: ReturnType + let location!: ReturnType + let dialog!: ReturnType + + function Probe() { + local = useLocal() + route = useRoute() + data = useData() + location = useLocation() + dialog = useDialog() + return + } + + const setup = await testRender( + () => ( + + + + + ({}) }}> + + + + + + + + + + + + + + + + + + + + + + + ), + { width: 100, height: 30, kittyKeyboard: true }, + ) + await setup.waitFor(() => local !== undefined && local.model.ready) + await data.location.sync() + return { + ...setup, + local, + route, + data, + location, + dialog, + events, + state: temporary.path, + async [Symbol.asyncDispose]() { + setup.renderer.destroy() + await temporary[Symbol.asyncDispose]() + }, + } +} + +export function model(id: string, variants: string[] = []): ModelInfo { + return { + id, + modelID: id, + providerID: "provider", + name: id, + status: "active", + enabled: true, + capabilities: { input: ["text"], output: ["text"], tools: true }, + cost: [], + limit: { context: 10000, output: 1000 }, + time: { released: 0 }, + variants: variants.map((id) => ({ id })), + } +} + +export function agent(id: string, selected?: AgentInfo["model"]): AgentInfo { + return { + id, + name: id, + model: selected, + mode: "primary", + hidden: false, + permissions: [], + request: { settings: {}, headers: {}, body: {} }, + } +} + +export function session(id: string, selected?: SessionInfo["model"], agent = "build"): SessionInfo { + return { + id, + agent, + model: selected, + title: id, + location: { directory }, + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } +} diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index 2a157caeb82..8eef52a8ed3 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -134,6 +134,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType { unrelated: { keep: true }, recent: [{ providerID: "openai", modelID: "gpt-5" }], favorite: [], - variant: { "openai/gpt-5": "high" }, + variant: { "openai/gpt-5": "high", default: "default" }, }) }) @@ -41,5 +41,8 @@ test("atomically serializes patches and variant updates", async () => { await repository.saveVariant(openai, "default") expect(await repository.resolveVariant(openai)).toBeUndefined() - expect((await Bun.file(file).json()).variant).toEqual({ "anthropic/claude/sonnet": "low" }) + expect((await Bun.file(file).json()).variant).toEqual({ + "openai/org/gpt-5": "default", + "anthropic/claude/sonnet": "low", + }) })