From 06714e599859ccc742197224b96a1371916da58f Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 16:21:54 +0200 Subject: [PATCH] Keep unresolved session identity separate from Strong fallback --- components/model-picker.tsx | 26 +++++++++++++---------- tests/model-catalog-setup.test.tsx | 33 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/components/model-picker.tsx b/components/model-picker.tsx index b618e73..0b8e46d 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -43,7 +43,9 @@ export function catalogLimits(catalog: Pick): Ma export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) { const { catalog, refresh } = useModelCatalog(); const [models, setModels] = useState(); - const [session, setSession] = useState(); + // A successful read with no settings is a legacy session; undefined + // means no session record is known yet, not permission to inherit Strong. + const [session, setSession] = useState<{ settings?: ChatSettings }>(); const [open, setOpen] = useState<"model" | "thinking" | null>(null); const [error, setError] = useState(null); const containerRef = useRef(null); @@ -58,8 +60,8 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod const readRecord = useCallback(() => { if (chatId) { fetch(`/api/chats/${chatId}`) - .then((r) => r.json() as Promise<{ settings?: ChatSettings }>) - .then((body) => setSession(body.settings)) + .then((r) => { if (!r.ok) throw new Error(`session read failed: ${r.status}`); return r.json() as Promise<{ settings?: ChatSettings }>; }) + .then((body) => setSession({ settings: body.settings })) .catch(() => { /* same read-only posture as a models-section failure */ }); @@ -83,15 +85,17 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod }); // Dual-bound truth: chat mode displays the session's own record, - // falling back to the strong slot for legacy unstamped sessions — - // EXACTLY what resolveConversationSpec runs. Draft mode is the strong - // slot itself. + // falling back to Strong only for a successfully read legacy unstamped + // session — EXACTLY what resolveConversationSpec runs. An unresolved or + // failed record read must not report a provisional global preference as + // this session's identity (including to the Send guard). Draft mode is + // the strong slot itself. const strong = models?.strong; const override: ChatSettings | undefined = chatId - ? { - model: session?.model ?? strong?.model, - thinkingLevel: session?.thinkingLevel ?? strong?.thinkingLevel, - } + ? session ? { + model: session.settings?.model ?? strong?.model, + thinkingLevel: session.settings?.thinkingLevel ?? strong?.thinkingLevel, + } : undefined : strong; const effectiveId = override?.model?.id; const effectiveProvider = override?.model?.provider; @@ -195,7 +199,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod return; } const stored = (await res.json().catch(() => null)) as { settings?: ChatSettings } | null; - setSession(stored?.settings ?? next); + setSession({ settings: stored?.settings ?? next }); setOpen(null); } catch { setError("Could not reach the server — try again."); diff --git a/tests/model-catalog-setup.test.tsx b/tests/model-catalog-setup.test.tsx index 4583863..a426ae2 100644 --- a/tests/model-catalog-setup.test.tsx +++ b/tests/model-catalog-setup.test.tsx @@ -233,6 +233,39 @@ test("blocked session recovery opens its ordinary picker, never changes Strong o assert.equal(selected, `other:${b.id}`); assert.deepEqual(globals, { strong: body.slots.strong }); }); +for (const result of ["override", "legacy", "http-failure", "network-failure"] as const) test(`known catalog never enforces provisional Strong on a pending session; session read ${result}`, async () => { + installFetch(); const available = { ...b, provider: "other" }; const body = catalog(); + body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }]; + // Global Strong is unavailable, but this existing session owns a valid + // model. Resolve global settings first while keeping its record pending. + const globals: ModelsSettings = { strong: { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" } }; + let resolveSession!: (response: Response) => void; + let rejectSession!: (reason: Error) => void; + const sessionRead = new Promise((resolve, reject) => { resolveSession = resolve; rejectSession = reject; }); + const requestsOnly = globalThis.fetch; + globalThis.fetch = async (input, init) => { + if (input === "/api/settings") return Response.json({ settings: { models: globals } }); + if (input === "/api/chats/fixture") return sessionRead; + return requestsOnly(input, init); + }; + await warm(body); const identities: Array = []; + await render(<> { identities.push(id); }} />); + assert.ok(snapshot.catalog, "catalog is already known, not the deferred unknown-catalog case"); + assert.equal(element("stats-model").textContent, "—▾"); + assert.equal(identities.at(-1), undefined); + assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), false, "pending session cannot block Send using global Strong"); + await act(async () => { + if (result === "network-failure") rejectSession(new Error("fixture connection failure")); + else if (result === "http-failure") resolveSession(Response.json({ error: "fixture unavailable" }, { status: 503 })); + else resolveSession(Response.json(result === "legacy" ? {} : { settings: { model: { provider: available.provider, id: available.id }, thinkingLevel: "medium" } })); + }); + const expected = result === "legacy" ? `${a.provider}:${a.id}` : result === "override" ? `other:${b.id}` : undefined; + assert.equal(identities.at(-1), expected); + assert.equal(selectedModelUnavailable(snapshot.catalog, identities.at(-1)), result === "legacy", "only a confirmed legacy record may inherit the unavailable Strong pin"); + assert.equal(document.querySelector('[data-testid="model-menu"]'), null); + assert.deepEqual(writes, []); +}); + test("availability and setup completeness never conflate missing metadata, disconnected providers or saved identities", () => { const body = catalog(false); assert.equal(savedSlotsConfigured(body.slots), true);