From 29494bbf714ec1fec543914ed6d8cda9780fbd26 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 27 Jul 2026 15:29:45 +0200 Subject: [PATCH] tui: hydrate model after catalog loads Session model was applied once on prompt mount, before the location-scoped catalog finished loading, so startup could pick a fallback or miss durable model changes. Hydrate reactively once the active location's models are valid. --- packages/tui/src/component/dialog-model.tsx | 8 +- packages/tui/src/component/prompt/index.tsx | 7 +- packages/tui/src/component/use-connected.tsx | 4 +- packages/tui/src/context/local.tsx | 53 +++++++--- packages/tui/src/context/location.tsx | 4 + packages/tui/test/app-lifecycle.test.tsx | 105 +++++++++++++++++-- 6 files changed, 149 insertions(+), 32 deletions(-) diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index f998a28417..1efbeb08b2 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -7,16 +7,20 @@ import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" import { useData } from "../context/data" +import { useLocation } from "../context/location" export function DialogModel(props: { providerID?: string }) { const local = useLocal() const data = useData() + const location = useLocation() const dialog = useDialog() const [query, setQuery] = createSignal("") const connected = useConnected() - const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item]))) - const models = createMemo(() => data.location.model.list() ?? []) + const providers = createMemo( + () => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])), + ) + const models = createMemo(() => data.location.model.list(location.ref) ?? []) const showExtra = createMemo(() => connected() && !props.providerID) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index ff4b516a88..be71e61365 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -360,7 +360,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. Model hydration is + // reactive in LocalProvider so it can wait for the location catalog. let syncedSessionID: string | undefined createEffect(() => { const sessionID = props.sessionID @@ -369,10 +370,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/component/use-connected.tsx b/packages/tui/src/component/use-connected.tsx index 4fdbe4a57c..ad94e22b94 100644 --- a/packages/tui/src/component/use-connected.tsx +++ b/packages/tui/src/component/use-connected.tsx @@ -1,8 +1,10 @@ import { createMemo } from "solid-js" import { useData } from "../context/data" +import { useLocation } from "../context/location" import { hasConnectedProvider } from "../util/connected-provider" export function useConnected() { const data = useData() - return createMemo(() => hasConnectedProvider(data.location.integration.list() ?? [])) + const location = useLocation() + return createMemo(() => hasConnectedProvider(data.location.integration.list(location.ref) ?? [])) } diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 98faea2f9d..78d002fd32 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -22,6 +22,7 @@ import { useToast } from "../ui/toast" 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("/") @@ -56,11 +57,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const args = useArgs() const event = useEvent() const permission = usePermission() + const location = useLocation() + + const models = () => data.location.model.list(location.ref) function isModelValid(model: ModelPreferenceModel) { - return !!data.location.model - .list() - ?.some((item) => item.providerID === model.providerID && item.id === model.modelID) + return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID) } function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) { @@ -73,9 +75,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ function createAgent() { const agents = createMemo(() => - (data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden), + (data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden), + ) + const visibleAgents = createMemo(() => + (data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden), ) - const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden)) const [agentStore, setAgentStore] = createStore({ current: undefined as string | undefined, }) @@ -190,7 +194,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - const model = data.location.model.list()?.[0] + const model = models()?.[0] if (!model) return undefined return { providerID: model.providerID, @@ -209,6 +213,33 @@ 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 + if (args.model) { + const requested = parseModel(args.model) + if (isModelValid(requested)) 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", modelPreferenceKey(model), normalizeModelVariant(selected.variant)) + }) + }) + return { current: currentModel, get ready() { @@ -229,10 +260,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ reasoning: false, } } - const provider = data.location.provider.list()?.find((item) => item.id === value.providerID) - const info = data.location.model - .list() - ?.find((item) => item.providerID === value.providerID && item.id === value.modelID) + const provider = data.location.provider.list(location.ref)?.find((item) => item.id === value.providerID) + const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID) return { provider: provider?.name ?? value.providerID, model: info?.name ?? value.modelID, @@ -340,9 +369,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ list() { const m = currentModel() if (!m) return [] - const info = data.location.model - .list() - ?.find((item) => item.providerID === m.providerID && item.id === m.modelID) + const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID) return info?.variants?.map((variant) => variant.id) ?? [] }, set(value: string | undefined) { diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index c539d95013..43785dacb5 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -4,6 +4,7 @@ import { useClient } from "./client" import { useData } from "./data" const context = createContext<{ + readonly ref: LocationRef | undefined readonly current: LocationGetOutput | undefined set: (location?: LocationRef) => void }>() @@ -34,6 +35,9 @@ export function LocationProvider(props: ParentProps) { return ( { +test("session startup waits for catalogs and follows durable model changes", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) const events = createEventStream() const cwd = process.cwd() - const location = { directory: cwd, project: { id: "project", directory: cwd } } + const sessionDirectory = `${cwd}/session` + const location = (directory: string) => ({ directory, project: { id: "project", directory } }) const session = { id: "dummy", title: "Demo session", projectID: "project", - location: { directory: cwd }, + location: { directory: sessionDirectory }, agent: "build", - model: { providerID: "provider", id: "model" }, + model: { providerID: "provider", id: "model", variant: "high" }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 0, updated: 0 }, } const bodies: unknown[] = [] + const modelSwitches: unknown[] = [] + const locationRequested = Promise.withResolvers() + const targetLocation = Promise.withResolvers() + const catalogRequested = Promise.withResolvers() + const catalog = Promise.withResolvers() const promptSubmitted = Promise.withResolvers() + const secondPromptSubmitted = Promise.withResolvers() const calls = createFetch(async (url, request) => { - if (url.pathname === "/api/location") return json(location) + const directory = url.searchParams.get("location[directory]") ?? cwd + if (url.pathname === "/api/location") { + if (directory === sessionDirectory) { + locationRequested.resolve() + await targetLocation.promise + } + return json(location(directory)) + } if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (url.pathname === "/api/session/dummy") return json({ data: session }) if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/message/msg_model_gamma") + return json({ + data: { + id: "msg_model_gamma", + type: "model-switched", + previous: session.model, + model: { providerID: "provider", id: "gamma", variant: "medium" }, + time: { created: 1 }, + }, + }) if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) if (url.pathname === "/api/agent") return json({ - location, + location: location(directory), data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }], }) - if (url.pathname === "/api/model") + if (url.pathname === "/api/model") { + if (directory !== sessionDirectory) + return json({ + location: location(directory), + data: [{ id: "fallback", providerID: "provider", name: "Fallback", variants: [] }], + }) + catalogRequested.resolve() + await catalog.promise return json({ - location, - data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }], + location: location(directory), + data: [ + { id: "model", providerID: "provider", name: "Model", variants: [{ id: "high" }] }, + { id: "fallback", providerID: "provider", name: "Fallback", variants: [] }, + { id: "gamma", providerID: "provider", name: "Gamma", variants: [{ id: "medium" }] }, + ], }) + } + if (url.pathname === "/api/session/dummy/model") { + modelSwitches.push(await request.json()) + return new Response(null, { status: 204 }) + } if (url.pathname === "/api/session/dummy/prompt") { bodies.push(await request.json()) promptSubmitted.resolve() + if (bodies.length === 2) secondPromptSubmitted.resolve() return json({ data: {} }) } }, events) @@ -196,6 +237,22 @@ test("session startup prompt is submitted exactly once", async () => { }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), ) + await locationRequested.promise + await Promise.race([ + promptSubmitted.promise.then(() => { + throw new Error("startup prompt was submitted before its location loaded") + }), + Bun.sleep(100), + ]) + targetLocation.resolve() + await catalogRequested.promise + await Promise.race([ + promptSubmitted.promise.then(() => { + throw new Error("startup prompt was submitted before its model catalog loaded") + }), + Bun.sleep(100), + ]) + catalog.resolve() await Promise.race([ promptSubmitted.promise, Bun.sleep(2000).then(() => { @@ -203,12 +260,38 @@ test("session startup prompt is submitted exactly once", async () => { }), ]) await Bun.sleep(20) - setup.renderer.destroy() - await task expect(bodies).toHaveLength(1) expect(bodies[0]).toMatchObject({ text: "RESUME_READY" }) + expect(modelSwitches).toEqual([]) + + events.emit({ + id: "evt_model_gamma", + created: 1, + type: "session.model.selected", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + location: session.location, + data: { + sessionID: session.id, + model: { providerID: "provider", id: "gamma", variant: "medium" }, + }, + }) + await Bun.sleep(20) + await setup.mockInput.typeText("SECOND") + setup.mockInput.pressEnter() + await Promise.race([ + secondPromptSubmitted.promise, + Bun.sleep(2000).then(() => { + throw new Error("second prompt was not submitted") + }), + ]) + expect(modelSwitches).toEqual([]) + await Bun.sleep(20) + setup.renderer.destroy() + await task } finally { + targetLocation.resolve() + catalog.resolve() if (!setup.renderer.isDestroyed) setup.renderer.destroy() await server.stop() mock.restore()