From 419dcf1ba29749ed3491b627b780f5c94a08713b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 16:12:13 +0200 Subject: [PATCH] Restore exclusive provider-first setup recovery --- components/chat.tsx | 51 ++++++------ components/home.tsx | 27 +++--- components/new-session.tsx | 26 +++--- components/setup-cta.tsx | 32 +++++++- components/setup-dialog.tsx | 45 +++++----- components/stats-bar.tsx | 11 ++- tests/model-catalog-setup.test.tsx | 127 ++++++++++++++++++++++++----- 7 files changed, 222 insertions(+), 97 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index cef946d..952abf5 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -5,7 +5,7 @@ import { useChatSession } from "@/components/session"; import { visibleQueuedSteers } from "@/components/session-core"; import { SetupDialog } from "@/components/setup-dialog"; import { Hint } from "@/components/ui/hint"; -import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta"; import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom"; import { Conversation, @@ -78,8 +78,6 @@ import { } from "@/src/core"; import { ReadingThinkingField } from "@/components/reading-thinking"; import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block"; -import { slotsConfigured, type ModelsSettings } from "@/src/core"; -import { useSettingsSaved } from "@/components/settings-signal"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ /** @@ -555,30 +553,18 @@ export function Chat({ textRef.current = v; setText(v); }, []); - // The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the - // refusal interception share one SetupDialog mount. `configured` is - // seeded server-side (slotsConfigured on the record) and flips on Save. - const [configured, setConfigured] = useState(initialConfigured); - // The zero-keys truth (zero-keys wave): no provider key = the pill - // takes precedence over the models CTA (keys come first in the chain). - // Reads once on mount, re-reads on the "keys" signal. - const zeroKeys = useZeroKeys(); - // Cross-surface freshness (fix wave): another surface's models write — - // setup save, composer pick, a settings write — re-reads the record so - // the setup CTA appears/disappears live (it used to lag until reload). - useSettingsSaved((section) => { - if (section !== "models") return; - fetch("/api/settings") - .then((r) => r.json() as Promise<{ settings?: { models?: ModelsSettings } | null } | null>) - .then((body) => setConfigured(slotsConfigured(body?.settings?.models))) - .catch(() => {}); - }); + // Saved slot completeness is live, but the session's selected route + // comes from its own ordinary model control, never today's Strong pin. + const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured); + const catalogRef = useRef(catalog); + catalogRef.current = catalog; + const [selectedModel, setSelectedModel] = useState(); const [setupOpen, setSetupOpen] = useState(false); /** The last explicitly-sent draft (composer send): the * refusal flow restores it into the composer, where it waits for the * learner to re-send (Save never auto-sends, owner 2026-10-09). * Auto-continuations (probe answers) never set it — their refusal - * opens the dialog with no replay. */ + * opens recovery with no replay. */ const draftRef = useRef(null); // THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's @@ -594,8 +580,9 @@ export function Chat({ if (!error) return; const missing = parseModelsRefusal(error.message); // The key-refusal twin (zero-keys wave): a pinned model whose - // provider key is gone gets the SAME treatment — revert-draft and - // open setup (the dialog derives to its first-key stage on its own). + // provider key is gone also restores the draft. With another provider + // connected, repair belongs to the ordinary current-session picker; + // otherwise setup derives its exclusive provider/loading stage. const keyRefused = parseProviderKeyRefusal(error.message); if (!missing && !keyRefused) return; // a real error keeps the honest banner // ORDER MATTERS: clearError() flips this effect's own dep (`error`) @@ -615,7 +602,8 @@ export function Chat({ draftRef.current = null; } clearError(); - setSetupOpen(true); + if (keyRefused && catalogRef.current?.providers.some((p) => p.keyPresent)) openModelPicker(); + else setSetupOpen(true); })(); return () => { cancelled = true; @@ -1146,6 +1134,14 @@ export function Chat({ if (busy || pending || compacting) return; const t = textRef.current.trim(); if (!t) return; + if (zeroKeys) { + setSetupOpen(true); + return; + } + if (selectedModelUnavailable(catalog, selectedModel)) { + openModelPicker(); + return; // No optimistic send and no change to the learner's draft. + } draftRef.current = t; // the refusal flow's preserve-and-replay seam applyText(""); void send({ type: "text", text: t }); @@ -1605,6 +1601,7 @@ export function Chat({ compactionRunning={compacting} onSubmitThinking={submitThinking} chatId={chatId} + onModelChange={setSelectedModel} plan={hasPlan ? : undefined} onOpenPlan={() => setPlanOpen(true)} requiresReveal={requiresReveal} @@ -1708,7 +1705,6 @@ export function Chat({ onClose={() => setSetupOpen(false)} onDone={() => { setSetupOpen(false); - setConfigured(true); // Save only saves (owner ruling 2026-10-09): the draft went // home into the composer at refusal time and STAYS there — // the learner sends it themselves. @@ -1843,6 +1839,7 @@ function DockOverlay({ composer, onSubmitAnswer, chatId, + onModelChange, onReserve, plan, onOpenPlan, @@ -1879,6 +1876,7 @@ function DockOverlay({ onSubmitAnswer: (output: QuestionAnswerData) => void; /** The model control's pass-through (the stats bar hosts it). */ chatId: string; + onModelChange: (model: string | undefined) => void; /** Reports the dock's settled height (block + gradient) — ChatView's * dockReserve state feeds the transcript's spacer from it. */ onReserve: (h: number) => void; @@ -2331,6 +2329,7 @@ function DockOverlay({ pricing={pricing} dictationSlot={dictationSlot} chatId={chatId} + onModelChange={onModelChange} /> {/* The scroll-down disc (moved here from the Conversation, owner refinement round 2026-09-24): anchored to the BLOCK's diff --git a/components/home.tsx b/components/home.tsx index 4b16d62..6644bc6 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -7,7 +7,7 @@ import { GraphView } from "@/components/graph-view"; import { MinimalHome } from "@/components/minimal-home"; import { SetupDialog } from "@/components/setup-dialog"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; -import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta"; import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; @@ -41,15 +41,8 @@ export function Home({ // start until the user has picked all three slots — no defaults for // 33 providers. The dock shows the CTA while unconfigured; submitting // a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save - // stores the slots and the queued message sends). - const [configured, setConfigured] = useState(initialConfigured); - // The zero-keys truth (zero-keys wave): no provider key = the pill - // takes precedence over the models CTA (keys come first in the chain). - const zeroKeys = useZeroKeys(); - // The strong slot's keyless truth (pre-send key guards, owner interlude - // 2026-09-25): keys may exist generally while the STRONG slot's pinned - // provider has none — the same skip-everything treatment on send. - const strongKeyless = useStrongKeyless(); + // stores slots only; the learner sends the message themselves). + const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured); const [setupOpen, setSetupOpen] = useState(false); // Voice dictation (STT wave, 2026-09-17): finals append at the end of the // topic draft; the strip lives in the stats bar's middle slot. @@ -123,13 +116,18 @@ export function Home({ // models case guards BEFORE creation; the key cases must too — a // refused first send would otherwise leave an orphan error-shell // chat in the sidebar (the 503 lands only after POST /api/chats - // already created the record). Same skip-everything-and-open-setup - // treatment; the server's 503 gate remains the belt for /chat/[id] - // sessions. - if (zeroKeys || strongKeyless) { + // already created the record). Zero providers opens connection setup; + // an unavailable selection with another provider opens its ordinary + // picker. The server's admission gate remains the belt. + if (zeroKeys) { setSetupOpen(true); return; } + const strong = catalog?.slots.strong?.model; + if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) { + openModelPicker(); + return; // Preserve the draft and never create an orphan session. + } setBusy(true); try { // The map context (owner, 2026-10-04): picks in EITHER mode ride @@ -326,7 +324,6 @@ export function Home({ open={setupOpen} onClose={() => setSetupOpen(false)} onDone={() => { - setConfigured(true); setSetupOpen(false); // Save only saves: the draft waits in the composer. }} diff --git a/components/new-session.tsx b/components/new-session.tsx index c2a0930..2672340 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -8,7 +8,7 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; import { SetupDialog } from "@/components/setup-dialog"; -import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta"; import { StatsBar } from "@/components/stats-bar"; import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; @@ -275,14 +275,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi // 33 providers, chats cannot start. The draft stays in the textarea; // Save stores the slots and never sends (owner ruling 2026-10-09) — // the learner sends the draft themselves. - const [configured, setConfigured] = useState(initialConfigured); - // The zero-keys truth (zero-keys wave): no provider key = the pill - // takes precedence over the models CTA (keys come first in the chain). - const zeroKeys = useZeroKeys(); - // The strong slot's keyless truth (pre-send key guards, owner interlude - // 2026-09-25): keys may exist generally while the STRONG slot's pinned - // provider has none — the same skip-everything treatment on send. - const strongKeyless = useStrongKeyless(); + const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured); const [setupOpen, setSetupOpen] = useState(false); const send = useCallback(() => { @@ -295,12 +288,18 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi // The pre-send KEY guards (owner interlude, no-orphan-chats): guard // BEFORE startDraftSession creates the record — a refused first // send would otherwise leave an orphan error-shell chat in the - // sidebar. Same skip-everything-and-open-setup treatment; the - // server's 503 gate remains the belt for /chat/[id] sessions. - if (zeroKeys || strongKeyless) { + // sidebar. Zero providers opens connection setup; an unavailable + // selection with another provider opens the ordinary Strong picker. + // The server's admission gate remains the belt. + if (zeroKeys) { setSetupOpen(true); return; } + const strong = catalog?.slots.strong?.model; + if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) { + openModelPicker(); + return; // The learner replaces it explicitly, then sends again. + } setBusy(true); void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined) .then((target) => { @@ -308,7 +307,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi else setBusy(false); }) .catch(() => setBusy(false)); - }, [busy, router, configured, zeroKeys, strongKeyless]); + }, [busy, router, configured, zeroKeys, catalog]); return ( <> @@ -369,7 +368,6 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi open={setupOpen} onClose={() => setSetupOpen(false)} onDone={() => { - setConfigured(true); setSetupOpen(false); // Save only saves (owner ruling 2026-10-09): the draft // waits in the composer; the learner sends it. diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index de59b98..299715d 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -15,9 +15,10 @@ * its own save), the same re-read pattern chat.tsx uses for "models". */ -import type { AuthKeyStatus } from "@/src/core"; +import { useEffect, useState } from "react"; +import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core"; import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section"; -import { useModelCatalog } from "@/components/model-picker"; +import { useModelCatalog } from "@/components/model-catalog"; import type { ModelCatalogResponse } from "@/src/core"; /** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses @@ -32,6 +33,33 @@ export function hasProviderKey( return classifyKeys(statuses, providers).providerRows.some((s) => s.present); } +/** Completeness is saved identity, not current provider availability. */ +export function savedSlotsConfigured(slots: ModelCatalogResponse["slots"] | undefined): boolean { + return MODEL_SLOTS.every((slot) => !!slots?.[slot]?.model); +} + +/** Keep the last known completeness during connection invalidation, but + * follow ordinary slot repairs as soon as their shared snapshot arrives. */ +export function useModelSetup(initialConfigured: boolean) { + const { catalog } = useModelCatalog(); + const [lastConfigured, setLastConfigured] = useState(initialConfigured); + const configured = catalog ? savedSlotsConfigured(catalog.slots) : lastConfigured; + useEffect(() => { if (catalog) setLastConfigured(savedSlotsConfigured(catalog.slots)); }, [catalog]); + return { catalog, configured, zeroKeys: catalog ? !catalog.providers.some((p) => p.keyPresent) : false }; +} + +/** A known unavailable route needs an explicit replacement, never an + * automatic provider/model substitution. Unknown metadata defers to the + * server's admission gate instead of declaring a route disconnected. */ +export function selectedModelUnavailable(catalog: ModelCatalogResponse | undefined, selected: string | undefined): boolean { + return !!catalog && !!selected && !catalog.models.some((m) => `${m.provider}:${m.id}` === selected); +} + +/** Reuse the ordinary control (also opened by the existing keyboard action). */ +export function openModelPicker(): void { + window.dispatchEvent(new CustomEvent("lh:kbd-open-model-picker")); +} + /** The zero-keys truth for the calling page: TRUE only after a * successful read says no provider key exists (pending and failed * reads render nothing — the pill must never flash on a gap, and a diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 641898f..053f3eb 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -24,8 +24,9 @@ * Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog * (keyed providers only), the vision slot filtered to image-capable * models, in the app's shared dropdown language (ModelMenuList). - * Saved disconnected selections stay visible; Add provider opens the - * connection body when no provider remains. Refreshes preserve edits. + * Saved disconnected selections and edits are retained internally in + * the exclusive provider stage. Connecting with all slots already saved + * closes setup; otherwise the pickers require only the missing slots. * All three required (owner: no skip, no defaults); Save PUTs the * models section and reports onDone(models). * @@ -45,6 +46,7 @@ import { ModelMenuList } from "@/components/settings/model-menu"; import { MenuShell } from "@/components/menu-shell"; import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; import { useModelCatalog } from "@/components/model-catalog"; +import { savedSlotsConfigured } from "@/components/setup-cta"; import { Hint } from "@/components/ui/hint"; import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { ProviderMenuList } from "@/components/settings/model-menu"; @@ -247,12 +249,11 @@ export function SetupDialog(props: SetupDialogProps) { function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { const backdropDismiss = useBackdropDismiss(onClose); const { catalog, failed, refresh } = useModelCatalog(); - const [lastBody, setLastBody] = useState(catalog); - const body = catalog ?? lastBody; + const providerStageSeen = useRef(false); + const connectionCloseRequested = useRef(false); const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots)); const [levels, setLevels] = useState(() => prefillLevels(catalog)); const editedSlots = useRef(new Set()); - const [showKeys, setShowKeys] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null); @@ -273,8 +274,8 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { // The keys signal (zero-keys wave): keys-section saves/deletes AND // THIS dialog's own first-key save dispatch it; the fetch effect below - // re-runs on it, so saving a key flips the stage to the pickers with - // the widened catalog — no restart, no Settings round-trip. + // re-runs on it, so saving a key widens the catalog. Incomplete saved + // slots advance to pickers; complete saved slots just close setup. const [keysTick, setKeysTick] = useState(0); useSettingsSaved((section) => { if (section === "keys") setKeysTick((t) => t + 1); @@ -291,7 +292,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { useEffect(() => { if (!catalog) return; - setLastBody(catalog); const pre = prefillFromSlots(catalog.models, catalog.slots); const preLevels = prefillLevels(catalog); // Fresh metadata is always used, but a response cannot reset an @@ -310,9 +310,18 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { } return next; }); - if (catalog.providers.some((p) => p.keyPresent)) setShowKeys(false); }, [catalog]); + const stage = setupStage(catalog?.providers ?? null); + const closeAfterConnection = stage === "pickers" && providerStageSeen.current && savedSlotsConfigured(catalog?.slots); + useEffect(() => { + if (stage === "keys") providerStageSeen.current = true; + if (closeAfterConnection && !connectionCloseRequested.current) { + connectionCloseRequested.current = true; + onClose(); // Connection recovery is not a model-settings save. + } + }, [stage, closeAfterConnection, onClose]); + // Esc + outside-click — layered dismissal, closable WITHOUT // configuring (browsing stays possible; the gate is the send // interception, not this dialog). An open slot menu eats Esc first @@ -354,14 +363,16 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) { }; }, [onClose, openMenu, keyMenuOpen]); - const hasSelections = Object.keys(picked).length > 0 || SLOT_ORDER.some((slot) => body?.slots?.[slot]?.model); - const stage = hasSelections ? "pickers" : setupStage(body ? body.providers : null); + // Do not briefly paint model pickers between successful connection and + // closing an already-configured setup. No saved model/thinking writes. + if (closeAfterConnection) return null; + const allPicked = SLOT_ORDER.every((s) => picked[s]); /** Stage 1's save: PUT the pasted key, dispatch the keys signal, and - * CLEAN the local screen — the signal re-fetch widens the catalog and - * the stage flips to the pickers on its own; the dialog stays mounted - * the whole way. Shape-gate 400s keep their user-facing line inline. */ + * CLEAN the local key field. The fresh catalog decides whether to + * close recovery or advance to the missing model slots. Shape-gate + * 400s keep their user-facing line inline. */ async function saveKey() { if (!keyProvider || !keyValue.trim() || keySaving) return; setKeySaving(true); @@ -465,10 +476,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {

Pick the model for each tier. All three are required before chats can start.

- {!body?.providers.some((p) => p.keyPresent) && <> - - {showKeys && keysBody} - } {SLOT_ORDER.map((slot) => { const entry = picked[slot]; const level = levels[slot]; @@ -481,7 +488,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
{SLOT_META[slot].label} - {!body?.slots?.[slot]?.model && !picked[slot] && ( + {!catalog?.slots?.[slot]?.model && !picked[slot] && ( void; /** The dictation strip (STT wave, 2026-09-17): the row's middle is dead * space between the session data and the model control — the mic * button lives there, and while recording the meter unfurls to fill @@ -55,6 +58,10 @@ export function StatsBar({ }) { const [open, setOpen] = useState(false); const [selectedModel, setSelectedModel] = useState(); + const modelChanged = useCallback((model: string | undefined) => { + setSelectedModel(model); + onModelChange?.(model); + }, [onModelChange]); // Same dropdown discipline as the model control (owner, df4433f treatment, // stats round 1): close on any pointerdown outside the bar or on Escape. const barRef = useRef(null); @@ -192,7 +199,7 @@ export function StatsBar({ {/* The model + thinking control — one controller for the conversation agents (prober/teacher); the record's `turns` above still folds the ACTUAL models billed, regardless of what the picker says. */} - +
); } diff --git a/tests/model-catalog-setup.test.tsx b/tests/model-catalog-setup.test.tsx index 43db670..4583863 100644 --- a/tests/model-catalog-setup.test.tsx +++ b/tests/model-catalog-setup.test.tsx @@ -9,6 +9,8 @@ import { AgentSection } from "../components/settings/agent-section.tsx"; import { providerKeyLabel, classifyKeys } from "../components/settings/keys-section.tsx"; import { useModelCatalog } from "../components/model-catalog.ts"; import { notifySettingsSaved } from "../components/settings-signal.ts"; +import { useModelSetup, savedSlotsConfigured, selectedModelUnavailable, openModelPicker } from "../components/setup-cta.tsx"; +import { StatsBar } from "../components/stats-bar.tsx"; import type { ModelCatalogEntry, ModelCatalogResponse, ModelsSettings } from "../src/core/index.ts"; const dom = parseHTML(""); @@ -40,7 +42,7 @@ function catalog(connected = true): ModelCatalogResponse { }; } function Reader() { snapshot = useModelCatalog(); return null; } -function setup() { return {}} onDone={() => {}} />; } +function setup(onClose = () => {}, onDone = (_models: ModelsSettings) => {}) { return ; } function installFetch() { requests = []; writes = []; globalThis.fetch = async (input, init) => { @@ -107,22 +109,27 @@ test("cached setup is immediately usable; fresh metadata/untouched slots update }); }); -test("disconnect invalidates choices, not saved selections or thinking; all-disconnected setup offers Add provider", async () => { - installFetch(); await warm(); await render(<>{setup()}); await answer(1, catalog()); +test("zero providers always means exclusive connection stage; reconnect with all saved slots only closes, even if every selection remains unavailable", async () => { + installFetch(); await warm(); + let closed = 0, completed = 0; + await render(<>{setup(() => { closed++; }, () => { completed++; })}); await answer(1, catalog()); await click("setup-trigger-strong"); - assert.ok(document.querySelector('[data-testid="model-option-claude-opus-5-5"]')); await act(async () => notifySettingsSaved("keys")); - assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null, "stale choices disappear before the fresh request resolves"); + assert.ok(element("setup-stage-loading")); + assert.equal(document.querySelector('[data-testid="model-option-claude-opus-5-5"]'), null); await answer(2, catalog(false)); - assert.equal(element("setup-trigger-strong").textContent, "model:free▾"); - assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); - assert.ok(element("setup-add-provider")); await click("setup-add-provider"); assert.ok(element("setup-stage-keys")); - await click("setup-thinking-strong"); await click("thinking-option-xhigh"); - assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾"); - await click("setup-save"); assert.equal(writes[0]?.strong?.thinkingLevel, "xhigh"); assert.equal(writes[0]?.strong?.model?.id, a.id); + assert.ok(element("setup-stage-keys")); + assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null); + assert.equal(document.querySelector('[data-testid="setup-add-provider"]'), null); + assert.equal(document.querySelector('[data-testid="setup-save"]'), null); + await act(async () => notifySettingsSaved("keys")); + const other = catalog(false); other.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }]; + await answer(3, other); + assert.equal(closed, 1); assert.equal(completed, 0); assert.deepEqual(writes, []); + assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null, "no intermediate picker frame after reconnect"); }); -test("cold reconnect checks saved pins before choosing the key stage, and reopening gets a new draft", async () => { +test("cold saved disconnected pins still enter the provider stage, without any model-selector frame", async () => { installFetch(); await warm(); await act(async () => notifySettingsSaved("keys")); const stages: Array = []; @@ -132,14 +139,47 @@ test("cold reconnect checks saved pins before choosing the key stage, and reopen return null; } await render(<>{setup()}); - assert.ok(element("setup-stage-loading")); await answer(1, catalog(false)); - assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-pickers"]); - await click("setup-thinking-strong"); await click("thinking-option-xhigh"); - await render(); - await render(<>{setup()}); - assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾", "a reopened draft uses saved state, not the abandoned edit"); - await answer(2, catalog(false)); + assert.deepEqual(stages, ["setup-stage-loading", "setup-stage-keys"]); +}); + +for (const count of [0, 1, 2]) test(`connecting with ${count} saved slots only requires the missing slots; stale identities/thinking survive`, async () => { + installFetch(); const before = catalog(false); + if (count < 1) before.slots.strong = null; + if (count < 2) before.slots.cheap = null; + before.slots.vision = null; + if (before.slots.strong) before.slots.strong.thinkingLevel = "xhigh"; + await warm(before); + let closed = 0; await render(<>{setup(() => { closed++; })}); await answer(1, before); + assert.ok(element("setup-stage-keys")); + const connected = { ...before, models: [{ ...b, provider: "other" }], providers: [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }] }; + await act(async () => notifySettingsSaved("keys")); await answer(2, connected); + assert.ok(element("setup-stage-pickers")); assert.equal(document.querySelector('[data-testid="setup-stage-keys"]'), null); assert.equal(closed, 0); + for (const slot of ["strong", "cheap", "vision"] as const) { + if (before.slots[slot]) continue; + await click(`setup-trigger-${slot}`); await click("model-option-claude-opus-5-5"); + } + if (count > 0) assert.equal(element("setup-thinking-strong").textContent?.trim(), "xhigh▾"); + await click("setup-save"); + for (const slot of ["strong", "cheap", "vision"] as const) { + if (before.slots[slot]) assert.deepEqual(writes[0]?.[slot], before.slots[slot]); + else assert.equal(writes[0]?.[slot]?.model?.provider, "other"); + } +}); + +test("a partial setup's model/thinking edits survive exclusive provider recovery, but reopening discards the abandoned draft", async () => { + installFetch(); const before = catalog(); before.slots.cheap = null; before.slots.vision = null; + await warm(before); await render(<>{setup()}); await answer(1, before); + await click("setup-trigger-strong"); await click("model-option-claude-opus-5-5"); + await click("setup-thinking-strong"); await click("thinking-option-medium"); + await act(async () => notifySettingsSaved("keys")); await answer(2, { ...before, models: [], providers: [] }); + assert.ok(element("setup-stage-keys")); assert.equal(document.querySelector('[data-testid="setup-stage-pickers"]'), null); + await act(async () => notifySettingsSaved("keys")); await answer(3, before); + assert.equal(element("setup-trigger-strong").textContent, "claude-opus-5-5▾"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "medium▾"); + await render(); await render(<>{setup()}); + assert.equal(element("setup-trigger-strong").textContent, "model:free▾"); + assert.equal(element("setup-thinking-strong").textContent?.trim(), "low▾"); await answer(4, before); }); test("Agent opens with cached thinking metadata; refresh and disconnect never clear saved levels", async () => { @@ -155,6 +195,55 @@ test("Agent opens with cached thinking metadata; refresh and disconnect never cl await click("thinking-option-xhigh"); assert.equal(changed?.strong?.thinkingLevel, "xhigh"); assert.equal(changed?.strong?.model?.id, a.id); }); +test("saved completeness follows ordinary repairs and survives temporary connection invalidation", async () => { + installFetch(); let state: ReturnType; + function StateReader() { state = useModelSetup(false); return null; } + await render(); await answer(0, catalog()); + assert.equal(state!.configured, true); + await act(async () => notifySettingsSaved("keys")); assert.equal(state!.configured, true); + const partial = catalog(false); partial.slots.vision = null; + await answer(1, partial); assert.equal(state!.configured, false); assert.equal(state!.zeroKeys, true); + await act(async () => notifySettingsSaved("models")); await answer(2, catalog(false)); + assert.equal(state!.configured, true); assert.equal(state!.zeroKeys, true, "saved is not connected"); +}); + +test("blocked session recovery opens its ordinary picker, never changes Strong or substitutes a model", async () => { + installFetch(); const available = { ...b, provider: "other" }; const body = catalog(); + body.models = [available]; body.providers = [{ id: "other", envVar: "OTHER_KEY", keyPresent: true }]; + body.slots.strong = { model: { provider: "other", id: b.id }, thinkingLevel: "medium" }; + const globals: ModelsSettings = { strong: body.slots.strong }; + const session = { model: { provider: a.provider, id: a.id }, thinkingLevel: "xhigh" }; + const requestsOnly = globalThis.fetch; const sessionWrites: unknown[] = []; + globalThis.fetch = async (input, init) => { + if (input === "/api/settings") return Response.json({ settings: { models: globals } }); + if (input === "/api/chats/fixture") return Response.json({ settings: session }); + if (input === "/api/chats/fixture/settings") { const next = JSON.parse(String(init?.body)); sessionWrites.push(next); return Response.json({ settings: next }); } + return requestsOnly(input, init); + }; + await warm(body); let selected: string | undefined; + await render(<> { selected = id; }} />); + assert.equal(selected, `${a.provider}:${a.id}`, "current session does not inherit a different Strong pin"); + assert.equal(selectedModelUnavailable(body, selected), true); + assert.deepEqual(sessionWrites, []); + await act(async () => openModelPicker()); + assert.ok(element("model-menu")); assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null); + assert.equal(document.querySelector('[data-testid="model-option-vendor_model_free"]'), null); + await click("model-option-claude-opus-5-5"); + assert.deepEqual(sessionWrites, [{ model: { provider: "other", id: b.id }, thinkingLevel: "xhigh" }]); + assert.equal(selected, `other:${b.id}`); assert.deepEqual(globals, { strong: body.slots.strong }); +}); + +test("availability and setup completeness never conflate missing metadata, disconnected providers or saved identities", () => { + const body = catalog(false); + assert.equal(savedSlotsConfigured(body.slots), true); + assert.equal(selectedModelUnavailable(body, `${a.provider}:${a.id}`), true); + assert.equal(selectedModelUnavailable(undefined, `${a.provider}:${a.id}`), false); + assert.equal(selectedModelUnavailable(body, undefined), false); + body.models = [b]; body.providers = [{ ...provider, keyPresent: true }]; + assert.equal(selectedModelUnavailable(body, `${b.provider}:${b.id}`), false); + body.slots.cheap = null; assert.equal(savedSlotsConfigured(body.slots), false); +}); + test("shared refresh fences late failure as well as late success, and Retry clears a current error", async () => { installFetch(); await warm(); await act(async () => snapshot.refresh()); await act(async () => snapshot.refresh());